Author SHA1 Message Date
jb 8991682e59 feat: consume selected mechanic revisions 2026-09-02 23:32:42 -04:00
jb 7e392a1f37 feat: persist authored mechanic revisions 2026-09-02 15:39:50 -04:00
jb 98f7f51e46 feat: add authored mechanic contracts 2026-08-26 23:50:03 -04:00
jb b53e296ef5 chore: ignore feature worktrees
Validate and deploy production / validate (push) Canceled after 0s
Validate and deploy production / deploy (push) Canceled after 0s
Validate and deploy staging / validate (push) Successful in 33s
Validate and deploy staging / deploy (push) Canceled after 0s
2026-08-26 21:49:11 -04:00
jb fdbf811808 docs: add agent and domain guidance
Validate and deploy production / validate (push) Canceled after 0s
Validate and deploy production / deploy (push) Canceled after 0s
Validate and deploy staging / validate (push) Successful in 33s
Validate and deploy staging / deploy (push) Canceled after 0s
2026-08-26 21:48:07 -04:00
jb 1fba43272a chore: configure Paseo workspace 2026-08-22 14:01:39 -04:00
jb 18e13ca80c cleanup
Validate and deploy production / validate (push) Canceled after 0s
Validate and deploy production / deploy (push) Canceled after 0s
Validate and deploy staging / validate (push) Successful in 38s
Validate and deploy staging / deploy (push) Canceled after 0s
2026-08-21 23:42:46 -04:00
jb 8c021c134f Update mechanic authoring persistence 2026-08-20 23:07:14 -04:00
jb 29d26bd036 Keep production services online after restarts
Validate and deploy production / validate (push) Canceled after 0s
Validate and deploy production / deploy (push) Canceled after 0s
Validate and deploy staging / validate (push) Failing after 27s
Validate and deploy staging / deploy (push) Skipped
2026-08-19 02:14:39 -04:00
jb 4777a3abd5 feat: refine builder skill chip flows
Validate and deploy production / validate (push) Canceled after 0s
Validate and deploy production / deploy (push) Canceled after 0s
Validate and deploy staging / validate (push) Successful in 44s
Validate and deploy staging / deploy (push) Successful in 1m14s
2026-08-06 01:00:15 -04:00
jb 46f64d37b6 fix: serialize article autosaves
Validate and deploy production / validate (push) Canceled after 0s
Validate and deploy production / deploy (push) Canceled after 0s
Validate and deploy staging / validate (push) Successful in 33s
Validate and deploy staging / deploy (push) Successful in 1m11s
2026-08-04 12:45:29 -04:00
jb 7ca6cb8458 Updating mechanics for OS protocols
Validate and deploy production / validate (push) Canceled after 0s
Validate and deploy production / deploy (push) Canceled after 0s
Validate and deploy staging / validate (push) Successful in 33s
Validate and deploy staging / deploy (push) Successful in 1m9s
2026-08-03 16:12:20 -04:00
jb 57068b61cd feat: expand ISAC content modeling
Validate and deploy production / validate (push) Canceled after 0s
Validate and deploy production / deploy (push) Canceled after 0s
Validate and deploy staging / validate (push) Successful in 28s
Validate and deploy staging / deploy (push) Successful in 1m3s
2026-08-03 01:23:20 -04:00
269 changed files with 44440 additions and 21133 deletions
-29
View File
@@ -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"
}
]
}
]
}
}
+4
View File
@@ -42,3 +42,7 @@ tmp/
# Python caches
__pycache__/
*.py[cod]
.pi
.mesh
.worktrees/
-35
View File
@@ -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.
-68
View File
@@ -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.
-39
View File
@@ -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.
-37
View File
@@ -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.
-45
View File
@@ -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.
-41
View File
@@ -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.
-43
View File
@@ -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.
-38
View File
@@ -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.
-37
View File
@@ -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.
-47
View File
@@ -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.
-37
View File
@@ -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.
-41
View File
@@ -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.
-38
View File
@@ -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.
-32
View File
@@ -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.
-35
View File
@@ -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.
-38
View File
@@ -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.
-37
View File
@@ -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.
-36
View File
@@ -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.
-39
View File
@@ -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.
-35
View File
@@ -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.
-40
View File
@@ -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.
-39
View File
@@ -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.
+44 -10
View File
@@ -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.
## 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
Run in order after source changes:
```
```bash
npm run check # svelte-kit sync + svelte-check (typecheck)
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
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
After changing source code, styles, configuration, dependencies, or routes,
restart the development server before handing work back.
1. Inspect running processes and stop only this project's development-server
process tree.
2. Start one hidden `npm run dev` process from the repository root. Do not leave
duplicate project development servers running.
3. Confirm the server accepts an HTTP request before completing the task.
4. Leave the verified development server running so the project owner can review
the changes.
1. Start `npm run dev` from the repository root with `bg_run`, using
`isAgent: false` and a descriptive task name. Retain its task ID and output
path.
2. Before starting a replacement, stop the existing tracked development-server
task with `bg_kill <task-id>` so only one server runs.
3. Confirm the task logs show the server started, then confirm it accepts an
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 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
@@ -245,7 +279,7 @@ database. The command refuses to merge into a populated database.
## 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.
3. Apply the migration or maintenance operation. Verify schema state, record
counts, and critical entities.
+271
View File
@@ -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
+319 -30
View File
@@ -769,15 +769,12 @@ CREATE TABLE IF NOT EXISTS stat_attributes (
weapon_min REAL,
weapon_max REAL,
weapon_reference_level INTEGER NOT NULL DEFAULT 50 CHECK(weapon_reference_level BETWEEN 1 AND 50),
skill_chip_min REAL,
skill_chip_max REAL,
cap_value REAL,
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
CHECK((gear_min IS NULL AND gear_max IS NULL) OR (gear_min IS NOT NULL AND gear_max IS NOT NULL AND gear_min <= gear_max)),
CHECK((weapon_min IS NULL AND weapon_max IS NULL) OR (weapon_min IS NOT NULL AND weapon_max IS NOT NULL AND weapon_min <= weapon_max)),
CHECK((skill_chip_min IS NULL AND skill_chip_max IS NULL) OR (skill_chip_min IS NOT NULL AND skill_chip_max IS NOT NULL AND skill_chip_min <= skill_chip_max)),
CHECK(cap_value IS NULL OR cap_value >= 0)
);
CREATE UNIQUE INDEX IF NOT EXISTS stat_attributes_name_unit_nocase_idx ON stat_attributes(name COLLATE NOCASE, unit);
@@ -906,6 +903,7 @@ CREATE TABLE IF NOT EXISTS conditional_effect_outcomes (
delivery_mode TEXT NOT NULL DEFAULT 'instant' CHECK(delivery_mode IN ('instant','continuous')),
delivery_duration_seconds REAL CHECK(delivery_duration_seconds IS NULL OR delivery_duration_seconds>0),
targeting_json TEXT,
probability_percent REAL CHECK(probability_percent IS NULL OR (probability_percent>=0 AND probability_percent<=100)),
min_state_value REAL,
max_state_value REAL,
description_slot INTEGER CHECK(description_slot IS NULL OR description_slot>=1),
@@ -928,6 +926,266 @@ CREATE TABLE IF NOT EXISTS conditional_effect_outcomes (
);
CREATE INDEX IF NOT EXISTS conditional_effect_outcomes_rule_idx
ON conditional_effect_outcomes(rule_id,variant_key,outcome_key,sort_order);
-- Phase 2 mechanic persistence. Revision rows are append-only by application
-- convention. No database triggers enforce immutability.
CREATE TABLE IF NOT EXISTS mechanic_authored_sources (
id TEXT PRIMARY KEY,
owner_type TEXT NOT NULL CHECK(owner_type IN (
'talent','os_protocol_talent','skill_chip_talent','specialization_tree_rank'
)),
owner_id TEXT NOT NULL CHECK(length(trim(owner_id))>0),
template TEXT NOT NULL CHECK(length(trim(template))>0),
authored_contract_version INTEGER NOT NULL CHECK(authored_contract_version>0),
source_fingerprint TEXT NOT NULL CHECK(
length(source_fingerprint)=64
AND lower(source_fingerprint)=source_fingerprint
AND source_fingerprint NOT GLOB '*[^0-9a-f]*'
),
talent_scope TEXT CHECK(talent_scope IS NULL OR talent_scope IN ('standard','exotic')),
talent_has_tier_ii INTEGER CHECK(talent_has_tier_ii IS NULL OR talent_has_tier_ii IN (0,1)),
specialization_rank_description TEXT,
specialization_effect_id TEXT,
specialization_effect_template TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(owner_type,owner_id),
CHECK(
(owner_type='talent'
AND talent_scope IS NOT NULL
AND specialization_rank_description IS NULL
AND specialization_effect_id IS NULL
AND specialization_effect_template IS NULL)
OR
(owner_type='specialization_tree_rank'
AND talent_scope IS NULL
AND talent_has_tier_ii IS NULL
AND specialization_rank_description IS NOT NULL
AND length(trim(specialization_rank_description))>0
AND specialization_effect_id IS NOT NULL
AND length(trim(specialization_effect_id))>0
AND specialization_effect_template IS NOT NULL
AND length(trim(specialization_effect_template))>0)
OR
(owner_type IN ('os_protocol_talent','skill_chip_talent')
AND talent_scope IS NULL
AND talent_has_tier_ii IS NULL
AND specialization_rank_description IS NULL
AND specialization_effect_id IS NULL
AND specialization_effect_template IS NULL)
)
);
CREATE INDEX IF NOT EXISTS mechanic_authored_sources_owner_idx
ON mechanic_authored_sources(owner_type,owner_id);
CREATE TABLE IF NOT EXISTS mechanic_authored_variants (
source_id TEXT NOT NULL REFERENCES mechanic_authored_sources(id) ON DELETE CASCADE,
variant_key TEXT NOT NULL CHECK(length(trim(variant_key))>0),
materialized TEXT NOT NULL CHECK(length(materialized)>0),
sort_order INTEGER NOT NULL CHECK(sort_order>=0),
PRIMARY KEY(source_id,variant_key),
UNIQUE(source_id,sort_order)
);
CREATE INDEX IF NOT EXISTS mechanic_authored_variants_source_order_idx
ON mechanic_authored_variants(source_id,sort_order);
CREATE TABLE IF NOT EXISTS mechanic_authored_slots (
source_id TEXT NOT NULL,
variant_key TEXT NOT NULL,
slot_key TEXT NOT NULL CHECK(length(trim(slot_key))>0),
display_value TEXT NOT NULL CHECK(
display_value=trim(display_value)
AND display_value GLOB '*[0-9]*'
AND display_value NOT GLOB '*[^0-9.+-]*'
AND substr(display_value,2) NOT GLOB '*[+-]*'
AND length(display_value)-length(replace(display_value,'.',''))<=1
AND substr(display_value,-1)<>'.'
AND display_value NOT IN ('+','-','.','+.','-.')
),
sort_order INTEGER NOT NULL CHECK(sort_order>=0),
unit TEXT,
stat_attribute_id TEXT REFERENCES stat_attributes(id),
PRIMARY KEY(source_id,variant_key,slot_key),
UNIQUE(source_id,variant_key,sort_order),
FOREIGN KEY(source_id,variant_key)
REFERENCES mechanic_authored_variants(source_id,variant_key) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS mechanic_authored_slots_variant_order_idx
ON mechanic_authored_slots(source_id,variant_key,sort_order);
CREATE INDEX IF NOT EXISTS mechanic_authored_slots_attribute_idx
ON mechanic_authored_slots(stat_attribute_id)
WHERE stat_attribute_id IS NOT NULL;
CREATE TABLE IF NOT EXISTS mechanic_model_revisions (
id TEXT PRIMARY KEY,
source_id TEXT NOT NULL REFERENCES mechanic_authored_sources(id) ON DELETE CASCADE,
revision_number INTEGER NOT NULL CHECK(revision_number>0),
source_fingerprint TEXT NOT NULL CHECK(
length(source_fingerprint)=64
AND lower(source_fingerprint)=source_fingerprint
AND source_fingerprint NOT GLOB '*[^0-9a-f]*'
),
mechanic_contract_version INTEGER NOT NULL CHECK(mechanic_contract_version>0),
program_hash TEXT NOT NULL CHECK(
length(program_hash)=64
AND lower(program_hash)=program_hash
AND program_hash NOT GLOB '*[^0-9a-f]*'
),
simulation_readiness TEXT NOT NULL CHECK(simulation_readiness IN ('ready','requires_assumptions','captured_only')),
scenario_inputs_json TEXT NOT NULL DEFAULT '[]' CHECK(json_valid(scenario_inputs_json)),
source_variants_json TEXT NOT NULL DEFAULT '[]' CHECK(json_valid(source_variants_json)),
program_name TEXT NOT NULL CHECK(length(trim(program_name))>0),
origin TEXT NOT NULL CHECK(length(trim(origin))>0),
actor_id TEXT REFERENCES users(id) ON DELETE SET NULL,
created_at TEXT NOT NULL,
UNIQUE(source_id,revision_number),
UNIQUE(source_id,id)
);
CREATE INDEX IF NOT EXISTS mechanic_model_revisions_source_created_idx
ON mechanic_model_revisions(source_id,revision_number DESC,created_at DESC);
CREATE INDEX IF NOT EXISTS mechanic_model_revisions_fingerprint_idx
ON mechanic_model_revisions(source_id,source_fingerprint);
CREATE TABLE IF NOT EXISTS mechanic_model_revision_pools (
id TEXT PRIMARY KEY,
revision_id TEXT NOT NULL REFERENCES mechanic_model_revisions(id) ON DELETE CASCADE,
pool_key TEXT NOT NULL CHECK(length(trim(pool_key))>0),
label TEXT NOT NULL CHECK(length(trim(label))>0),
state_kind TEXT NOT NULL CHECK(state_kind IN ('flag','count','stack','duration','scalar','target_mark','resource')),
state_scope TEXT NOT NULL CHECK(state_scope IN ('agent','target')),
initial_value REAL NOT NULL,
minimum_value REAL,
maximum_value REAL,
best_case_value REAL,
duration_seconds REAL CHECK(duration_seconds IS NULL OR duration_seconds>0),
duration_variants_json TEXT CHECK(duration_variants_json IS NULL OR json_valid(duration_variants_json)),
sort_order INTEGER NOT NULL CHECK(sort_order>=0),
UNIQUE(revision_id,id),
UNIQUE(revision_id,pool_key),
UNIQUE(revision_id,sort_order),
CHECK(minimum_value IS NULL OR maximum_value IS NULL OR maximum_value>=minimum_value),
CHECK(best_case_value IS NULL OR minimum_value IS NULL OR best_case_value>=minimum_value),
CHECK(best_case_value IS NULL OR maximum_value IS NULL OR best_case_value<=maximum_value),
CHECK(NOT (duration_seconds IS NOT NULL AND duration_variants_json IS NOT NULL))
);
CREATE INDEX IF NOT EXISTS mechanic_model_revision_pools_revision_order_idx
ON mechanic_model_revision_pools(revision_id,sort_order);
CREATE TABLE IF NOT EXISTS mechanic_model_revision_clauses (
id TEXT PRIMARY KEY,
revision_id TEXT NOT NULL REFERENCES mechanic_model_revisions(id) ON DELETE CASCADE,
clause_key TEXT NOT NULL CHECK(length(trim(clause_key))>0),
label TEXT,
event_kind TEXT NOT NULL CHECK(event_kind IN (
'shot_fired','weapon_hit','skill_deployed','signature_ability_deployed',
'skill_hit','skill_multishot_triggered','agent_hit','health_damage_taken',
'damage_dealt','enemy_killed','assist','ally_downed','agent_downed',
'ally_rescued','grenade_thrown','medical_kit_used','healing_applied',
'extra_health_granted','extra_health_changed','movement_snapshot',
'proximity_snapshot','firing_started','firing_stopped','reload_started',
'reload_completed','weapon_swapped','tick'
)),
recipe_key TEXT,
conditions_json TEXT CHECK(conditions_json IS NULL OR json_valid(conditions_json)),
reducer_json TEXT NOT NULL DEFAULT '{"kind":"none"}' CHECK(json_valid(reducer_json)),
weight_expression_json TEXT CHECK(weight_expression_json IS NULL OR json_valid(weight_expression_json)),
mutations_json TEXT NOT NULL DEFAULT '[]' CHECK(json_valid(mutations_json)),
cooldown_seconds REAL CHECK(cooldown_seconds IS NULL OR cooldown_seconds>0),
proc_chance_percent REAL CHECK(proc_chance_percent IS NULL OR (proc_chance_percent>=0 AND proc_chance_percent<=100)),
requires_assigned_ability_active INTEGER CHECK(requires_assigned_ability_active IS NULL OR requires_assigned_ability_active IN (0,1)),
sort_order INTEGER NOT NULL CHECK(sort_order>=0),
UNIQUE(revision_id,id),
UNIQUE(revision_id,clause_key),
UNIQUE(revision_id,sort_order)
);
CREATE INDEX IF NOT EXISTS mechanic_model_revision_clauses_revision_order_idx
ON mechanic_model_revision_clauses(revision_id,sort_order);
CREATE INDEX IF NOT EXISTS mechanic_model_revision_clauses_event_idx
ON mechanic_model_revision_clauses(event_kind,revision_id,sort_order);
CREATE TABLE IF NOT EXISTS mechanic_model_revision_outcomes (
id TEXT PRIMARY KEY,
revision_id TEXT NOT NULL REFERENCES mechanic_model_revisions(id) ON DELETE CASCADE,
clause_id TEXT NOT NULL,
state_pool_id TEXT,
effect_key TEXT NOT NULL CHECK(length(trim(effect_key))>0),
variant_key TEXT NOT NULL CHECK(length(trim(variant_key))>0),
label TEXT NOT NULL CHECK(length(trim(label))>0),
outcome_kind TEXT NOT NULL CHECK(outcome_kind IN ('attribute_modifier','direct_damage','restoration','resource_cost','weapon_reload','marker')),
effect_scope TEXT NOT NULL CHECK(effect_scope IN ('self','equipped_weapon','inactive_weapon','target','allies','self_and_allies','assigned_ability','all_skills')),
value REAL NOT NULL,
value_expression_json TEXT CHECK(value_expression_json IS NULL OR json_valid(value_expression_json)),
value_basis TEXT NOT NULL CHECK(value_basis IN ('flat','max_health_percent','current_health_percent','weapon_base_percent')),
resource_kind TEXT CHECK(resource_kind IS NULL OR resource_kind IN ('health','armor','ammo','signature_ability_charge')),
delivery_mode TEXT NOT NULL DEFAULT 'instant' CHECK(delivery_mode IN ('instant','continuous')),
delivery_duration_seconds REAL CHECK(delivery_duration_seconds IS NULL OR delivery_duration_seconds>0),
application_phase TEXT NOT NULL CHECK(application_phase IN ('on_event','after_event','on_enter','on_exit','while_active')),
combat_timing TEXT CHECK(combat_timing IS NULL OR combat_timing IN ('pre_event','current')),
stat_attribute_id TEXT REFERENCES stat_attributes(id),
per_stack INTEGER CHECK(per_stack IS NULL OR per_stack IN (0,1)),
min_state_value REAL CHECK(min_state_value IS NULL OR min_state_value>=0),
max_state_value REAL CHECK(max_state_value IS NULL OR max_state_value>=0),
targeting_json TEXT CHECK(targeting_json IS NULL OR json_valid(targeting_json)),
probability_percent REAL CHECK(probability_percent IS NULL OR (probability_percent>=0 AND probability_percent<=100)),
sort_order INTEGER NOT NULL CHECK(sort_order>=0),
UNIQUE(revision_id,id),
UNIQUE(revision_id,clause_id,variant_key,effect_key),
UNIQUE(revision_id,clause_id,variant_key,sort_order),
CHECK(max_state_value IS NULL OR min_state_value IS NULL OR max_state_value>=min_state_value),
CHECK(
(delivery_mode='instant' AND delivery_duration_seconds IS NULL)
OR (delivery_mode='continuous' AND outcome_kind='restoration' AND delivery_duration_seconds IS NOT NULL)
),
CHECK(
(outcome_kind='attribute_modifier' AND stat_attribute_id IS NOT NULL AND resource_kind IS NULL)
OR (outcome_kind IN ('restoration','resource_cost') AND stat_attribute_id IS NULL AND resource_kind IS NOT NULL)
OR (outcome_kind IN ('direct_damage','weapon_reload','marker') AND stat_attribute_id IS NULL AND resource_kind IS NULL)
),
FOREIGN KEY(revision_id,clause_id)
REFERENCES mechanic_model_revision_clauses(revision_id,id) ON DELETE CASCADE,
FOREIGN KEY(revision_id,state_pool_id)
REFERENCES mechanic_model_revision_pools(revision_id,id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS mechanic_model_revision_outcomes_clause_order_idx
ON mechanic_model_revision_outcomes(revision_id,clause_id,variant_key,sort_order);
CREATE INDEX IF NOT EXISTS mechanic_model_revision_outcomes_pool_idx
ON mechanic_model_revision_outcomes(revision_id,state_pool_id)
WHERE state_pool_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS mechanic_model_revision_outcomes_attribute_idx
ON mechanic_model_revision_outcomes(stat_attribute_id)
WHERE stat_attribute_id IS NOT NULL;
CREATE TABLE IF NOT EXISTS mechanic_model_selections (
source_id TEXT PRIMARY KEY REFERENCES mechanic_authored_sources(id) ON DELETE CASCADE,
revision_id TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY(source_id,revision_id)
REFERENCES mechanic_model_revisions(source_id,id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS mechanic_model_selections_revision_idx
ON mechanic_model_selections(revision_id,source_id);
CREATE TABLE IF NOT EXISTS mechanic_public_variants (
source_id TEXT NOT NULL,
variant_key TEXT NOT NULL,
selected_revision_id TEXT,
current_source_fingerprint TEXT NOT NULL CHECK(
length(current_source_fingerprint)=64
AND lower(current_source_fingerprint)=current_source_fingerprint
AND current_source_fingerprint NOT GLOB '*[^0-9a-f]*'
),
authored_contract_version INTEGER NOT NULL CHECK(authored_contract_version>0),
mechanic_contract_version INTEGER CHECK(mechanic_contract_version IS NULL OR mechanic_contract_version>0),
status TEXT NOT NULL CHECK(status IN ('modeled','not_modeled')),
behavior_text TEXT NOT NULL CHECK(length(behavior_text)>0),
display_source TEXT NOT NULL CHECK(display_source IN ('model','authored')),
updated_at TEXT NOT NULL,
PRIMARY KEY(source_id,variant_key),
CHECK(
(display_source='model' AND status='modeled' AND selected_revision_id IS NOT NULL)
OR (display_source='authored' AND status='not_modeled')
),
FOREIGN KEY(source_id,variant_key)
REFERENCES mechanic_authored_variants(source_id,variant_key) ON DELETE CASCADE,
FOREIGN KEY(source_id,selected_revision_id)
REFERENCES mechanic_model_revisions(source_id,id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS mechanic_public_variants_status_idx
ON mechanic_public_variants(status,source_id,variant_key);
CREATE INDEX IF NOT EXISTS mechanic_public_variants_revision_idx
ON mechanic_public_variants(selected_revision_id)
WHERE selected_revision_id IS NOT NULL;
CREATE TABLE IF NOT EXISTS talent_tier_effects (
id TEXT PRIMARY KEY,
talent_tier_id TEXT NOT NULL REFERENCES talent_tiers(id) ON DELETE CASCADE,
@@ -1168,14 +1426,30 @@ CREATE TABLE IF NOT EXISTS skill_chip_two_piece_bonuses (
CHECK(max_value >= min_value)
);
CREATE TABLE IF NOT EXISTS skill_chip_primary_options (
skill_chip_set_id TEXT NOT NULL REFERENCES skill_chip_sets(id) ON DELETE CASCADE,
stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id),
PRIMARY KEY(skill_chip_set_id, stat_attribute_id)
stat_attribute_id TEXT PRIMARY KEY REFERENCES stat_attributes(id)
);
CREATE TABLE IF NOT EXISTS skill_chip_primary_quality_values (
stat_attribute_id TEXT NOT NULL,
quality TEXT NOT NULL CHECK(quality IN ('Standard','Specialized','Superior','High-end')),
min_value REAL NOT NULL,
max_value REAL NOT NULL,
PRIMARY KEY(stat_attribute_id,quality),
FOREIGN KEY(stat_attribute_id) REFERENCES skill_chip_primary_options(stat_attribute_id) ON DELETE CASCADE,
CHECK(max_value>=min_value),
CHECK(min_value>=0)
);
CREATE TABLE IF NOT EXISTS skill_chip_secondary_options (
skill_chip_set_id TEXT NOT NULL REFERENCES skill_chip_sets(id) ON DELETE CASCADE,
stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id),
PRIMARY KEY(skill_chip_set_id, stat_attribute_id)
stat_attribute_id TEXT PRIMARY KEY REFERENCES stat_attributes(id)
);
CREATE TABLE IF NOT EXISTS skill_chip_secondary_quality_values (
stat_attribute_id TEXT NOT NULL,
quality TEXT NOT NULL CHECK(quality IN ('Specialized','Superior','High-end')),
min_value REAL NOT NULL,
max_value REAL NOT NULL,
PRIMARY KEY(stat_attribute_id,quality),
FOREIGN KEY(stat_attribute_id) REFERENCES skill_chip_secondary_options(stat_attribute_id) ON DELETE CASCADE,
CHECK(max_value>=min_value),
CHECK(min_value>=0)
);
CREATE TABLE IF NOT EXISTS skill_chip_talents (
id TEXT PRIMARY KEY,
@@ -1232,19 +1506,21 @@ CREATE TABLE IF NOT EXISTS weapon_mod_quality_values (
CHECK(primary_level_20_max>=primary_level_1_max)
);
CREATE TABLE IF NOT EXISTS weapon_mod_secondary_options (
weapon_mod_id TEXT NOT NULL REFERENCES weapon_mods(id) ON DELETE CASCADE,
stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id),
PRIMARY KEY(weapon_mod_id,stat_attribute_id)
stat_attribute_id TEXT PRIMARY KEY REFERENCES stat_attributes(id)
);
CREATE TABLE IF NOT EXISTS weapon_mod_secondary_quality_values (
weapon_mod_id TEXT NOT NULL,
stat_attribute_id TEXT NOT NULL,
quality TEXT NOT NULL CHECK(quality IN ('Standard','Specialized','Superior','High-end')),
min_value REAL NOT NULL,
max_value REAL NOT NULL,
PRIMARY KEY(weapon_mod_id,stat_attribute_id,quality),
FOREIGN KEY(weapon_mod_id,stat_attribute_id) REFERENCES weapon_mod_secondary_options(weapon_mod_id,stat_attribute_id) ON DELETE CASCADE,
CHECK(max_value>=min_value)
quality TEXT NOT NULL CHECK(quality IN ('Specialized','Superior','High-end')),
level_1_min REAL NOT NULL,
level_1_max REAL NOT NULL,
level_20_min REAL NOT NULL,
level_20_max REAL NOT NULL,
PRIMARY KEY(stat_attribute_id,quality),
FOREIGN KEY(stat_attribute_id) REFERENCES weapon_mod_secondary_options(stat_attribute_id) ON DELETE CASCADE,
CHECK(level_1_max>=level_1_min),
CHECK(level_20_max>=level_20_min),
CHECK(level_20_min>=level_1_min),
CHECK(level_20_max>=level_1_max)
);
CREATE TABLE IF NOT EXISTS weapon_mod_exotic_attributes (
id TEXT PRIMARY KEY,
@@ -1292,19 +1568,21 @@ CREATE TABLE IF NOT EXISTS gear_mod_quality_values (
CHECK(primary_level_20_max>=primary_level_1_max)
);
CREATE TABLE IF NOT EXISTS gear_mod_secondary_options (
gear_mod_id TEXT NOT NULL REFERENCES gear_mods(id) ON DELETE CASCADE,
stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id),
PRIMARY KEY(gear_mod_id,stat_attribute_id)
stat_attribute_id TEXT PRIMARY KEY REFERENCES stat_attributes(id)
);
CREATE TABLE IF NOT EXISTS gear_mod_secondary_quality_values (
gear_mod_id TEXT NOT NULL,
stat_attribute_id TEXT NOT NULL,
quality TEXT NOT NULL CHECK(quality IN ('Standard','Specialized','Superior','High-end')),
min_value REAL NOT NULL,
max_value REAL NOT NULL,
PRIMARY KEY(gear_mod_id,stat_attribute_id,quality),
FOREIGN KEY(gear_mod_id,stat_attribute_id) REFERENCES gear_mod_secondary_options(gear_mod_id,stat_attribute_id) ON DELETE CASCADE,
CHECK(max_value>=min_value)
quality TEXT NOT NULL CHECK(quality IN ('Specialized','Superior','High-end')),
level_1_min REAL NOT NULL,
level_1_max REAL NOT NULL,
level_20_min REAL NOT NULL,
level_20_max REAL NOT NULL,
PRIMARY KEY(stat_attribute_id,quality),
FOREIGN KEY(stat_attribute_id) REFERENCES gear_mod_secondary_options(stat_attribute_id) ON DELETE CASCADE,
CHECK(level_1_max>=level_1_min),
CHECK(level_20_max>=level_20_min),
CHECK(level_20_min>=level_1_min),
CHECK(level_20_max>=level_1_max)
);
CREATE TABLE IF NOT EXISTS specialization_progression_config (
id TEXT PRIMARY KEY CHECK(id='default'),
@@ -1404,6 +1682,17 @@ CREATE TABLE IF NOT EXISTS specialization_ability_attacks (
CHECK(charge_policy<>'none' OR charge_count=1)
);
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 (
id TEXT PRIMARY KEY,
ability_id TEXT NOT NULL REFERENCES specialization_abilities(id) ON DELETE CASCADE,
@@ -1628,7 +1917,7 @@ INSERT OR IGNORE INTO calculation_stages(id,revision_id,stage_key,label,operator
INSERT OR IGNORE INTO calculation_stages(id,revision_id,stage_key,label,operator,display_order,locked_position)
VALUES('calculation-stage-cycle-1','calculation-revision-1','firing_cycle','Firing cycle','firing_cycle',9,'last');
CREATE TABLE IF NOT EXISTS game_data_icons (
entity_type TEXT NOT NULL CHECK(entity_type IN ('talent', 'brand_set', 'os_protocol', 'skill_chip', 'specialization', 'specialization_ability', 'specialization_node')),
entity_type TEXT NOT NULL CHECK(entity_type IN ('talent', 'brand_set', 'os_protocol', 'skill_chip', 'specialization', 'specialization_ability', 'specialization_node', 'weapon_mod_slot')),
entity_id TEXT NOT NULL,
storage_key TEXT NOT NULL UNIQUE,
byte_size INTEGER NOT NULL CHECK(byte_size > 0),
+265
View File
@@ -0,0 +1,265 @@
import { connect, type Database } from '@tursodatabase/database';
import { readFile } from 'node:fs/promises';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
let database: Database;
const applySchema = async (target: Database) => {
const schema = await readFile(new URL('./schema.sql', import.meta.url), 'utf8');
for (const statement of schema
.split(';')
.map((value) => value.trim())
.filter(Boolean)) {
await target.exec(statement);
}
};
const insertSource = async (
target: Database,
id: string,
ownerId: string,
fingerprint: string,
) => {
await (
await target.prepare(
`INSERT INTO mechanic_authored_sources(
id,owner_type,owner_id,template,authored_contract_version,source_fingerprint,
talent_scope,talent_has_tier_ii,created_at,updated_at
) VALUES(?,?,?,?,?,?,?,?,?,?)`,
)
).run(
id,
'talent',
ownerId,
'Gain {1}',
1,
fingerprint,
'standard',
1,
'2026-08-27T00:00:00.000Z',
'2026-08-27T00:00:00.000Z',
);
};
describe('mechanic persistence schema', () => {
beforeEach(async () => {
database = await connect(':memory:');
await database.exec('PRAGMA foreign_keys=ON');
await applySchema(database);
});
afterEach(async () => {
await database.close();
});
it('installs the complete schema with clean foreign keys', async () => {
const rows = await (
await database.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'mechanic_%' ORDER BY name",
)
).all();
expect(rows.map((row) => row.name)).toEqual([
'mechanic_authored_slots',
'mechanic_authored_sources',
'mechanic_authored_variants',
'mechanic_model_revision_clauses',
'mechanic_model_revision_outcomes',
'mechanic_model_revision_pools',
'mechanic_model_revisions',
'mechanic_model_selections',
'mechanic_public_variants',
]);
expect(await (await database.prepare('PRAGMA foreign_key_check')).all()).toEqual([]);
});
it('enforces owner metadata, ordered authored children, and source cascades', async () => {
const fingerprint = 'a'.repeat(64);
await insertSource(database, 'source-1', 'talent-1', fingerprint);
await expect(
insertSource(database, 'source-2', 'talent-1', fingerprint),
).rejects.toThrow();
await expect(
database.exec(
`INSERT INTO mechanic_authored_sources(
id,owner_type,owner_id,template,authored_contract_version,source_fingerprint,
specialization_rank_description,specialization_effect_id,specialization_effect_template,
created_at,updated_at
) VALUES('source-3','talent','talent-3','Gain',1,'${fingerprint}',
'rank','effect','template','now','now')`,
),
).rejects.toThrow();
await (
await database.prepare(
`INSERT INTO mechanic_authored_variants(source_id,variant_key,materialized,sort_order)
VALUES('source-1','I','Gain 1',0),('source-1','II','Gain 2',1)`,
)
).run();
await (
await database.prepare(
`INSERT INTO mechanic_authored_slots(
source_id,variant_key,slot_key,display_value,sort_order
) VALUES('source-1','I','1','1',0)`,
)
).run();
await expect(
database.exec(
`INSERT INTO mechanic_authored_slots(
source_id,variant_key,slot_key,display_value,sort_order
) VALUES('source-1','missing','1','1',0)`,
),
).rejects.toThrow();
await expect(
database.exec(
`INSERT INTO mechanic_authored_slots(
source_id,variant_key,slot_key,display_value,sort_order
) VALUES('source-1','II','1','10% Weapon Damage',0)`,
),
).rejects.toThrow();
await database.exec("DELETE FROM mechanic_authored_sources WHERE id='source-1'");
for (const table of [
'mechanic_authored_variants',
'mechanic_authored_slots',
]) {
expect(
await (
await database.prepare(`SELECT COUNT(*) AS count FROM ${table}`)
).get(),
).toMatchObject({ count: 0 });
}
});
it('allows deliberate duplicate graphs while proving selection ownership', async () => {
const sourceFingerprint = 'a'.repeat(64);
const programHash = 'b'.repeat(64);
await insertSource(database, 'source-1', 'talent-1', sourceFingerprint);
await (
await database.prepare(
`INSERT INTO mechanic_authored_variants(source_id,variant_key,materialized,sort_order)
VALUES('source-1','I','Gain 1',0)`,
)
).run();
await (
await database.prepare(
`INSERT INTO mechanic_model_revisions(
id,source_id,revision_number,source_fingerprint,mechanic_contract_version,
program_hash,simulation_readiness,scenario_inputs_json,source_variants_json,
program_name,origin,created_at
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`,
)
).run(
'revision-1',
'source-1',
1,
sourceFingerprint,
1,
programHash,
'ready',
'[]',
'["I"]',
'Model',
'manual',
'now',
);
await (
await database.prepare(
`INSERT INTO mechanic_model_revisions(
id,source_id,revision_number,source_fingerprint,mechanic_contract_version,
program_hash,simulation_readiness,scenario_inputs_json,source_variants_json,
program_name,origin,created_at
) SELECT 'revision-2',source_id,2,source_fingerprint,mechanic_contract_version,
program_hash,simulation_readiness,scenario_inputs_json,source_variants_json,
program_name,origin,created_at FROM mechanic_model_revisions WHERE id='revision-1'`,
)
).run();
await expect(
database.exec(
`INSERT INTO mechanic_model_revisions(
id,source_id,revision_number,source_fingerprint,mechanic_contract_version,
program_hash,simulation_readiness,scenario_inputs_json,source_variants_json,
program_name,origin,created_at
) SELECT 'revision-3',source_id,1,source_fingerprint,mechanic_contract_version,
program_hash,simulation_readiness,scenario_inputs_json,source_variants_json,
program_name,origin,created_at FROM mechanic_model_revisions WHERE id='revision-1'`,
),
).rejects.toThrow();
await (
await database.prepare(
`INSERT INTO mechanic_model_selections(source_id,revision_id,updated_at)
VALUES('source-1','revision-2','now')`,
)
).run();
await expect(
database.exec(
`INSERT INTO mechanic_model_selections(source_id,revision_id,updated_at)
VALUES('source-1','not-this-source','now')`,
),
).rejects.toThrow();
await (
await database.prepare(
`INSERT INTO mechanic_model_revision_pools(
id,revision_id,pool_key,label,state_kind,state_scope,initial_value,
minimum_value,maximum_value,best_case_value,duration_seconds,
duration_variants_json,sort_order
) VALUES('pool-1','revision-1','stacks','Stacks','stack','agent',0,0,5,5,NULL,NULL,0)`,
)
).run();
await (
await database.prepare(
`INSERT INTO mechanic_model_revision_clauses(
id,revision_id,clause_key,label,event_kind,recipe_key,conditions_json,
reducer_json,weight_expression_json,mutations_json,cooldown_seconds,
proc_chance_percent,requires_assigned_ability_active,sort_order
) VALUES('clause-1','revision-1','on-hit','On hit','weapon_hit',NULL,NULL,
'{"kind":"none"}',NULL,'[]',NULL,25,NULL,0)`,
)
).run();
await (
await database.prepare(
`INSERT INTO mechanic_model_revision_outcomes(
id,revision_id,clause_id,state_pool_id,effect_key,variant_key,label,
outcome_kind,effect_scope,value,value_expression_json,value_basis,
resource_kind,delivery_mode,delivery_duration_seconds,application_phase,
combat_timing,stat_attribute_id,per_stack,min_state_value,max_state_value,
targeting_json,probability_percent,sort_order
) VALUES('outcome-1','revision-1','clause-1','pool-1','mark','I','Mark',
'marker','self',1,NULL,'flat',NULL,'instant',NULL,'while_active','current',
NULL,0,NULL,NULL,NULL,NULL,0)`,
)
).run();
await (
await database.prepare(
`INSERT INTO mechanic_public_variants(
source_id,variant_key,selected_revision_id,current_source_fingerprint,
authored_contract_version,mechanic_contract_version,status,behavior_text,
display_source,updated_at
) VALUES('source-1','I','revision-1',?,1,1,'modeled','Modeled behavior','model','now')`,
)
).run(sourceFingerprint);
await database.exec("DELETE FROM mechanic_authored_sources WHERE id='source-1'");
for (const table of [
'mechanic_authored_variants',
'mechanic_authored_slots',
'mechanic_model_revisions',
'mechanic_model_revision_pools',
'mechanic_model_revision_clauses',
'mechanic_model_revision_outcomes',
'mechanic_model_selections',
'mechanic_public_variants',
]) {
expect(
await (
await database.prepare(`SELECT COUNT(*) AS count FROM ${table}`)
).get(),
).toMatchObject({ count: 0 });
}
});
});
+2 -2
View File
@@ -3,7 +3,7 @@ name: shd-build-production
services:
app:
image: ${SHD_IMAGE:-shd-build-production:local}
restart: unless-stopped
restart: always
init: true
environment:
INFISICAL_PROJECT_ID: 2ee94b27-e599-4194-9797-3372d42e53f6
@@ -42,7 +42,7 @@ services:
scheduler:
image: ${SHD_IMAGE:-shd-build-production:local}
restart: unless-stopped
restart: always
init: true
command:
- scheduler
+5
View File
@@ -142,6 +142,10 @@ describe('container runtime startup', () => {
{ key: 'APP_ORIGIN', value: 'https://exported.invalid' },
{ key: 'DISCORD_CLIENT_ID', value: 'exported-discord-client' },
{ key: 'DISCORD_CLIENT_SECRET', value: 'exported-discord-secret' },
{
key: 'MFA_TOTP_ENCRYPTION_KEYS',
value: 'totp-encryption-keyring',
},
]),
writeExport(sharedExport, [
{ key: 'SCHEDULER_TOKEN', value: schedulerToken },
@@ -220,6 +224,7 @@ describe('container runtime startup', () => {
APP_ORIGIN: 'https://stage.shd.build',
DISCORD_CLIENT_ID: 'exported-discord-client',
DISCORD_CLIENT_SECRET: 'exported-discord-secret',
MFA_TOTP_ENCRYPTION_KEYS: 'totp-encryption-keyring',
ORIGIN: 'https://stage.shd.build',
PASSKEY_RP_ID: 'shd.build',
SCHEDULER_TOKEN: schedulerToken,
+237 -63
View File
@@ -341,17 +341,36 @@ squeezed beside long module headings on mobile.
### Command Grid builder
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
selected cell. 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.
entire build visible in a compact matrix while one connected editor configures
the selected cell. The editor uses two lanes when its container has enough
width. Equipment identity and base values occupy the first lane; allocations,
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
Sets, or Skill Chips. Do not replace those choices with configuration counts.
Selecting an ability opens its three attached Skill Chip positions. Selecting
equipment opens the searchable real-data catalog and its compatible
configuration controls. Do not display a prototype option that the current
loadout schema cannot save.
Matrix cells show the slot and equipped item at rest. The selected cell reveals
its compact Talent, Attribute, Brand Set, or Skill Chip summary. This
progressive disclosure keeps the matrix scannable without replacing configured
choices with counts. Selecting an ability opens its three attached Skill Chip
positions. Selecting equipment opens the searchable real-data catalog and its
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
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
current player errors.
The builder uses three workbench columns on wide screens, moves the live summary
below the matrix and editor at intermediate widths, and stacks all three regions
on narrow screens. Every grid track and editor child must accept content
shrinkage. At 560px and below, stack attribute picker/value pairs and section
headings; keep the command actions in two equal columns. Validation markers use
short visible labels while their full messages remain available to assistive
technology and pointer users.
The builder uses two primary workbench columns on wide screens: the compact
matrix and the two-lane editor. The live summary spans both columns below them.
Narrow screens stack the matrix, editor, and summary. Every grid track and
editor child must accept content shrinkage. At 560px and below, stack Attribute
picker/value pairs and section headings; keep the command actions in two equal
columns. Validation markers use short visible labels while their full messages
remain available to assistive technology and pointer users.
Use `--shd-builder-canvas` for recessed equipment fields. Use
`--shd-builder-selection` with `--shd-builder-selection-border` for the selected
cell. Preserve button semantics, `aria-pressed`, focus-visible outlines, pressed
feedback, and textual readiness states. At narrower desktop widths, keep the
matrix and editor together and move live results below them. On mobile, stack
the matrix, editor, and live results in that order.
feedback, and textual readiness states. On mobile, stack the matrix, editor, and
live results in that order.
The command header contains the required build name, local and account save
actions, account visibility, and optional sharing notes. Sharing notes include a
@@ -878,6 +896,20 @@ error, or danger indicators.
- Tiered records with shared structure use one shared selector and side-by-side
value columns rather than duplicating the entire editor. Template-driven copy
must show live previews for every tier and document its placeholder syntax.
- Talent, OS Protocol Talent, Skill Chip Talent, and Specialization rank-effect
forms use the shared authored-description editor. Place the exact source text
first because it remains authoritative when no model revision is selected.
Put model controls in a separate bordered section with explicit Preserve,
Save, and Clear choices. Keep fixed units and Attribute wording in the
authored template; every token value is numeric and contains only the number
that changes between variants, while the combat model owns Attribute
semantics. The token matrix
keeps one row per varying authored value and one column per variant so
operators can compare substitutions without duplicating the program editor.
On narrow screens, keep the matrix
horizontally scrollable instead of collapsing labels into ambiguous cards.
Show modeled previews, readiness, assumptions, and validation as text; color
may reinforce these states but must not replace their labels.
### Forms
@@ -1572,13 +1604,42 @@ an icon in every option.
### Skill Chip data console
Skill Chip editing uses the dense ISAC staged-definition pattern: identity and
icon, 2-piece numeric bonus, mutually exclusive primary and secondary roll
pools, then 3-piece talent. Superior blue and High-end yellow identify the
supported qualities; reserve orange for actions and the current interactive
state. A four-step segmented preview shows the linear zero-to-three High-end
interpolation without duplicate values. At narrow widths, stack roll-pool and
talent columns, use a two-by-two preview grid, and retain 44-pixel targets.
Place two shared configuration cards above the Skill Chip definition registry.
The `SHARED PRIMARY PROFILE` card contains the fixed Skill Duration, Skill
Radius, Skill Cooldown Recovery, and Skill Intensity rows, with direct minimum
and maximum fields for Standard, Specialized, Superior, and High-end. Its
summary shows the configured count for each quality and the `NOT YET DEFINED`
calculation state. The `SHARED SECONDARY POOL` card contains repeatable
searchable Attribute rows with direct minimum and maximum fields for
Specialized, Superior, and High-end. Its summary shows each quality's fixed
capacity and the shared option count. Each card opens its own editor and closes
that editor after a successful save. Validation keeps the affected editor open
with its values intact. Successful saves append
`game_data/skill_chip_primary_profile.updated` or
`game_data/skill_chip_secondary_pool.updated`.
State the fixed secondary capacities in the pool card and editor: Standard zero,
Specialized one, Superior two, and High-end two. Skill Chip fields never show an
item-level control or level-based preview. Quality labels use the global green,
blue, purple, and yellow treatments; orange remains reserved for actions and
selection. On narrow screens, profile summaries stack, endpoint matrices scroll
inside their cards, and actions retain 44-pixel targets.
Definition editors follow the dense staged pattern: ability assignment, identity
and icon, one per-set 2-piece bonus, then the optional 3-piece Talent. Place the
Talent assignment control before one disclosure that contains the entire Talent
workspace: identity, description, quality-mix preview, and
`MechanicProgramEditor`. The disclosure summary shows the configured Talent name
or `TALENT WORKSPACE`, the quality-scaled outcome count, and an explicit
`EXPAND` or `COLLAPSE` action. Existing Talents start collapsed when an operator
opens a definition. Assigning a Talent or receiving a Talent validation error
opens the workspace. Native disclosure semantics expose the expanded state, keep
draft state mounted while collapsed, and provide a 44-pixel mobile target.
Every equipped chip quality counts toward the two-piece threshold. The set-bonus
preview uses four steps for zero through three equipped High-end chips and the
formula `minimum + (maximum − minimum) × h / 3`; only High-end chips increase
`h`. Do not duplicate calculated values as editable fields.
### ISAC record identity controls
@@ -1796,6 +1857,34 @@ responsive card grid. Record cards use the same hierarchy:
- bordered fact cells for primary values;
- relationship lines and tier descriptions only when applicable.
#### Public mechanic behavior status
Mechanic owners use the shared integrated card hierarchy. The public DTO carries a
stable group ID, owner type, label and optional context, plus explicitly labeled
variants. Each variant provides public behavior text, `modeled` or `not_modeled`
status, and `model` or `authored` display source. Public cards never receive
lifecycle, readiness, revision, fingerprint, or program data.
Every owner group displays one textual `MODELED` or `NOT MODELED` badge beside
its identity. `MODELED` uses the model surface, model border, and blue
informational text. `NOT MODELED` uses neutral borders and muted text. Orange
remains reserved for navigation, focus, and actions. Each behavior block labels
its displayed basis as `MODELED BEHAVIOR` or `AUTHORED DESCRIPTION`.
Talent groups retain Tier I and Tier II variants. Exotic Talents omit Tier II
when the authored source omits it. OS Protocol Talents use a Base variant beside
their existing level and upgrade controls. Skill Chip Talents use Minimum and
Maximum variants in the three-piece section. Specialization tree-rank effects
remain independent groups under their Focus, node, and rank context.
Compact cards visually clamp long behavior text without removing it from the
accessible name or linked detail. Quick views render the same shared card at a
wider reading measure, retain the binary badge, and add `DISPLAY SOURCE` with
`MODELED BEHAVIOR` or `AUTHORED DESCRIPTION`. Global Database results show the
owner category, record name, current public behavior, binary status, and an
explicit registry navigation label. Status is textual at every breakpoint;
mobile places it after identity and before behavior.
When a record represents an ordered progression rather than independent facts,
use the stacked fact treatment. Brand Set bonuses are shown vertically in two-,
three-, then four-piece order; do not add a redundant “3/3 bonuses” badge when
@@ -1808,6 +1897,15 @@ same `weaponBaseRange` curve as the builder; never label the configured base
endpoints as level-50 values. Weapon Talent 1 and Talent 2 pools use separate
clickable icon rails and the standard Talent quick view.
Show each weapon's named Mod slots and slot pictograms between its performance
facts and Talent pools. Native T0 weapons display the complete T1/T2 slot set.
Mark the slot that is absent at T0 with the blue information state and an
explicit `T1+` label so the unlock does not rely on color alone. Other slots use
the neutral telemetry treatment. If ISAC has no slot data, show
`MOD SLOT DATA NOT CONFIGURED`. Do not show the weapon-category bonus count as
`BASE BONUSES`; that identical count does not help players compare weapon
definitions.
Record cards may end with contextual actions that connect related public
registries. Actions use the compact orange tactical-button treatment and encode
their destination filter in the URL. Brand cards provide a “find Gear” action
@@ -1970,18 +2068,26 @@ mode with weapon icons and retained accessible names. Show configured versus
required slot counts in text. Disabled tiers remain visibly unavailable and must
not submit slot assignments.
Quality-scaled registry editors use a compact matrix when the same numeric shape
repeats across all four quality levels. Apply global quality colors only to the
quality labels, retain textual quality names, and keep numeric fields neutral.
On narrow screens the matrix may scroll horizontally while its surrounding
editor and previews stack.
Regular Weapon Mod definitions use a compact Level 1 and Level 20 primary-range
matrix for Standard, Specialized, Superior, and High-end quality. Apply global
quality colors only to the quality labels, retain textual quality names, and
keep numeric fields neutral. On narrow screens the matrix may scroll
horizontally while its surrounding editor and previews stack. Primary core type
remains builder-selected and appears as the three always-available core icons
rather than a persisted ISAC choice. Individual regular Weapon Mod editors also
retain triggerless Mod Effects with one or two Attribute references and fixed
per-quality values.
When two item slots draw from the same canonical option pool, edit that pool
once rather than rendering two duplicated selectors. Weapon Mods use a
repeatable secondary-attribute row: one searchable attribute reference plus four
quality range pairs. Show that the builder selects two distinct options from the
pool. Primary core type is likewise builder-selected and is displayed as the
three always-available core icons rather than a persisted ISAC choice.
Configure one global secondary Attribute pool above the Weapon Mod registry.
Each repeatable row contains one searchable Attribute reference and Level 1 and
Level 20 minimum and maximum fields for Specialized, Superior, and High-end.
Values between those endpoints use linear interpolation. Do not render Standard
range fields because Standard has no secondary slot. State the fixed capacity in
the pool editor and builder: Standard zero, Specialized one, Superior two, and
High-end two. The builder requires distinct selections. Individual Weapon Mod
editors must not repeat or submit pool data. A successful pool save uses the
standard shared-profile lifecycle and creates the dedicated
`game_data/weapon_mod_secondary_pool.updated` audit event.
Exotic Weapon Mod status is a separate single-icon toggle using the exotic icon
and the standard pressed, focus-visible, and selected states. Selecting it must
@@ -1993,12 +2099,20 @@ state panel visible so the change in data semantics is unmistakable.
### Gear Mod registry
Gear Mods reuse the compact quality matrix, level preview, and pooled
secondary-attribute rows established by Weapon Mods. Do not render exotic,
Gear Mods reuse the compact Level 1 and Level 20 primary-range matrix and level
preview established by regular Weapon Mods. Do not render exotic,
weapon-category, slot-type, or Mod Effect controls in this simpler editor.
Present the five primary options as an always-visible informational band because
the actual primary is selected on the build instance, not persisted on the
canonical definition.
the build instance selects the primary; the canonical definition does not store
that selection.
Configure the Gear Mod family’s global secondary Attribute pool above the
registry, separate from individual Gear Mod editors. Use the same three-quality
endpoint rows, linear interpolation, and distinct-selection rule as the Weapon
Mod pool. Capacity is zero for Standard, one for Specialized, and two each for
Superior and High-end. Individual Gear Mod edits must not repeat or submit this
pool. A successful pool save creates the dedicated
`game_data/gear_mod_secondary_pool.updated` audit event.
Use explicit textual tier capacity wherever Gear tier is summarized:
`1 MOD SLOT` for T0 and `2 MOD SLOTS` for T1/T2. On narrow screens the identity
@@ -2229,6 +2343,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
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
Firepower, Toughness, or Engineering icon. Keep the specialization art primary
and place the attribute mark in a smaller corner badge. Mirror ability slots 1
@@ -2311,9 +2432,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
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
remains visible in the generated script. 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 static
remains visible in the generated script. The `DERIVED VALUE` operation writes
the branch reducer's numeric result into the selected pool and displays that
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
fields, reducer output, source-defined scenario controls, and OS Protocol
upgrade scaling where the source permits it. Selecting either Attribute model
@@ -2325,6 +2448,25 @@ applied to the stored value. Restoration outcomes also expose an `INSTANT` or
field and describes the configured value as the total delivered across that
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
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
text readout for the center, valid-target policy, inclusive boundary, and
runtime evidence requirement. Only `ON EVENT` and `AFTER EVENT` remain available
for this mode; selecting it from another phase moves the outcome to
`AFTER EVENT`. Changing the outcome kind or target scope clears an incompatible
area definition. Keep `ENEMIES IN RADIUS · LEGACY` visible for saved descriptive
radius metadata, but do not present that mode as calculated explosion fan-out.
Action Family recipes are optional authoring starters. Present the recipe
catalog as a searchable assignment with only the parameters required by the
selected family. `ADD RECIPE BRANCH` copies typed branches into the current
@@ -2332,13 +2474,15 @@ source program, where authors may edit its exposed branch, state, and outcome
controls. The recipe label records provenance only. `REPLACE PROGRAM` requires
an inline confirmation that states all existing branches will be discarded.
Builder Controls define source-specific scenario assumptions for the planned
player simulation. ISAC authors choose toggle, slider, or numeric input and
provide a label, Agent guidance, default value, best-case value, and numeric
bounds when applicable. The current player UI does not render these controls or
read their saved values. It uses 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
Builder Controls define source-specific scenario assumptions for player
simulation. ISAC authors choose toggle, slider, or numeric input and provide a
label, Agent guidance, default value, best-case value, and numeric bounds when
applicable. The calculation timeline builder can consume values supplied for
these controls, including `allies-within-30m-with-agent-granted-buff`. Current
Builder and shared Build pages do not render the controls or let players change
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.
Use `--shd-model-surface` and `--shd-model-border` for mechanic programs,
@@ -2389,7 +2533,10 @@ The panel presents five explicit states:
polite live region, and disables repeat actions.
- `CLARIFICATION REQUIRED` lists each focused question with its reason and
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
handoff, supported approximation, and optional workaround in separate labeled
blocks. Do not present a capability gap as validation failure.
@@ -2413,6 +2560,13 @@ applying the draft would replace configured branches, pools, controls, or
outcomes. The standard ISAC save lifecycle begins only after the author reviews
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
actions. Blue identifies modeled information, yellow marks assumptions or
unsupported coverage, and red marks request or validation failures. Never use
@@ -2511,10 +2665,11 @@ conditional Talent outcomes, weapon and Skill DPS, encounter assumptions,
activation profiles, rotations, and simulation behind explicit `COMING SOON`
panels.
Loadout schema version 4 retains `maximum`, `custom`, conditional state,
encounter-assumption, and per-ability activation fields for compatibility. The
current player UI ignores those fields. When player simulation ships, one
required two-option `SegmentedChoice` will use these visible labels:
Loadout schema version 6 retains `maximum`, `custom`, conditional state,
encounter-assumption, and per-ability activation fields. Version 4 is obsolete
prototype data rather than a compatibility format. The current player UI ignores
the simulation fields. When player simulation ships, one required two-option
`SegmentedChoice` will use these visible labels:
- `MAXIMUM POTENTIAL` calculates a configured ceiling. It applies conditional
effects at peak state and treats selected abilities as firing on cooldown
@@ -2565,8 +2720,17 @@ The builder presents Specialization and Focus as the first loadout decision. The
Focus sets the primary Attribute, progression tree, and three ordered abilities.
Its identity combines Specialization artwork and the primary Attribute mark.
Each ability panel contains three fixed Skill Chip positions. A chip picker
shows definitions assigned to that ability and excludes definitions assigned
elsewhere.
shows the ability's three definitions. When ISAC explicitly pairs that ability
with one compatible ability, the picker also shows the partner's three
definitions and labels shared entries. The relationship is bidirectional and
does not apply to other abilities in the same specialization. Players still
equip three chips in each ability panel. Skill Chips have no item-level control.
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
ranks remain visible with their lock state. The builder permits a row after the
@@ -2581,11 +2745,11 @@ enabled ability. Future Skill result cards will show activation count,
single-target DPS, total-target DPS, and a combined summary. They will state
that Skills can critically hit and multi-shot but cannot headshot.
Saved loadouts store focus, node ranks, nine Skill Chips, conditional modes,
Version 5 loadouts store focus, node ranks, nine Skill Chips, conditional modes,
encounter assumptions, and per-ability activation profiles. The builder accepts
only the current complete structure for compatibility. Current player pages
ignore the simulation fields. Invalid local drafts are discarded; invalid server
records are removed through a backed-up database migration.
only the current complete structure. Current player pages ignore the simulation
fields. The builder discards version 4 local drafts; a backed-up database
migration removes version 4 server records.
### Calculation revision controls
@@ -2650,3 +2814,13 @@ asset and states that the change is permanent. For assigned icons, the
confirmation names the owning entity type. ISAC clears that source icon
assignment before it removes the shared file. General library uploads retain the
`ICON` or `IMAGE` state.
In the Weapon Mod registry, operators manage shared artwork for all eight
code-defined slots. The compact panel labels each slot `ICON REGISTERED` or
`ICON REQUIRED`. Selecting a slot opens the standard icon upload control. Keep
the slot name visible in ISAC after an operator uploads artwork.
On public weapon cards, a configured slot chip shows only its uploaded icon.
Keep the slot name in screen-reader and hover text. Retain the visible `T1+`
marker and blue information treatment for slots unlocked at Tier 1. When artwork
is missing, show the slot name instead of an invented glyph.
+56 -18
View File
@@ -26,8 +26,8 @@ Use these event boundaries:
- **Healing:** Count each healing event. A heal-over-time effect counts each
tick as a separate event.
- **Extra Health gained:** Count every Extra Health grant, including a grant
received while the Agent already has Extra Health. Use
`extra_health_granted` for the grant action.
received while the Agent already has Extra Health. Use `extra_health_granted`
for the grant action.
- **Extra Health state changed:** `extra_health_changed` is Agent-scoped. Emit
it when the Agent receives a grant, when the remaining amount changes, and
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
event is distinct from the `extra_health_granted` grant action.
- **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 at a time produces one completed-reload event for each round.
round to load. A canceled reload does not count. A weapon that loads one round
at a time produces one completed-reload event for each round.
- **Taking damage:** Require Health loss. A hit absorbed by Extra Health does
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
deployed Skill object.
- Destruction or timed expiration removes that object from the active count.
The benefit ends when no qualifying objects remain.
- Destruction or timed expiration removes that object from the active count. The
benefit ends when no qualifying objects remain.
- Instant-use Skills do not qualify.
- 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.
- Each qualifying presence refreshes the ten-second duration.
- Leaving the radius or reviving the ally does not cancel the current
ten-second duration.
- Leaving the radius or reviving the ally does not cancel the current ten-second
duration.
- 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 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
does not qualify.
Resolve the Extra Health amount from the Agent's displayed Firepower when
Second Wind activates. Include all equipped-item and active-buff contributions
in that Firepower value:
Resolve the Extra Health amount from the Agent's displayed Firepower when Second
Wind activates. Include all equipped-item and active-buff contributions in that
Firepower value:
- Tier I grants Extra Health equal to 600% 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
five-second duration and five-second cooldown start when it activates. Expiry
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.
If Second Wind supplies the Agent's only Extra Health, the old amount expires
and the new amount begins at that boundary.
prior grant can expire as a qualifying reload completion grants a new amount. If
Second Wind supplies the Agent's only Extra Health, the old amount expires and
the new amount begins at that boundary.
## Nothing to Lose
Nothing to Lose uses a forward-looking deployment window:
- Each Health-damage instance makes its own 50% activation roll. Damage
absorbed by Extra Health does not roll.
- Each Health-damage instance makes its own 50% activation roll. Damage absorbed
by Extra Health does not roll.
- A successful activation applies to every Skill deployed during the five
seconds after that damage instance. Several Skills can qualify during the
same window.
seconds after that damage instance. Several Skills can qualify during the same
window.
- Calculate each reduction from the Skill's total cooldown after equipment
modifiers.
- 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.
+60
View File
@@ -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…_
+106
View File
@@ -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.
+14
View File
@@ -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.
+204 -66
View File
@@ -431,11 +431,13 @@ reference.
# OS Protocol progression
OS Protocols use dedicated normalized tables because their talent progression
has six calculated states (base plus five upgrades) rather than the shared
two-tier talent model. Each protocol owns one `os_protocol_primary_effects`
record and one `os_protocol_talents` record. Each protocol Talent owns its
mechanic program through the shared conditional-effect tables.
OS Protocols use dedicated normalized tables because an assigned Talent has six
calculated states: its base value plus five upgrades. Each protocol owns one
`os_protocol_primary_effects` record and may own one `os_protocol_talents`
record. The optional Talent owns its mechanic program through the shared
conditional-effect tables. ISAC keeps the Talent workspace collapsed until an
operator assigns or opens it, and removing an existing Talent requires explicit
confirmation.
Dedicated profile tables normalize the three secondary attributes. One
`os_protocol_secondary_profiles` record exists for each configured quality +
@@ -460,6 +462,15 @@ compatible base weapon categories through `weapon_mod_compatible_categories`;
eligibility requires both a category match and the slot to exist on the equipped
weapon at its selected tier.
The eight code-defined slot types have stable UUIDs and one shared icon each.
Operators manage the artwork in the Weapon Mod registry through the game-data
icon pipeline. Each upload or removal appends a
`game_data/weapon_mod_slot_icon.updated` audit event, and contributors may send
the same action to Data Oversight. Public weapon cards use icon-only slot chips
when artwork exists, with the slot name available to screen readers and on
hover. Cards show the name when artwork is missing and retain the visible `T1+`
marker for slots unlocked at Tier 1.
Exotic Weapon Mods instead reference one `exotic_weapon_id`. They do not carry
regular category compatibility. The builder treats them as permanent equipment
and lets the user change only their level. They do not use quality profiles,
@@ -469,16 +480,21 @@ values in `weapon_mod_exotic_attributes`; intermediate values use linear
interpolation.
All regular Weapon Mods permit Firepower, Toughness, or Engineering as a
builder-time primary choice. `weapon_mod_quality_values` stores the shared
level-1 and level-20 primary roll endpoints for each quality; primary values use
linear interpolation from levels 1–20.
builder-time primary choice. `weapon_mod_quality_values` stores each
definition's level-1 and level-20 primary roll endpoints for each quality;
primary values use linear interpolation from levels 1–20.
`weapon_mod_secondary_options` is the shared pool from which a build selects two
distinct secondary attributes. Every option owns a minimum and maximum at each
quality through `weapon_mod_secondary_quality_values`; secondary availability is
not tied to a numbered slot in canonical data. Mod Effects are triggerless, hold
one or two attribute references, and store fixed per-quality values in
`weapon_mod_effect_quality_values`.
`weapon_mod_secondary_options` stores one global secondary Attribute pool for
all regular Weapon Mods. Each Attribute has Level 1 and Level 20 minimum and
maximum ranges for Specialized, Superior, and High-end quality through
`weapon_mod_secondary_quality_values`; values at intermediate levels use linear
interpolation. Secondary capacity is fixed by quality: Standard has zero,
Specialized has one, Superior has two, and High-end has two. A build selects
distinct Attributes from the pool up to that capacity. Individual Weapon Mod
records do not own secondary options or ranges. Pool updates append a
`game_data/weapon_mod_secondary_pool.updated` audit event. Mod Effects remain
triggerless, hold one or two Attribute references, and store fixed per-quality
values in `weapon_mod_effect_quality_values`.
### Gear Mods and tier slot capacity
@@ -488,42 +504,86 @@ Specialized, Superior, and High-end quality at Levels 1 and 20. Values between
those endpoints use linear interpolation. A build instance selects one primary
attribute from Firepower, Toughness, Engineering, Health, or Armor.
The two secondary slots draw distinct attributes from one shared option pool.
Each available secondary attribute stores its own minimum and maximum for all
four qualities. Gear tier determines capacity without per-item configuration: T0
gear has one Gear Mod slot; T1 and T2 gear have two.
One global Gear Mod secondary Attribute pool serves every Gear Mod definition.
Each Attribute has Level 1 and Level 20 minimum and maximum ranges for
Specialized, Superior, and High-end quality through
`gear_mod_secondary_quality_values`; values at intermediate levels use linear
interpolation. Secondary capacity is fixed by quality: Standard has zero,
Specialized has one, Superior has two, and High-end has two. A build selects
distinct Attributes from the pool up to that capacity. Individual Gear Mod
records do not own secondary options or ranges. Gear tier still determines mod
slot capacity without per-item configuration: T0 gear has one Gear Mod slot; T1
and T2 gear have two.
The `gear_mods`, `gear_mod_quality_values`, `gear_mod_secondary_options`, and
`gear_mod_secondary_quality_values` tables store canonical data. Successful
mutations use `gear_mod.created`, `gear_mod.updated`, `gear_mod.archived`, and
`gear_mod.unarchived` audit actions. Contributor submissions use the `gearmods`
proposal module and remain non-canonical until approved by Oversight.
The `gear_mods` and `gear_mod_quality_values` tables store individual definition
data. `gear_mod_secondary_options` and `gear_mod_secondary_quality_values` store
the family-wide pool. Pool updates append a
`game_data/gear_mod_secondary_pool.updated` audit event. Individual definition
mutations retain the `gear_mod.created`, `gear_mod.updated`,
`gear_mod.archived`, and `gear_mod.unarchived` audit actions. Contributor
submissions use the `gearmods` proposal module and remain non-canonical until
approved by Oversight.
# Skill Chip quality scaling
Skill Chip sets support Superior and High-end chip instances. Each set owns one
2-piece bonus, distinct primary and secondary stat-option pools, and one 3-piece
Talent with a typed conditional rule. Primary and secondary options reference
active Attributes that define Skill Chip minimum and maximum roll values. A
configured chip rolls one primary and two distinct secondary stats from those
pools.
Each Skill Chip definition belongs to one specialization ability. Each set owns
one 2-piece bonus and may own one 3-piece Talent with a typed conditional rule.
ISAC keeps the complete Talent workspace, including its description and mechanic
program, inside one collapsible optional section.
Each active Skill Chip definition belongs to one active specialization ability
through `skill_chip_sets.specialization_ability_id`. An ability has capacity for
three active definitions. The save and restore actions enforce that capacity,
and the schema requires the foreign key for each row. A specialization focus is
All definitions use one fixed primary profile. The profile contains Skill
Duration, Skill Radius, Skill Cooldown Recovery, and Skill Intensity, with
direct minimum and maximum values for Standard, Specialized, Superior, and
High-end. Primary values apply to the assigned ability rather than the Agent.
Builder and shared Build pages label them `NOT YET DEFINED` and exclude them
from current Agent and combat totals.
All definitions also use one shared secondary Attribute pool. Each pool entry
stores direct minimum and maximum values for Specialized, Superior, and
High-end. Standard chips have no secondary slot, Specialized chips have one, and
Superior and High-end chips have two. A configured chip selects distinct
secondary Attributes up to its quality's capacity. Skill Chips have no item
level, so neither profile uses level interpolation.
Active definitions reference an active specialization ability through
`skill_chip_sets.specialization_ability_id`. An ability has capacity for three
active definitions. The save and restore actions enforce that capacity, and the
schema requires the foreign key for each row. A specialization focus is
builder-ready only when each of its three assigned abilities has all three
active Skill Chip definitions. Skill Chip mutations refresh readiness for
focuses that reference both the previous and resulting ability.
Both set-bonus progression systems use the number of equipped High-end chips as
the interpolation step. For `h` High-end chips, where `h` is zero through three,
a configured minimum and maximum resolve as
`minimum + (maximum − minimum) × h / 3`. Zero High-end chips therefore produce
the minimum, while three produce the maximum. The editor stores endpoints and
previews all four states, preventing duplicated tier data from diverging. The
`game_data` audit stream captures Skill Chip mutations, owned relationships, and
safe icon metadata.
`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
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
minimum and maximum resolve as `minimum + (maximum − minimum) × h / 3`. Zero
High-end chips resolve to the minimum, while three resolve to the maximum. The
editor stores endpoints and previews all four states.
ISAC presents the fixed primary profile and secondary pool as two shared
registry/editor cards above the definition registry. Successful profile saves
append `game_data/skill_chip_primary_profile.updated` and
`game_data/skill_chip_secondary_pool.updated`, respectively. Definition
mutations continue to capture their owned bonus, optional Talent, ability
relationship, and safe icon metadata in the `game_data` audit stream.
# Mechanic programs and loadout state
@@ -542,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
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:
- Standard Talents require Tier I and Tier II values. Exotic Talents require
@@ -575,9 +638,37 @@ normalized events are `shot_fired`, `weapon_hit`, `skill_deployed`,
`weapon_swapped`, and `tick`. Event details such as target, distance, critical
state, source, movement, and nearby counts travel as typed event values. The
event registry publishes authoritative field metadata where branches need a
bounded contract. For those events, the mechanic editor derives its event-field
controls from that metadata. For example, `weapon_hit.values.headshot` is a
boolean that distinguishes headshots from body shots.
bounded contract. For those events, the mechanic editor derives event-field
conditions and manual derived-scalar controls from that metadata.
`proximity_snapshot.values.alliesWithin30MetersWithAgentGrantedBuff` is an
optional non-negative integer. It contains the resolved count of distinct allied
Agents at a distance less than or equal to 30 meters who have at least one buff
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
runtime expires that state before it evaluates another event at the same
timestamp.
`weapon_hit.values.aiming` records whether the Agent was aiming down sights when
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.
Predicates compare this field explicitly with `true` or `false`; negation and
inequality cannot turn missing evidence into a match. A `weapon_hit` or
`skill_hit` event may include an event-target-centered area sample. The bounded
sample lists up to 256 valid ally and enemy entity IDs with their distances from
the hit target and includes that target as an enemy at zero meters. Duplicate
IDs, negative distances, or a missing center target invalidate the evaluation.
`extra_health_changed` is Agent-scoped. Normalized timeline producers emit it
when the Agent receives an Extra Health grant, when the remaining amount
@@ -612,7 +703,10 @@ The program graph uses three child tables:
durations, intervals, or derived values. Mutations can set, add, consume,
clear, derive, or transfer state. A mutation may bind a target-scoped pool to
the current event target, event actor, or another typed entity selector. Each
target owns an independent value and expiry timer.
target owns an independent value and expiry timer. A clause may require active
assigned-ability state and carry `procChancePercent`. The runtime evaluates
that chance after predicates and cooldown checks, before reducers, mutations,
and outcomes.
- `conditional_effect_outcomes` applies Attribute modifiers, direct damage,
restoration, resource costs, or markers. Each outcome names its target scope,
value basis, optional resource or Attribute, combat timing, optional state
@@ -621,15 +715,46 @@ The program graph uses three child tables:
required by the owning source. The common value models are static values, live
Attribute values, percentage-normalized Attribute values, event-field values,
reducer output, source-defined scenario values, and OS Protocol upgrade
scaling. Restoration outcomes may deliver their total immediately or
scaling. An outcome may carry `probabilityPercent`; each eligible outcome
rolls separately. Restoration outcomes may deliver their total immediately or
continuously over an explicit duration.
Draft outcome expressions may use `{"ref":{"source":"variant_value"}}`. This
reference belongs to the conditional draft grammar and does not enter the
runtime expression grammar. Each persisted outcome-variant row keeps its
human-facing numeric `value`, while `value_expression_json` preserves the
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
events in timestamp order. Predicates decide whether an event enters a clause.
Reducers convert event streams into activations or derived values. The runtime
applies state mutations, enforces pool bounds and expiry, emits outcomes in
phase order, and records telemetry for each activation.
When `event_target_area` damage activates, the runtime requires the triggering
event's authoritative area sample. It includes valid enemies whose distance is
less than or equal to the configured radius, sorts them by entity ID, and emits
one equal damage outcome for each entity. The outcome chance resolves once
before fan-out. A missing sample invalidates the evaluation instead of producing
single-target damage or treating the area as empty.
Custom evaluation uses a Mulberry32 stream seeded by `randomSeed`; an omitted
seed resolves from the stable mechanic program ID. The returned evaluation
reports the seed. Maximum mode resolves every nonzero clause or outcome chance
as successful. `extend_duration` changes only an active assigned ability's
remaining duration. `start_cooldown_on_max` writes an expiring Agent cooldown
pool only when an earlier mutation in the same clause transitions a capped stack
pool to its maximum.
Each write to an expiring pool refreshes its timer. When a pool expires,
`on_exit` outcomes evaluate stack thresholds and per-stack expressions against
the final value before reset. The runtime records the bound target on emitted
@@ -647,11 +772,13 @@ substitute zero.
The read-only `combat.currentHealthPercent` reference divides resolved current
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
Health value that could become stale. Current timing observes deterministic
resource changes from earlier branches at the same event. Health-band mechanics
use strict upper bounds and inclusive lower bounds, so bands do not overlap and
an exact threshold enters the next weaker band. Maximum-potential evaluation
applies the same predicates before emitting passive outcomes.
Health value that could become stale. Pre-event timing reads the combat snapshot
before mechanic branches process the event. Current timing observes
deterministic resource changes from earlier branches at the same event.
Health-band mechanics use strict upper bounds and inclusive lower bounds, so
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,
then delivers that total at a constant rate over the configured duration.
@@ -694,6 +821,13 @@ descriptions, and calculation metadata. The client cannot replace the system
instructions, choose arbitrary models, attach files, add tools, or add external
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 AI context serializes that manifest instead of maintaining a second outcome
grammar. The parser rejects outcome fields absent from the manifest. Contract
@@ -790,20 +924,23 @@ events remain the evidence for revision saves and activation.
Source authors define planned player scenario controls as typed toggle, slider,
or numeric inputs. Each input records its label, guidance, bounds when
applicable, default value, and best-case value. ISAC retains these definitions
for continued mechanic authoring. Current Builder and shared Build pages do not
render or evaluate them. Their calculation panels display `COMING SOON` instead.
for continued mechanic authoring, and the calculation timeline builder can
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 4 stores one focus, selected tree ranks, nine Skill
Chips, a unified conditional-effect state map, encounter assumptions,
per-ability activation controls, and `calculationMode`. Runtime readers accept
the exact version 4 structure. The schema retains its simulation fields for
compatibility, but the current player UI ignores them. A backed-up prototype
migration removes drafts, saved builds, and calibration snapshots that use
incompatible payloads.
Loadout schema version 6 stores one focus, selected tree ranks, nine Skill
Chips, independent T1/T2 choices for each equipped gear and weapon Talent, a
unified conditional-effect state map, encounter assumptions, per-ability
activation controls, and `calculationMode`. Runtime readers accept the exact
version 6 structure. A backed-up migration translated version 5 equipment
Talents from their previous equipment-tier-derived behavior. The schema retains
its simulation fields, but the current player UI ignores them. Version 4 remains
obsolete prototype data.
## Planned player calculation modes
Loadout v4 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
conditional-effect state, and runs selected abilities on cooldown against
@@ -925,11 +1062,12 @@ does not scale.
# Game-data icon storage
Talents, brand sets, OS Protocols, and Skill Chips reference uploaded artwork
through the polymorphic `game_data_icons` metadata table. The shared server
pipeline accepts transparent SVG, PNG, WebP, and AVIF sources, rejects active
SVG content and external references, and uses Sharp’s native worker pool to
rasterize and normalize each accepted icon to a transparent 256 × 256 WebP.
Talents, brand sets, OS Protocols, Skill Chips, Specializations, and shared
Weapon Mod slots reference uploaded artwork through the polymorphic
`game_data_icons` metadata table. The shared server pipeline accepts transparent
SVG, PNG, WebP, and AVIF sources, rejects active SVG content and external
references, and uses Sharp’s native worker pool to rasterize and normalize each
accepted icon to a transparent 256 × 256 WebP.
Normalized files live beneath the ignored `data/game-icons/` directory. The
validated `/icons/game-data/[type]/[id]` endpoint serves them with immutable
+5
View File
@@ -0,0 +1,5 @@
{
"worktree": {
"setup": "npm install"
}
}
+606 -17
View File
@@ -45,7 +45,8 @@ const isRoll = (value) =>
isFiniteNumber(value.value);
const isRollPair = (value) =>
Array.isArray(value) && value.length === 2 && value.every(isRoll);
const isGearSelection = (value) =>
const isTalentTier = (value) => value === 1 || value === 2;
const isLegacyGearSelection = (value) =>
hasExactKeys(value, [
'itemId',
'level',
@@ -68,7 +69,7 @@ const isGearSelection = (value) =>
isRollPair(value.rolls) &&
Array.isArray(value.augmentSlots) &&
value.augmentSlots.every(isFiniteNumber);
const isWeaponSelection = (value) =>
const isLegacyWeaponSelection = (value) =>
hasExactKeys(value, [
'itemId',
'level',
@@ -88,12 +89,68 @@ const isWeaponSelection = (value) =>
(key) => isFiniteNumber(value[key]),
) &&
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) =>
hasExactKeys(value, ['setId', 'quality', 'primary', 'secondary']) &&
typeof value.setId === 'string' &&
(value.quality === 'Superior' || value.quality === 'High-end') &&
['Standard', 'Specialized', 'Superior', 'High-end'].includes(value.quality) &&
isRoll(value.primary) &&
isRollPair(value.secondary);
Array.isArray(value.secondary) &&
value.secondary.length ===
({ Standard: 0, Specialized: 1, Superior: 2, 'High-end': 2 }[
value.quality
] ?? -1) &&
value.secondary.every(isRoll);
const isProtocolSelection = (value) =>
hasExactKeys(value, ['itemId', 'level', 'talentUpgrades']) &&
typeof value.itemId === 'string' &&
@@ -185,7 +242,7 @@ const isCurrentLoadoutSnapshot = (raw) => {
'skillActivations',
'specialization',
]) &&
value.version === 4 &&
value.version === 6 &&
typeof value.buildName === 'string' &&
['maximum', 'custom'].includes(value.calculationMode) &&
hasExactKeys(value.gear, gearSlots) &&
@@ -207,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(
process.env.TDR_DATABASE_PATH || './data/shd-build.db',
);
@@ -669,6 +769,44 @@ for (const statement of statements) {
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 (
await database.prepare("PRAGMA table_info('article_revisions')")
).all();
@@ -2078,6 +2216,17 @@ if (legacyProtocolEffects) {
await database.exec('DROP TABLE os_protocol_attribute_effects');
}
const assertProposalContributorNoteColumn = async () => {
const columns = await (
await database.prepare("PRAGMA table_info('data_change_proposals')")
).all();
if (!columns.some((column) => column.name === 'contributor_note')) {
throw new Error(
'Schema migration lost the contributor_note column from data_change_proposals.',
);
}
};
const proposalDefinition = await (
await database.prepare(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='data_change_proposals'",
@@ -2097,6 +2246,7 @@ if (
draft_entity_id TEXT,
applied_entity_id TEXT,
summary TEXT NOT NULL,
contributor_note TEXT,
payload_json TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','approved','rejected','failed')),
proposed_by TEXT NOT NULL REFERENCES users(id),
@@ -2106,14 +2256,20 @@ if (
reviewed_at TEXT,
applied_at TEXT
)`);
await database.exec(`INSERT INTO data_change_proposals_expanded
SELECT id,CASE module WHEN 'stats' THEN 'attributes' ELSE module END,action,entity_id,draft_entity_id,applied_entity_id,summary,payload_json,status,proposed_by,reviewed_by,review_note,created_at,reviewed_at,applied_at
await database.exec(`INSERT INTO data_change_proposals_expanded(
id,module,action,entity_id,draft_entity_id,applied_entity_id,summary,contributor_note,
payload_json,status,proposed_by,reviewed_by,review_note,created_at,reviewed_at,applied_at
)
SELECT id,CASE module WHEN 'stats' THEN 'attributes' ELSE module END,action,entity_id,
draft_entity_id,applied_entity_id,summary,contributor_note,payload_json,status,
proposed_by,reviewed_by,review_note,created_at,reviewed_at,applied_at
FROM data_change_proposals`);
await database.exec('DROP TABLE data_change_proposals');
await database.exec(
'ALTER TABLE data_change_proposals_expanded RENAME TO data_change_proposals',
);
await database.exec('PRAGMA foreign_keys=ON');
await assertProposalContributorNoteColumn();
}
await database.exec(
'CREATE INDEX IF NOT EXISTS data_change_proposals_status_created_idx ON data_change_proposals(status,created_at DESC)',
@@ -2144,6 +2300,7 @@ if (
draft_entity_id TEXT,
applied_entity_id TEXT,
summary TEXT NOT NULL,
contributor_note TEXT,
payload_json TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','approved','rejected','failed')),
proposed_by TEXT NOT NULL REFERENCES users(id),
@@ -2153,14 +2310,19 @@ if (
reviewed_at TEXT,
applied_at TEXT
)`);
await database.exec(`INSERT INTO data_change_proposals_weaponmods
SELECT id,module,action,entity_id,draft_entity_id,applied_entity_id,summary,payload_json,status,proposed_by,reviewed_by,review_note,created_at,reviewed_at,applied_at
await database.exec(`INSERT INTO data_change_proposals_weaponmods(
id,module,action,entity_id,draft_entity_id,applied_entity_id,summary,contributor_note,
payload_json,status,proposed_by,reviewed_by,review_note,created_at,reviewed_at,applied_at
)
SELECT id,module,action,entity_id,draft_entity_id,applied_entity_id,summary,contributor_note,
payload_json,status,proposed_by,reviewed_by,review_note,created_at,reviewed_at,applied_at
FROM data_change_proposals`);
await database.exec('DROP TABLE data_change_proposals');
await database.exec(
'ALTER TABLE data_change_proposals_weaponmods RENAME TO data_change_proposals',
);
await database.exec('PRAGMA foreign_keys=ON');
await assertProposalContributorNoteColumn();
await database.exec(
'CREATE INDEX IF NOT EXISTS data_change_proposals_status_created_idx ON data_change_proposals(status,created_at DESC)',
);
@@ -2191,6 +2353,7 @@ if (
draft_entity_id TEXT,
applied_entity_id TEXT,
summary TEXT NOT NULL,
contributor_note TEXT,
payload_json TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','approved','rejected','failed')),
proposed_by TEXT NOT NULL REFERENCES users(id),
@@ -2200,14 +2363,19 @@ if (
reviewed_at TEXT,
applied_at TEXT
)`);
await database.exec(`INSERT INTO data_change_proposals_specializations
SELECT id,module,action,entity_id,draft_entity_id,applied_entity_id,summary,payload_json,status,proposed_by,reviewed_by,review_note,created_at,reviewed_at,applied_at
await database.exec(`INSERT INTO data_change_proposals_specializations(
id,module,action,entity_id,draft_entity_id,applied_entity_id,summary,contributor_note,
payload_json,status,proposed_by,reviewed_by,review_note,created_at,reviewed_at,applied_at
)
SELECT id,module,action,entity_id,draft_entity_id,applied_entity_id,summary,contributor_note,
payload_json,status,proposed_by,reviewed_by,review_note,created_at,reviewed_at,applied_at
FROM data_change_proposals`);
await database.exec('DROP TABLE data_change_proposals');
await database.exec(
'ALTER TABLE data_change_proposals_specializations RENAME TO data_change_proposals',
);
await database.exec('PRAGMA foreign_keys=ON');
await assertProposalContributorNoteColumn();
await database.exec(
'CREATE INDEX IF NOT EXISTS data_change_proposals_status_created_idx ON data_change_proposals(status,created_at DESC)',
);
@@ -2238,6 +2406,7 @@ if (
draft_entity_id TEXT,
applied_entity_id TEXT,
summary TEXT NOT NULL,
contributor_note TEXT,
payload_json TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','approved','rejected','failed')),
proposed_by TEXT NOT NULL REFERENCES users(id),
@@ -2247,14 +2416,19 @@ if (
reviewed_at TEXT,
applied_at TEXT
)`);
await database.exec(`INSERT INTO data_change_proposals_calculations
SELECT id,module,action,entity_id,draft_entity_id,applied_entity_id,summary,payload_json,status,proposed_by,reviewed_by,review_note,created_at,reviewed_at,applied_at
await database.exec(`INSERT INTO data_change_proposals_calculations(
id,module,action,entity_id,draft_entity_id,applied_entity_id,summary,contributor_note,
payload_json,status,proposed_by,reviewed_by,review_note,created_at,reviewed_at,applied_at
)
SELECT id,module,action,entity_id,draft_entity_id,applied_entity_id,summary,contributor_note,
payload_json,status,proposed_by,reviewed_by,review_note,created_at,reviewed_at,applied_at
FROM data_change_proposals`);
await database.exec('DROP TABLE data_change_proposals');
await database.exec(
'ALTER TABLE data_change_proposals_calculations RENAME TO data_change_proposals',
);
await database.exec('PRAGMA foreign_keys=ON');
await assertProposalContributorNoteColumn();
await database.exec(
'CREATE INDEX IF NOT EXISTS data_change_proposals_status_created_idx ON data_change_proposals(status,created_at DESC)',
);
@@ -2289,6 +2463,7 @@ if (
draft_entity_id TEXT,
applied_entity_id TEXT,
summary TEXT NOT NULL,
contributor_note TEXT,
payload_json TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','approved','rejected','failed')),
proposed_by TEXT NOT NULL REFERENCES users(id),
@@ -2298,11 +2473,14 @@ if (
reviewed_at TEXT,
applied_at TEXT
)`);
await database.exec(`INSERT INTO data_change_proposals_source_owned
await database.exec(`INSERT INTO data_change_proposals_source_owned(
id,module,action,entity_id,draft_entity_id,applied_entity_id,summary,contributor_note,
payload_json,status,proposed_by,reviewed_by,review_note,created_at,reviewed_at,applied_at
)
SELECT id,
CASE WHEN module='triggers' THEN 'talents' ELSE module END,
CASE WHEN module='triggers' THEN 'legacyTrigger.'||action ELSE action END,
entity_id,draft_entity_id,applied_entity_id,summary,payload_json,
entity_id,draft_entity_id,applied_entity_id,summary,contributor_note,payload_json,
CASE WHEN module='triggers' AND status='pending' THEN 'failed' ELSE status END,
proposed_by,reviewed_by,
CASE
@@ -2328,6 +2506,7 @@ if (
} finally {
await database.exec('PRAGMA foreign_keys=ON');
}
await assertProposalContributorNoteColumn();
await database.exec(
'CREATE INDEX IF NOT EXISTS data_change_proposals_status_created_idx ON data_change_proposals(status,created_at DESC)',
);
@@ -2392,6 +2571,32 @@ if (
);
}
const weaponModSlotIconDefinition = await (
await database.prepare(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='game_data_icons'",
)
).get();
if (
typeof weaponModSlotIconDefinition?.sql === 'string' &&
!weaponModSlotIconDefinition.sql.includes("'weapon_mod_slot'")
) {
console.log('Adding shared Weapon Mod slot icon storage support.');
await database.exec(`CREATE TABLE game_data_icons_weapon_mod_slots (
entity_type TEXT NOT NULL CHECK(entity_type IN ('talent', 'brand_set', 'os_protocol', 'skill_chip', 'specialization', 'specialization_ability', 'specialization_node', 'weapon_mod_slot')),
entity_id TEXT NOT NULL,
storage_key TEXT NOT NULL UNIQUE,
byte_size INTEGER NOT NULL CHECK(byte_size > 0),
updated_at TEXT NOT NULL,
PRIMARY KEY(entity_type, entity_id)
)`);
await database.exec(`INSERT INTO game_data_icons_weapon_mod_slots
SELECT entity_type,entity_id,storage_key,byte_size,updated_at FROM game_data_icons`);
await database.exec('DROP TABLE game_data_icons');
await database.exec(
'ALTER TABLE game_data_icons_weapon_mod_slots RENAME TO game_data_icons',
);
}
const managedImageColumns = await (
await database.prepare("PRAGMA table_info('managed_images')")
).all();
@@ -2433,6 +2638,7 @@ const iconLibraryCategory = {
specialization: 'Specializations',
specialization_ability: 'Specializations',
specialization_node: 'Specializations',
weapon_mod_slot: 'Weapon Mods',
};
const iconLibraryLabel = {
talent: 'Talent',
@@ -2442,6 +2648,17 @@ const iconLibraryLabel = {
specialization: 'Specialization',
specialization_ability: 'Specialization Ability',
specialization_node: 'Specialization Tree Node',
weapon_mod_slot: 'Weapon Mod Slot',
};
const weaponModSlotNames = {
'28ffc3b1-5d98-4273-a9af-67dbff3e5c76': 'Optics',
'c4598f51-2cb0-4a20-a3a7-6d8983a26598': 'Muzzle',
'85ca3493-1871-4d22-950a-ed718fd547e0': 'Underbarrel',
'4d809a35-8285-4bff-be98-a0effe1d257e': 'Magazine',
'0e157cfe-975c-441d-8f49-72983161e7b1': 'Barrel',
'551fd7c8-3e5b-4c89-8437-9b901e89e8aa': 'Upper Receiver',
'72573367-6e91-4312-9cc3-710ede4f078e': 'Lower Receiver',
'8dda5edb-6079-4a23-a556-cd9be4521c70': 'Trigger',
};
const normalizeLibraryTags = (values) =>
[
@@ -2532,6 +2749,13 @@ const linkedIconMetadata = async (entityType, entityId) => {
],
};
}
case 'weapon_mod_slot': {
const name = weaponModSlotNames[entityId];
return {
name: name ? `${name} Slot` : null,
tags: ['weapon mod', 'slot', name],
};
}
default:
return { name: null, tags: [] };
}
@@ -2912,6 +3136,302 @@ if (
await database.exec('PRAGMA foreign_keys=ON');
}
for (const kind of ['weapon', 'gear']) {
const optionTable = `${kind}_mod_secondary_options`;
const valueTable = `${kind}_mod_secondary_quality_values`;
const optionColumns = await (
await database.prepare(`PRAGMA table_info('${optionTable}')`)
).all();
if (!optionColumns.some((column) => column.name === `${kind}_mod_id`))
continue;
const conflictingRanges = await (
await database.prepare(`SELECT stat_attribute_id,quality,
COUNT(DISTINCT CAST(min_value AS TEXT)||'/'||CAST(max_value AS TEXT)) AS variants
FROM ${valueTable}
WHERE quality IN ('Specialized','Superior','High-end')
GROUP BY stat_attribute_id,quality HAVING variants>1`)
).all();
if (conflictingRanges.length) {
throw new Error(
`Cannot create the shared ${kind} Mod secondary pool because existing per-Mod ranges disagree. Reconcile ${conflictingRanges.map((row) => `${row.stat_attribute_id}/${row.quality}`).join(', ')} first.`,
);
}
const incompleteOptions = await (
await database.prepare(`SELECT options.stat_attribute_id,
COUNT(DISTINCT CASE WHEN values_row.quality IN ('Specialized','Superior','High-end') THEN values_row.quality END) AS qualities
FROM ${optionTable} options
LEFT JOIN ${valueTable} values_row ON values_row.${kind}_mod_id=options.${kind}_mod_id
AND values_row.stat_attribute_id=options.stat_attribute_id
GROUP BY options.${kind}_mod_id,options.stat_attribute_id HAVING qualities<>3`)
).all();
if (incompleteOptions.length) {
throw new Error(
`Cannot create the shared ${kind} Mod secondary pool because ${incompleteOptions.map((row) => row.stat_attribute_id).join(', ')} does not have all applicable quality ranges.`,
);
}
console.log(
`Converting ${kind} Mod secondary Attributes to one shared level-scaled pool.`,
);
await database.exec('PRAGMA foreign_keys=OFF');
await database.exec('BEGIN');
try {
await database.exec(`CREATE TABLE ${optionTable}_shared (
stat_attribute_id TEXT PRIMARY KEY REFERENCES stat_attributes(id)
)`);
await database.exec(`CREATE TABLE ${valueTable}_shared (
stat_attribute_id TEXT NOT NULL,
quality TEXT NOT NULL CHECK(quality IN ('Specialized','Superior','High-end')),
level_1_min REAL NOT NULL,
level_1_max REAL NOT NULL,
level_20_min REAL NOT NULL,
level_20_max REAL NOT NULL,
PRIMARY KEY(stat_attribute_id,quality),
FOREIGN KEY(stat_attribute_id) REFERENCES ${optionTable}_shared(stat_attribute_id) ON DELETE CASCADE,
CHECK(level_1_max>=level_1_min),
CHECK(level_20_max>=level_20_min),
CHECK(level_20_min>=level_1_min),
CHECK(level_20_max>=level_1_max)
)`);
await database.exec(`INSERT INTO ${optionTable}_shared(stat_attribute_id)
SELECT DISTINCT stat_attribute_id FROM ${optionTable}`);
await database.exec(`INSERT INTO ${valueTable}_shared(
stat_attribute_id,quality,level_1_min,level_1_max,level_20_min,level_20_max
) SELECT stat_attribute_id,quality,MIN(min_value),MAX(max_value),MIN(min_value),MAX(max_value)
FROM ${valueTable}
WHERE quality IN ('Specialized','Superior','High-end')
GROUP BY stat_attribute_id,quality`);
await database.exec(`DROP TABLE ${valueTable}`);
await database.exec(`DROP TABLE ${optionTable}`);
await database.exec(
`ALTER TABLE ${optionTable}_shared RENAME TO ${optionTable}`,
);
await database.exec(
`ALTER TABLE ${valueTable}_shared RENAME TO ${valueTable}`,
);
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
} finally {
await database.exec('PRAGMA foreign_keys=ON');
}
}
const skillChipPrimaryOptionColumns = await (
await database.prepare("PRAGMA table_info('skill_chip_primary_options')")
).all();
if (
skillChipPrimaryOptionColumns.some(
(column) => column.name === 'skill_chip_set_id',
)
) {
const fixedSkillChipAttributes = await (
await database.prepare(`SELECT COUNT(*) AS total,
SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active
FROM stat_attributes
WHERE name IN ('Skill Duration','Skill Radius','Skill Cooldown Recovery','Skill Intensity')`)
).get();
if (
Number(fixedSkillChipAttributes?.total) !== 4 ||
Number(fixedSkillChipAttributes?.active) !== 4
)
throw new Error(
'Skill Chip profile migration requires exactly four active fixed primary Attributes.',
);
console.log(
'Converting Skill Chip primary and secondary options to shared quality profiles.',
);
await database.exec('PRAGMA foreign_keys=OFF');
await database.exec('BEGIN');
try {
await database.exec(
'DROP TABLE IF EXISTS skill_chip_primary_quality_values',
);
await database.exec(
'DROP TABLE IF EXISTS skill_chip_secondary_quality_values',
);
await database.exec(`CREATE TABLE skill_chip_primary_options_shared (
stat_attribute_id TEXT PRIMARY KEY REFERENCES stat_attributes(id)
)`);
await database.exec(`CREATE TABLE skill_chip_primary_quality_values_shared (
stat_attribute_id TEXT NOT NULL,
quality TEXT NOT NULL CHECK(quality IN ('Standard','Specialized','Superior','High-end')),
min_value REAL NOT NULL,
max_value REAL NOT NULL,
PRIMARY KEY(stat_attribute_id,quality),
FOREIGN KEY(stat_attribute_id) REFERENCES skill_chip_primary_options_shared(stat_attribute_id) ON DELETE CASCADE,
CHECK(max_value>=min_value),
CHECK(min_value>=0)
)`);
await database.exec(`CREATE TABLE skill_chip_secondary_options_shared (
stat_attribute_id TEXT PRIMARY KEY REFERENCES stat_attributes(id)
)`);
await database.exec(`CREATE TABLE skill_chip_secondary_quality_values_shared (
stat_attribute_id TEXT NOT NULL,
quality TEXT NOT NULL CHECK(quality IN ('Specialized','Superior','High-end')),
min_value REAL NOT NULL,
max_value REAL NOT NULL,
PRIMARY KEY(stat_attribute_id,quality),
FOREIGN KEY(stat_attribute_id) REFERENCES skill_chip_secondary_options_shared(stat_attribute_id) ON DELETE CASCADE,
CHECK(max_value>=min_value),
CHECK(min_value>=0)
)`);
await database.exec(`INSERT INTO skill_chip_primary_options_shared(stat_attribute_id)
SELECT id FROM stat_attributes
WHERE name IN ('Skill Duration','Skill Radius','Skill Cooldown Recovery','Skill Intensity')
AND active=1`);
await database.exec(`INSERT INTO skill_chip_primary_quality_values_shared(
stat_attribute_id,quality,min_value,max_value
) SELECT attributes.id,qualities.quality,attributes.skill_chip_min,attributes.skill_chip_max
FROM stat_attributes attributes
CROSS JOIN (
SELECT 'Standard' quality UNION ALL SELECT 'Specialized'
UNION ALL SELECT 'Superior' UNION ALL SELECT 'High-end'
) qualities
WHERE attributes.name IN ('Skill Duration','Skill Radius','Skill Cooldown Recovery','Skill Intensity')
AND attributes.active=1
AND attributes.skill_chip_min IS NOT NULL
AND attributes.skill_chip_max IS NOT NULL`);
await database.exec(`INSERT INTO skill_chip_secondary_options_shared(stat_attribute_id)
SELECT DISTINCT stat_attribute_id FROM skill_chip_secondary_options`);
await database.exec(`INSERT INTO skill_chip_secondary_quality_values_shared(
stat_attribute_id,quality,min_value,max_value
) SELECT options.stat_attribute_id,qualities.quality,attributes.skill_chip_min,attributes.skill_chip_max
FROM (SELECT DISTINCT stat_attribute_id FROM skill_chip_secondary_options) options
JOIN stat_attributes attributes ON attributes.id=options.stat_attribute_id
CROSS JOIN (
SELECT 'Specialized' quality UNION ALL SELECT 'Superior' UNION ALL SELECT 'High-end'
) qualities
WHERE attributes.skill_chip_min IS NOT NULL
AND attributes.skill_chip_max IS NOT NULL`);
await database.exec('DROP TABLE skill_chip_primary_options');
await database.exec('DROP TABLE skill_chip_secondary_options');
await database.exec(
'ALTER TABLE skill_chip_primary_options_shared RENAME TO skill_chip_primary_options',
);
await database.exec(
'ALTER TABLE skill_chip_primary_quality_values_shared RENAME TO skill_chip_primary_quality_values',
);
await database.exec(
'ALTER TABLE skill_chip_secondary_options_shared RENAME TO skill_chip_secondary_options',
);
await database.exec(
'ALTER TABLE skill_chip_secondary_quality_values_shared RENAME TO skill_chip_secondary_quality_values',
);
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
} finally {
await database.exec('PRAGMA foreign_keys=ON');
}
}
await database.exec(`INSERT OR IGNORE INTO skill_chip_primary_options(stat_attribute_id)
SELECT id FROM stat_attributes
WHERE active=1 AND name IN (
'Skill Duration','Skill Radius','Skill Cooldown Recovery','Skill Intensity'
)`);
const skillChipRangeAttributeColumns = await (
await database.prepare("PRAGMA table_info('stat_attributes')")
).all();
if (
skillChipRangeAttributeColumns.some(
(column) => column.name === 'skill_chip_min',
)
) {
console.log(
'Removing obsolete Attribute-level Skill Chip ranges in favor of shared quality profiles.',
);
await database.exec('PRAGMA foreign_keys=OFF');
await database.exec('BEGIN');
try {
await database.exec(
'DROP INDEX IF EXISTS stat_attributes_name_unit_nocase_idx',
);
await database.exec(`CREATE TABLE stat_attributes_current (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
type TEXT NOT NULL CHECK(type IN ('Offensive','Defensive')),
category TEXT NOT NULL CHECK(category IN ('Weapon','Skill','Character')),
calculation_type TEXT NOT NULL CHECK(calculation_type IN ('Additive','Multiplicative')),
unit TEXT NOT NULL DEFAULT '',
gear_min REAL,
gear_max REAL,
gear_reference_level INTEGER NOT NULL DEFAULT 50 CHECK(gear_reference_level BETWEEN 1 AND 50),
weapon_min REAL,
weapon_max REAL,
weapon_reference_level INTEGER NOT NULL DEFAULT 50 CHECK(weapon_reference_level BETWEEN 1 AND 50),
cap_value REAL,
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
CHECK((gear_min IS NULL AND gear_max IS NULL) OR (gear_min IS NOT NULL AND gear_max IS NOT NULL AND gear_min<=gear_max)),
CHECK((weapon_min IS NULL AND weapon_max IS NULL) OR (weapon_min IS NOT NULL AND weapon_max IS NOT NULL AND weapon_min<=weapon_max)),
CHECK(cap_value IS NULL OR cap_value>=0)
)`);
await database.exec(`INSERT INTO stat_attributes_current(
id,name,description,type,category,calculation_type,unit,
gear_min,gear_max,gear_reference_level,weapon_min,weapon_max,weapon_reference_level,
cap_value,active,created_at,updated_at
) SELECT id,name,description,type,category,calculation_type,unit,
gear_min,gear_max,gear_reference_level,weapon_min,weapon_max,weapon_reference_level,
cap_value,active,created_at,updated_at FROM stat_attributes`);
await database.exec('DROP TABLE stat_attributes');
await database.exec(
'ALTER TABLE stat_attributes_current RENAME TO stat_attributes',
);
await database.exec(
'CREATE UNIQUE INDEX stat_attributes_name_unit_nocase_idx ON stat_attributes(name COLLATE NOCASE,unit)',
);
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
} finally {
await database.exec('PRAGMA foreign_keys=ON');
}
}
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 (
await database.prepare("PRAGMA table_info('builds')")
).all();
@@ -2926,9 +3446,34 @@ if (buildColumns.length) {
await database.exec(
"UPDATE builds SET visibility='private' WHERE visibility='draft'",
);
const builds = await (
let builds = await (
await database.prepare('SELECT id,loadout_json FROM builds')
).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
.filter((build) => !isCurrentLoadoutSnapshot(build.loadout_json))
.map((build) => String(build.id));
@@ -3305,11 +3850,55 @@ if (
);
}
const calibrationCases = await (
const outcomeProbabilityColumns = await (
await database.prepare("PRAGMA table_info('conditional_effect_outcomes')")
).all();
if (
!outcomeProbabilityColumns.some(
(column) => column.name === 'probability_percent',
)
) {
await database.exec(
'ALTER TABLE conditional_effect_outcomes ADD COLUMN probability_percent REAL CHECK(probability_percent IS NULL OR (probability_percent>=0 AND probability_percent<=100))',
);
}
let calibrationCases = await (
await database.prepare(
'SELECT id,loadout_snapshot_json FROM calculation_calibration_cases',
)
).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
.filter(
(calibrationCase) =>
+72 -2
View File
@@ -44,21 +44,91 @@ for (const table of backup.tables) {
}
let restoredRows = 0;
const attributeTable = backup.tables.find(
(table) => table.name === 'stat_attributes',
);
const legacySkillChipProfiles = attributeTable?.rows.some(
(row) => 'skill_chip_min' in row || 'skill_chip_max' in row,
);
const legacySkillChipRanges = new Map(
(attributeTable?.rows ?? []).map((row) => [
String(row.id),
{ min: row.skill_chip_min, max: row.skill_chip_max },
]),
);
const restoredLegacySecondaries = new Set();
await database.exec('BEGIN');
try {
for (const table of backup.tables) {
for (const row of table.rows) {
const columns = Object.keys(row);
if (
legacySkillChipProfiles &&
table.name === 'skill_chip_primary_options'
)
continue;
const restoredRow = { ...row };
if (legacySkillChipProfiles && table.name === 'stat_attributes') {
delete restoredRow.skill_chip_min;
delete restoredRow.skill_chip_max;
}
if (
legacySkillChipProfiles &&
table.name === 'skill_chip_secondary_options'
) {
delete restoredRow.skill_chip_set_id;
const statId = String(restoredRow.stat_attribute_id ?? '');
if (restoredLegacySecondaries.has(statId)) continue;
restoredLegacySecondaries.add(statId);
}
const columns = Object.keys(restoredRow);
if (!columns.length) continue;
const columnSql = columns.map(quoteIdentifier).join(', ');
const placeholders = columns.map(() => '?').join(', ');
const insert = await database.prepare(
`INSERT INTO ${quoteIdentifier(table.name)} (${columnSql}) VALUES (${placeholders})`,
);
await insert.run(...columns.map((column) => row[column]));
await insert.run(...columns.map((column) => restoredRow[column]));
restoredRows++;
}
}
if (legacySkillChipProfiles) {
const primaryAttributes = await (
await database.prepare(`SELECT id,active FROM stat_attributes WHERE name IN (
'Skill Duration','Skill Radius','Skill Cooldown Recovery','Skill Intensity'
)`)
).all();
if (
primaryAttributes.length !== 4 ||
primaryAttributes.some((attribute) => Number(attribute.active) !== 1)
)
throw new Error(
'Legacy restore requires exactly four active fixed Skill Chip primary Attributes.',
);
for (const attribute of primaryAttributes) {
const statId = String(attribute.id);
await (
await database.prepare(
'INSERT INTO skill_chip_primary_options(stat_attribute_id) VALUES(?)',
)
).run(statId);
const range = legacySkillChipRanges.get(statId);
if (!range || range.min === null || range.max === null) continue;
for (const quality of ['Standard', 'Specialized', 'Superior', 'High-end'])
await (
await database.prepare(`INSERT INTO skill_chip_primary_quality_values
(stat_attribute_id,quality,min_value,max_value) VALUES(?,?,?,?)`)
).run(statId, quality, range.min, range.max);
}
for (const statId of restoredLegacySecondaries) {
const range = legacySkillChipRanges.get(statId);
if (!range || range.min === null || range.max === null) continue;
for (const quality of ['Specialized', 'Superior', 'High-end'])
await (
await database.prepare(`INSERT INTO skill_chip_secondary_quality_values
(stat_attribute_id,quality,min_value,max_value) VALUES(?,?,?,?)`)
).run(statId, quality, range.min, range.max);
}
}
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
+3 -9
View File
@@ -98,8 +98,6 @@ try {
weapon_min: record.weaponRange?.[0] ?? existing?.weapon_min ?? null,
weapon_max: record.weaponRange?.[1] ?? existing?.weapon_max ?? null,
weapon_reference_level: Number(existing?.weapon_reference_level ?? 50),
skill_chip_min: existing?.skill_chip_min ?? null,
skill_chip_max: existing?.skill_chip_max ?? null,
cap_value: existing?.cap_value ?? null,
active: 1,
created_at: String(existing?.created_at ?? now),
@@ -117,7 +115,7 @@ try {
await (
await database.prepare(`UPDATE stat_attributes SET
name=?,description=?,type=?,category=?,calculation_type=?,unit=?,
gear_min=?,gear_max=?,weapon_min=?,weapon_max=?,skill_chip_min=?,skill_chip_max=?,
gear_min=?,gear_max=?,weapon_min=?,weapon_max=?,
cap_value=?,active=1,updated_at=? WHERE id=?`)
).run(
next.name,
@@ -130,8 +128,6 @@ try {
next.gear_max,
next.weapon_min,
next.weapon_max,
next.skill_chip_min,
next.skill_chip_max,
next.cap_value,
now,
id,
@@ -142,8 +138,8 @@ try {
await database.prepare(`INSERT INTO stat_attributes(
id,name,description,type,category,calculation_type,unit,
gear_min,gear_max,gear_reference_level,weapon_min,weapon_max,weapon_reference_level,
skill_chip_min,skill_chip_max,cap_value,active,created_at,updated_at
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?)`)
cap_value,active,created_at,updated_at
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?)`)
).run(
id,
next.name,
@@ -158,8 +154,6 @@ try {
next.weapon_min,
next.weapon_max,
next.weapon_reference_level,
next.skill_chip_min,
next.skill_chip_max,
next.cap_value,
now,
now,
+6 -2
View File
@@ -2,7 +2,11 @@ import { connect } from '@tursodatabase/database';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { basename, dirname, join, resolve } from 'node:path';
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(
process.env.TDR_DATABASE_PATH || './data/shd-build-development.db',
@@ -67,7 +71,7 @@ for (const row of [
const snapshot = {
format: 'shd-build-isac-data',
version: 2,
version: ISAC_DATA_FORMAT_VERSION,
createdAt: new Date().toISOString(),
sourceDatabase: basename(databasePath),
totalRows,
+233 -2
View File
@@ -3,7 +3,11 @@ import { spawnSync } from 'node:child_process';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { basename, join, resolve } from 'node:path';
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
.slice(2)
@@ -22,7 +26,7 @@ const snapshotPath = resolve(snapshotArgument);
const snapshot = JSON.parse(await readFile(snapshotPath, 'utf8'));
if (
snapshot?.format !== 'shd-build-isac-data' ||
snapshot?.version !== 2 ||
snapshot?.version !== ISAC_DATA_FORMAT_VERSION ||
!Array.isArray(snapshot.tables) ||
!Array.isArray(snapshot.icons)
) {
@@ -39,6 +43,233 @@ if (
'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']) {
const options = snapshot.tables.find(
(table) => table.name === `${kind}_mod_secondary_options`,
).rows;
const values = snapshot.tables.find(
(table) => table.name === `${kind}_mod_secondary_quality_values`,
).rows;
const optionIds = options.map((row) => String(row.stat_attribute_id ?? ''));
if (
optionIds.some((id) => !id) ||
new Set(optionIds).size !== optionIds.length ||
(optionIds.length !== 0 &&
(optionIds.length < 2 || optionIds.length > 50)) ||
values.length !== optionIds.length * 3
) {
throw new Error(
`The ISAC snapshot contains an incomplete shared ${kind} Mod secondary pool.`,
);
}
for (const statId of optionIds) {
const profile = values.filter(
(row) => String(row.stat_attribute_id) === statId,
);
if (
profile.length !== 3 ||
['Specialized', 'Superior', 'High-end'].some(
(quality) => !profile.some((row) => row.quality === quality),
) ||
profile.some((row) => {
const endpoints = [
row.level_1_min,
row.level_1_max,
row.level_20_min,
row.level_20_max,
];
return (
endpoints.some(
(value) => typeof value !== 'number' || !Number.isFinite(value),
) ||
endpoints[1] < endpoints[0] ||
endpoints[2] < endpoints[0] ||
endpoints[3] < endpoints[1] ||
endpoints[3] < endpoints[2]
);
})
) {
throw new Error(
`The ISAC snapshot contains an invalid shared ${kind} Mod secondary profile for ${statId}.`,
);
}
}
if (
values.some(
(row) => !optionIds.includes(String(row.stat_attribute_id ?? '')),
)
) {
throw new Error(
`The ISAC snapshot contains an orphaned shared ${kind} Mod secondary range.`,
);
}
}
for (const [profileName, qualities, requiredOptions] of [
['primary', ['Standard', 'Specialized', 'Superior', 'High-end'], 4],
['secondary', ['Specialized', 'Superior', 'High-end'], null],
]) {
const options = snapshot.tables.find(
(table) => table.name === `skill_chip_${profileName}_options`,
).rows;
const values = snapshot.tables.find(
(table) => table.name === `skill_chip_${profileName}_quality_values`,
).rows;
const optionIds = options.map((row) => String(row.stat_attribute_id ?? ''));
const validCardinality = requiredOptions
? optionIds.length === requiredOptions
: optionIds.length <= 50;
if (
!validCardinality ||
optionIds.some((id) => !id) ||
new Set(optionIds).size !== optionIds.length
) {
throw new Error(
`The ISAC snapshot contains an incomplete shared Skill Chip ${profileName} profile.`,
);
}
if (profileName === 'primary' && optionIds.length) {
const attributes = snapshot.tables.find(
(table) => table.name === 'stat_attributes',
).rows;
const expectedIds = attributes
.filter(
(row) =>
Number(row.active) === 1 &&
[
'Skill Duration',
'Skill Radius',
'Skill Cooldown Recovery',
'Skill Intensity',
].includes(String(row.name)),
)
.map((row) => String(row.id))
.sort();
if (
expectedIds.length !== 4 ||
optionIds
.slice()
.sort()
.some((id, index) => id !== expectedIds[index])
)
throw new Error(
'The ISAC snapshot does not use the four fixed Skill Chip primary Attributes.',
);
}
for (const statId of optionIds) {
const rows = values.filter(
(row) => String(row.stat_attribute_id ?? '') === statId,
);
if (!rows.length) continue;
if (
rows.length !== qualities.length ||
qualities.some(
(quality) => !rows.some((row) => row.quality === quality),
) ||
rows.some(
(row) =>
typeof row.min_value !== 'number' ||
!Number.isFinite(row.min_value) ||
typeof row.max_value !== 'number' ||
!Number.isFinite(row.max_value) ||
row.min_value < 0 ||
row.max_value < row.min_value,
)
) {
throw new Error(
`The ISAC snapshot contains an invalid shared Skill Chip ${profileName} profile for ${statId}.`,
);
}
}
if (
values.some(
(row) => !optionIds.includes(String(row.stat_attribute_id ?? '')),
)
)
throw new Error(
`The ISAC snapshot contains an orphaned shared Skill Chip ${profileName} range.`,
);
}
for (const icon of snapshot.icons) {
if (
typeof icon.storageKey !== 'string' ||
+5
View File
@@ -32,7 +32,9 @@ export const ISAC_DATA_TABLES = [
'skill_chip_sets',
'skill_chip_two_piece_bonuses',
'skill_chip_primary_options',
'skill_chip_primary_quality_values',
'skill_chip_secondary_options',
'skill_chip_secondary_quality_values',
'skill_chip_talents',
'skill_chip_talent_effects',
'weapon_mods',
@@ -55,6 +57,7 @@ export const ISAC_DATA_TABLES = [
'specialization_combat_effects',
'specialization_focuses',
'specialization_abilities',
'specialization_ability_skill_chip_links',
'specialization_ability_attacks',
'specialization_ability_effects',
'specialization_focus_abilities',
@@ -73,5 +76,7 @@ export const ISAC_DATA_TABLES = [
'managed_images',
];
export const ISAC_DATA_FORMAT_VERSION = 5;
export const quoteIdentifier = (identifier) =>
`"${String(identifier).replaceAll('"', '""')}"`;
+1 -1
View File
@@ -23,7 +23,7 @@ const images = [];
for (const row of rows) {
if (
typeof row.storage_key !== 'string' ||
!/^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node)-[0-9a-f-]{36}\.webp$/i.test(
!/^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node|weapon_mod_slot)-[0-9a-f-]{36}\.webp$/i.test(
row.storage_key,
)
) {
+16 -3
View File
@@ -14,6 +14,19 @@ if (!bundleArgument || !process.argv.includes('--confirm-merge')) {
process.exit(2);
}
const updateExisting = process.argv.includes('--update-existing');
const gameDataRenderSourceTypes = new Set([
'weapon_render',
'gear_render',
'weapon_mod_render',
'gear_mod_render',
'talent_render',
'brand_set_render',
'os_protocol_render',
'skill_chip_render',
'specialization_render',
'specialization_ability_render',
'specialization_node_render',
]);
const bundlePath = resolve(bundleArgument);
const bundle = JSON.parse(await readFile(bundlePath, 'utf8'));
if (
@@ -30,21 +43,21 @@ for (const image of bundle.images) {
typeof image?.row?.id !== 'string' ||
!/^[0-9a-f-]{36}$/i.test(image.row.id) ||
typeof image.row.storage_key !== 'string' ||
!/^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node)-[0-9a-f-]{36}\.webp$/i.test(
!/^(?:library|talent|brand_set|os_protocol|skill_chip|specialization|specialization_ability|specialization_node|weapon_mod_slot)-[0-9a-f-]{36}\.webp$/i.test(
image.row.storage_key,
) ||
typeof image.contentBase64 !== 'string'
) {
throw new Error('The bundle contains an invalid managed image.');
}
const isItemRender = ['weapon_render', 'gear_render'].includes(
const isGameDataRender = gameDataRenderSourceTypes.has(
image.row.source_entity_type,
);
if (
image.row.source_entity_type &&
(typeof image.row.source_entity_id !== 'string' ||
!/^[0-9a-f-]{36}$/i.test(image.row.source_entity_id) ||
(isItemRender
(isGameDataRender
? !image.row.storage_key.startsWith('library-')
: !image.row.storage_key.startsWith(
`${image.row.source_entity_type}-`,
+1
View File
@@ -14,6 +14,7 @@ const APP_SECRET_NAMES = new Set([
'OPENAI_MECHANIC_MODEL',
'OPENAI_MECHANIC_REASONING_EFFORT',
'OPENAI_MODERATION_API_KEY',
'MFA_TOTP_ENCRYPTION_KEYS',
'ORIGIN',
'PASSKEY_RP_ID',
'REMOVE_BG_API_KEY',
+1064 -22
View File
File diff suppressed because it is too large Load Diff
+25 -6
View File
@@ -8,6 +8,7 @@ import {
} from '$lib/server/site-access';
import {
DATA_MODULES,
ProposalPayloadValidationError,
proposalActionResult,
queueDataProposal,
shouldModerateDataMutation,
@@ -82,12 +83,30 @@ export const handle: Handle = async ({ event, resolve }) => {
);
}
const submittedForm = await event.request.formData();
const proposal = await queueDataProposal(
user,
module,
action,
submittedForm,
);
let proposal: Awaited<ReturnType<typeof queueDataProposal>>;
try {
proposal = await queueDataProposal(
user,
module,
action,
submittedForm,
);
} catch (error) {
if (!(error instanceof ProposalPayloadValidationError)) throw error;
if (
event.request.headers.get('accept')?.includes('application/json')
) {
return json(
{
type: 'failure',
status: 413,
data: { proposalError: error.message },
},
{ status: 413 },
);
}
return new Response(error.message, { status: 413 });
}
if (event.request.headers.get('accept')?.includes('application/json')) {
const result = proposalActionResult(
module,
+166
View File
@@ -91,6 +91,172 @@ describe('article documents', () => {
).toBeNull();
});
it('accepts and canonicalizes metadata emitted by the Tiptap editor', () => {
const parsed = parseArticleDocument({
version: 1,
document: {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Read more',
marks: [
{
type: 'link',
attrs: {
href: 'https://shd.build/news',
target: '_blank',
rel: 'noopener noreferrer nofollow',
class: null,
title: 'News archive',
},
},
],
},
],
},
{
type: 'orderedList',
attrs: { start: 1, type: null },
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'First item' }],
},
],
},
],
},
],
},
});
expect(parsed?.document.document.content).toEqual([
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Read more',
marks: [
{
type: 'link',
attrs: {
href: 'https://shd.build/news',
title: 'News archive',
},
},
],
},
],
},
{
type: 'orderedList',
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'First item' }],
},
],
},
],
},
]);
expect(parsed?.plainText).toContain('News archive');
});
it('preserves supported ordered-list numbering', () => {
const parsed = parseArticleDocument({
version: 1,
document: {
type: 'doc',
content: [
{
type: 'orderedList',
attrs: { start: 2, type: 'A' },
content: [
{
type: 'listItem',
content: [{ type: 'paragraph' }],
},
],
},
],
},
});
expect(parsed?.document.document.content?.[0]).toMatchObject({
type: 'orderedList',
attrs: { start: 2, type: 'A' },
});
expect(parseArticleDocument(parsed?.document)?.document).toEqual(
parsed?.document,
);
});
it('rejects unknown link metadata and non-default ordered lists', () => {
const documentWith = (content: unknown[]) => ({
version: 1,
document: { type: 'doc', content },
});
expect(
parseArticleDocument(
documentWith([
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'unsafe metadata',
marks: [
{
type: 'link',
attrs: {
href: 'https://shd.build',
onclick: 'alert(1)',
},
},
],
},
],
},
]),
),
).toBeNull();
expect(
parseArticleDocument(
documentWith([
{
type: 'orderedList',
attrs: { start: 0, type: 'decimal' },
content: [],
},
]),
),
).toBeNull();
expect(
parseArticleDocument(
documentWith([
{
type: 'orderedList',
attrs: { start: 1, type: 1 },
content: [],
},
]),
),
).toBeNull();
});
it('rejects duplicate embed keys and unknown envelope fields', () => {
const embed = {
type: 'buildEmbed',
+63 -5
View File
@@ -10,7 +10,7 @@ const EMBED_KEY_PATTERN = /^[a-z0-9][a-z0-9-]{7,63}$/;
export type ArticleMark = {
type: 'bold' | 'italic' | 'code' | 'link';
attrs?: { href: string };
attrs?: { href: string; title?: string };
};
export type ArticleNode = {
@@ -53,7 +53,17 @@ const safeLink = (value: unknown) => {
}
};
function parseMarks(value: unknown): ArticleMark[] | null {
const nullableBoundedString = (value: unknown, maximum: number) =>
value === undefined ||
value === null ||
(typeof value === 'string' &&
value.length <= maximum &&
!/\p{Cc}/u.test(value));
function parseMarks(
value: unknown,
onLinkTitle: (title: string) => void,
): ArticleMark[] | null {
if (value === undefined) return [];
if (!Array.isArray(value) || value.length > 4) return null;
const seen = new Set<string>();
@@ -77,10 +87,31 @@ function parseMarks(value: unknown): ArticleMark[] | null {
!isRecord(candidate.attrs)
)
return null;
if (!exactKeys(candidate.attrs, ['href'])) return null;
if (
!exactKeys(candidate.attrs, [
'href',
'target',
'rel',
'class',
'title',
]) ||
!nullableBoundedString(candidate.attrs.target, 100) ||
!nullableBoundedString(candidate.attrs.rel, 500) ||
!nullableBoundedString(candidate.attrs.class, 500) ||
!nullableBoundedString(candidate.attrs.title, 500)
)
return null;
const href = safeLink(candidate.attrs.href);
if (!href) return null;
marks.push({ type: 'link', attrs: { href } });
const title =
typeof candidate.attrs.title === 'string'
? candidate.attrs.title.normalize('NFKC').trim()
: '';
if (title) onLinkTitle(title);
marks.push({
type: 'link',
attrs: { href, ...(title ? { title } : {}) },
});
}
return marks;
}
@@ -134,7 +165,9 @@ export function parseArticleDocument(value: unknown): {
if (/\p{Cc}/u.test(text.replace(/[\n\t]/g, ''))) return null;
textLength += [...text].length;
if (textLength > MAX_ARTICLE_TEXT_CHARACTERS) return null;
const marks = parseMarks(candidate.marks);
const marks = parseMarks(candidate.marks, (title) =>
textParts.push(title),
);
if (!marks) return null;
textParts.push(text);
return marks.length ? { type, text, marks } : { type, text };
@@ -211,6 +244,31 @@ export function parseArticleDocument(value: unknown): {
)
return null;
attrs = { level: Number(candidate.attrs.level) };
} else if (type === 'orderedList' && candidate.attrs !== undefined) {
if (
!isRecord(candidate.attrs) ||
!exactKeys(candidate.attrs, ['start', 'type'])
)
return null;
const start = candidate.attrs.start ?? 1;
const listType = candidate.attrs.type;
if (
!Number.isInteger(start) ||
Number(start) < 1 ||
Number(start) > 1_000_000 ||
(listType !== null &&
listType !== undefined &&
(typeof listType !== 'string' ||
!['1', 'a', 'A', 'i', 'I'].includes(listType)))
)
return null;
const canonicalType =
typeof listType === 'string' && listType !== '1' ? listType : undefined;
attrs = {
...(Number(start) === 1 ? {} : { start: Number(start) }),
...(canonicalType ? { type: canonicalType } : {}),
};
if (!Object.keys(attrs).length) attrs = undefined;
} else if (candidate.attrs !== undefined) {
if (!isRecord(candidate.attrs) || Object.keys(candidate.attrs).length)
return null;
+613 -70
View File
@@ -10,12 +10,36 @@ import {
equippedConditionalProgram,
mechanicModifiersAt,
skillModifierWindowsForMechanic,
evaluateLoadoutCombat,
weaponCriticalChance,
} from './loadout-combat.js';
import {
calculateSkillDps,
type SkillAbilityInput,
} from '$lib/calculations/skill';
import type { MechanicRuntimeSelection } from '$lib/server/mechanic-runtime-selection';
function selectedRuntime(
variants: Record<string, MechanicProgram>,
options: Partial<
Pick<MechanicRuntimeSelection, 'simulationReadiness' | 'scenarioInputs'>
> = {},
): MechanicRuntimeSelection {
return {
owner: { type: 'talent', id: 'talent', scope: 'standard', hasTierII: true },
sourceId: 'source',
sourceFingerprint: 'a'.repeat(64),
revisionId: 'revision',
revisionNumber: 1,
simulationReadiness: options.simulationReadiness ?? 'ready',
scenarioInputs: options.scenarioInputs ?? [],
variants: Object.entries(variants).map(([key, program]) => ({
key,
label: key,
program,
})),
};
}
describe('loadout mechanic timeline', () => {
function magazineBandsFor(magazine: number, encounterSeconds = magazine - 1) {
@@ -176,6 +200,38 @@ describe('loadout mechanic timeline', () => {
).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', () => {
const profile = createDraft().combatProfile;
profile.encounterSeconds = 2;
@@ -259,81 +315,568 @@ describe('loadout mechanic timeline', () => {
).toEqual([1, 2]);
});
it('falls back to a documented Tier I mechanic when equipment Tier II is absent', () => {
const persistedRule = {
rule: {
id: 'tier-i-only',
source_type: 'talent',
source_id: 'exotic-talent',
configured: 1,
script_version: 1,
simulation_readiness: 'ready',
scenario_inputs_json: '[]',
},
statePools: [],
clauses: [
{
id: 'shot-clause',
rule_id: 'tier-i-only',
clause_key: 'shot',
label: 'Shot',
trigger_id: null,
event_kind: 'shot_fired',
parameters_json: '{}',
conditions_json: JSON.stringify({ kind: 'true' }),
reducer_json: JSON.stringify({ kind: 'none' }),
emission_kind: 'each_match',
state_pool_id: null,
mutations_json: '[]',
cooldown_seconds: null,
output_branch: 'active',
sort_order: 1,
active: 1,
},
],
outcomes: [
{
id: 'weapon-damage-i',
rule_id: 'tier-i-only',
clause_id: 'shot-clause',
state_pool_id: null,
effect_key: 'Weapon Damage',
variant_key: 'I',
outcome_key: 'weapon-damage',
application_kind: 'active_modifier',
effect_scope: 'equipped_weapon',
stat_attribute_id: 'weapon-damage',
value: 15,
value_expression_json: null,
value_basis: 'flat',
resource_kind: null,
combat_timing: null,
delivery_mode: 'instant',
delivery_duration_seconds: null,
targeting_json: null,
value_scaling: 'flat',
unit: '%',
damage_basis: null,
application_phase: 'on_event',
min_state_value: null,
max_state_value: null,
description_slot: 1,
sort_order: 1,
},
],
triggers: [],
it('selects a direct validated Phase 2 program without adapting legacy rows', () => {
const tierTwo: MechanicProgram = {
id: 'mechanic-runtime:["source","II"]',
name: 'Selected Tier II',
clauses: [],
};
const adapted = equippedConditionalProgram({
key: 'weapon:0:exotic-talent',
tier: 2,
rule: persistedRule,
expect(
equippedConditionalProgram({
key: 'weapon:0:talent',
tier: 2,
isExotic: false,
runtime: selectedRuntime({ I: { ...tierTwo, id: 'tier-I' }, II: tierTwo }),
}),
).toBe(tierTwo);
});
it('interpolates Skill Chip minimum and maximum programs from one selected revision', () => {
const program = (id: string, value: number): MechanicProgram => ({
id,
name: 'Skill Chip',
clauses: [
{
id: 'chip-clause',
when: 'weapon_hit',
reducer: { kind: 'none' },
mutations: [],
outcomes: [
{
id: 'chip-outcome',
label: 'Chip outcome',
phase: 'on_event',
kind: 'attribute_modifier',
scope: 'all_skills',
value,
basis: 'flat',
attributeId: 'skill-damage',
},
],
},
],
});
const runtime = selectedRuntime({
minimum: program('minimum', 10),
maximum: program('maximum', 40),
});
expect(adapted?.clauses[0].outcomes?.[0]).toMatchObject({
id: 'weapon-damage-i',
value: 15,
const interpolated = equippedConditionalProgram({
key: 'chips:chip-set:ability',
tier: 2,
isExotic: false,
runtime,
});
expect(interpolated?.clauses[0].outcomes?.[0]?.value).toBe(30);
expect(interpolated?.id).toBe('minimum');
});
it('interpolates expression-backed outcomes, nested values, and pool durations', () => {
const program = (id: string, value: number): MechanicProgram => ({
id,
name: 'Skill Chip',
scenarioInputs: [
{
key: 'target-count',
label: 'Target count',
kind: 'slider',
readiness: 'ready',
defaultValue: value,
bestCaseValue: value + 3,
min: value - 2,
max: value + 7,
},
],
pools: [
{
key: 'chip-pool',
label: 'Chip pool',
scope: 'agent',
initial: value,
min: value - 2,
max: value * 2,
bestCaseValue: value + 2,
expiresAfterSeconds: value - 1,
},
],
clauses: [
{
id: 'chip-clause',
when: 'weapon_hit',
reducer: {
kind: 'periodic',
immediate: true,
intervalSeconds: {
op: 'clamp',
value: {
op: 'add',
left: value,
right: { op: 'floor', value: value + 1 },
},
min: value - 1,
max: value * 3,
},
},
cooldownSeconds: value,
weight: {
op: 'multiply',
left: value,
right: {
op: 'subtract',
left: { ref: { source: 'scenario', key: 'target-count' } },
right: value - 1,
},
},
mutations: [
{
kind: 'add',
pool: 'chip-pool',
value: {
op: 'multiply',
left: value,
right: { op: 'add', left: value, right: 1 },
},
},
],
outcomes: [
{
id: 'chip-outcome',
label: 'Chip outcome',
phase: 'while_active',
kind: 'attribute_modifier',
scope: 'all_skills',
value: {
op: 'multiply',
left: {
op: 'clamp',
value: {
op: 'add',
left: value,
right: { op: 'floor', value: value + 1 },
},
min: value - 1,
max: value * 3,
},
right: {
ref: { source: 'attribute', attributeId: 'skill-damage' },
},
},
basis: 'flat',
attributeId: 'skill-damage',
statePool: 'chip-pool',
perStack: true,
durationSeconds: value,
minStacks: value - 1,
maxStacks: value * 2,
probabilityPercent: value * 10,
},
],
},
],
});
const runtime = selectedRuntime({
minimum: program('minimum', 3),
maximum: program('maximum', 15),
});
const interpolated = equippedConditionalProgram({
key: 'chips:chip-set:ability',
tier: 1,
isExotic: false,
runtime,
});
expect(interpolated?.scenarioInputs?.[0]).toMatchObject({
defaultValue: 7,
bestCaseValue: 10,
min: 5,
max: 14,
});
expect(interpolated?.pools?.[0]).toMatchObject({
initial: 7,
min: 5,
max: 14,
bestCaseValue: 9,
expiresAfterSeconds: 6,
});
expect(interpolated?.clauses[0]).toMatchObject({
cooldownSeconds: 7,
reducer: {
kind: 'periodic',
immediate: true,
intervalSeconds: {
op: 'clamp',
value: {
op: 'add',
left: 7,
right: { op: 'floor', value: 8 },
},
min: 6,
max: 21,
},
},
mutations: [
{
value: {
op: 'multiply',
left: 7,
right: { op: 'add', left: 7, right: 1 },
},
},
],
});
expect(interpolated?.clauses[0].outcomes?.[0]).toMatchObject({
value: {
op: 'multiply',
left: {
op: 'clamp',
value: {
op: 'add',
left: 7,
right: { op: 'floor', value: 8 },
},
min: 6,
max: 21,
},
right: {
ref: { source: 'attribute', attributeId: 'skill-damage' },
},
},
attributeId: 'skill-damage',
statePool: 'chip-pool',
perStack: true,
durationSeconds: 7,
minStacks: 6,
maxStacks: 14,
probabilityPercent: 70,
});
});
it('returns exact endpoint programs and retains quality-count clamping', () => {
const program = (id: string, value: number): MechanicProgram => ({
id,
name: 'Skill Chip',
clauses: [
{
id: 'chip-clause',
when: 'weapon_hit',
reducer: { kind: 'none' },
mutations: [],
outcomes: [
{
id: 'chip-outcome',
label: 'Chip outcome',
phase: 'on_event',
kind: 'attribute_modifier',
scope: 'all_skills',
value,
basis: 'flat',
attributeId: 'skill-damage',
},
],
},
],
});
const minimum = program('minimum', 10);
const maximum = program('maximum', 40);
const runtime = selectedRuntime({ minimum, maximum });
const selected = (tier: number) =>
equippedConditionalProgram({
key: 'chips:chip-set:ability',
tier,
isExotic: false,
runtime,
});
expect(selected(-1)).toBe(minimum);
expect(selected(0)).toBe(minimum);
expect(selected(3)).toBe(maximum);
expect(selected(4)).toBe(maximum);
});
it('withholds Skill Chip execution when minimum and maximum graphs differ', () => {
const minimum: MechanicProgram = {
id: 'minimum',
name: 'Skill Chip',
clauses: [
{
id: 'chip-clause',
when: 'weapon_hit',
reducer: { kind: 'none' },
mutations: [],
outcomes: [
{
id: 'chip-outcome',
label: 'Chip outcome',
phase: 'on_event',
kind: 'attribute_modifier',
scope: 'all_skills',
value: {
op: 'multiply',
left: 10,
right: {
ref: { source: 'attribute', attributeId: 'skill-damage' },
},
},
basis: 'flat',
attributeId: 'skill-damage',
},
],
},
],
};
const maximum: MechanicProgram = {
...minimum,
id: 'maximum',
clauses: [
{
...minimum.clauses[0],
outcomes: [
{
...minimum.clauses[0].outcomes![0],
value: {
op: 'multiply',
left: 40,
right: {
ref: { source: 'attribute', attributeId: 'skill-duration' },
},
},
},
],
},
],
};
const selected = (
runtime: MechanicRuntimeSelection,
tier = 2,
) =>
equippedConditionalProgram({
key: 'chips:chip-set:ability',
tier,
isExotic: false,
runtime,
});
expect(selected(selectedRuntime({ minimum }))).toBeNull();
expect(selected(selectedRuntime({ maximum }))).toBeNull();
expect(selected(selectedRuntime({ minimum, maximum }), 0)).toBeNull();
expect(selected(selectedRuntime({ minimum, maximum }))).toBeNull();
expect(selected(selectedRuntime({ minimum, maximum }), 3)).toBeNull();
const extraOutcomeMaximum: MechanicProgram = {
...minimum,
id: 'maximum-extra-outcome',
clauses: [
{
...minimum.clauses[0],
outcomes: [
...minimum.clauses[0].outcomes!,
{ ...minimum.clauses[0].outcomes![0], id: 'additional-outcome' },
],
},
],
};
expect(
selected(selectedRuntime({ minimum, maximum: extraOutcomeMaximum })),
).toBeNull();
});
it('falls back to Tier I only for an exotic selected revision that omits Tier II', () => {
const tierOne: MechanicProgram = {
id: 'mechanic-runtime:["exotic-source","I"]',
name: 'Selected Tier I',
clauses: [],
};
const runtime = selectedRuntime({ I: tierOne });
expect(
equippedConditionalProgram({
key: 'weapon:0:exotic-talent',
tier: 2,
isExotic: true,
runtime,
}),
).toBe(tierOne);
expect(
equippedConditionalProgram({
key: 'weapon:0:standard-talent',
tier: 2,
isExotic: false,
runtime,
}),
).toBeNull();
});
it('does not execute an unselected or stale legacy payload', () => {
const staleHistorical = {
key: 'weapon:0:talent',
tier: 1,
isExotic: false,
runtime: null,
rule: { clauses: [{ outcomes: [{ value: 999 }] }] },
};
expect(equippedConditionalProgram(staleHistorical)).toBeNull();
});
});
describe('requires-assumptions mechanic gating', () => {
const program: MechanicProgram = {
id: 'assumed-direct-damage',
name: 'Assumed direct damage',
scenarioInputs: [
{
key: 'assumed-direct-damage',
label: 'Assumed direct damage',
kind: 'number',
readiness: 'requires_assumption',
min: 1,
max: 100,
},
],
clauses: [
{
id: 'damage-on-hit',
when: 'weapon_hit',
reducer: { kind: 'none' },
mutations: [],
outcomes: [
{
id: 'assumed-damage',
label: 'Assumed damage',
phase: 'on_event',
kind: 'direct_damage',
scope: 'equipped_weapon',
basis: 'flat',
value: {
ref: { source: 'scenario', key: 'assumed-direct-damage' },
},
},
],
},
],
};
function fixture(inputs?: Record<string, number | boolean>) {
const draft = createDraft();
for (const weapon of draft.weapons) {
weapon.itemId = 'weapon';
weapon.damage = 100;
}
for (const slot of Object.keys(draft.gear)) draft.gear[slot].itemId = 'gear';
for (const chip of draft.chips) chip.setId = 'chip';
draft.protocol.itemId = 'protocol';
draft.specialization.focusId = 'focus';
draft.conditionalEffects['weapon:0:assumed'] = {
mode: 'encounter',
...(inputs ? { inputs } : {}),
};
const runtime = selectedRuntime(
{ I: program },
{
simulationReadiness: 'requires_assumptions',
scenarioInputs: program.scenarioInputs,
},
);
return {
draft,
catalog: {
attributes: [
{
id: 'weapon-damage',
name: 'Weapon Damage',
calculation_type: 'Additive',
unit: '%',
cap_value: null,
},
],
gear: [],
gearAugments: [],
brands: [],
weapons: [
{
id: 'weapon',
name: 'Weapon',
category: 'Assault Rifle',
rpm: 60,
magazine: 100,
reload_speed: 1,
},
],
talents: [],
chipSets: [],
protocols: [],
specializations: [
{
focuses: [
{
id: 'focus',
abilities: [{ id: 'ability-1' }, { id: 'ability-2' }, { id: 'ability-3' }],
nodes: [],
},
],
},
],
calculation: {
revision: { id: 'revision', version: 1 },
stages: [],
profiles: [
{
weapon_category: '*',
stage_key: 'weapon_damage',
stat_attribute_id: 'weapon-damage',
weight: 1,
},
],
},
},
summary: {
stats: [{ id: 'weapon-damage', value: 0, sourceCount: 0 }],
abilityStats: [],
activeBonuses: [],
equippedTalents: [
{
key: 'weapon:0:assumed',
id: 'assumed',
runtime,
tier: 1,
isExotic: false,
source: 'weapon',
description: '',
custom: { name: 'Assumed direct damage' },
},
],
},
};
}
it('blocks combat output when an active requires-assumptions mechanic has no caller input', () => {
const { draft, catalog, summary } = fixture();
const primary = evaluateLoadoutCombat(draft, catalog, summary)[0];
expect(primary.status).toBe('insufficient');
if (primary.status === 'insufficient') {
expect(primary.missing).toContain(
'Conditional mechanic Assumed direct damage requires explicit valid scenario inputs: Assumed direct damage',
);
}
});
it('executes a requires-assumptions mechanic when the caller supplies a valid input', () => {
const { draft, catalog, summary } = fixture({
'assumed-direct-damage': 50,
});
const primary = evaluateLoadoutCombat(draft, catalog, summary)[0];
expect(primary.status).toBe('complete');
if (primary.status === 'complete') {
expect(primary.effectSummaries?.[0]?.activationCount).toBeGreaterThan(0);
expect(primary.encounterDps).toBeGreaterThan(100);
}
});
});
+256 -89
View File
@@ -7,14 +7,15 @@ import {
} from '$lib/calculations/combat';
import {
evaluateMechanicProgram,
PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_VALUE_KEY,
PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_INPUT,
type MechanicCombatContext,
type MechanicEvaluation,
type MechanicEvent,
type MechanicOutcome,
type MechanicProgram,
type NumericExpression,
} from '$lib/calculations/mechanic';
import { adaptConditionalEffectProgram } from '$lib/calculations/mechanic-persistence';
import type { MechanicRuntimeSelection } from '$lib/server/mechanic-runtime-selection';
import {
calculateSkillDps,
skillActivationTimes,
@@ -190,103 +191,157 @@ export function weaponCriticalChance(
);
}
function conditionalProgram(
rule: any,
function selectedRuntimeProgram(
runtime: MechanicRuntimeSelection | null | undefined,
variantKey: string,
valueScale = 1,
): MechanicProgram | null {
if (!rule?.rule) return null;
const adapted = adaptConditionalEffectProgram(rule, { variantKey });
if (
!adapted.program ||
adapted.status === 'INVALID' ||
adapted.status === 'CAPTURED_ONLY'
)
return null;
if (valueScale === 1) return adapted.program;
const scale = (value: NumericExpression): NumericExpression =>
typeof value === 'number' ? value * valueScale : value;
return {
...adapted.program,
clauses: adapted.program.clauses.map((clause) => ({
...clause,
outcomes: clause.outcomes?.map((outcome) => ({
...outcome,
value: scale(outcome.value),
})),
})),
};
return runtime?.variants.find((variant) => variant.key === variantKey)?.program ?? null;
}
function interpolatedConditionalProgram(
rule: any,
function hasMechanicProgram<T extends { program: MechanicProgram | null }>(
source: T,
): source is T & { program: MechanicProgram } {
return source.program !== null;
}
type InterpolatedMechanicValue = { value: unknown };
function isMechanicDefinitionRecord(
value: unknown,
): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
/**
* Interpolates numeric leaves only when the entire mechanic definition graph
* matches. Non-numeric values carry mechanic identity and semantics, so a
* mismatch withholds the program rather than combining different mechanics.
*/
function interpolateMechanicValue(
minimum: unknown,
maximum: unknown,
ratio: number,
): InterpolatedMechanicValue | null {
if (typeof minimum === 'number' || typeof maximum === 'number') {
if (
typeof minimum !== 'number' ||
typeof maximum !== 'number' ||
!Number.isFinite(minimum) ||
!Number.isFinite(maximum)
)
return null;
return { value: minimum + (maximum - minimum) * ratio };
}
if (Object.is(minimum, maximum)) return { value: minimum };
if (Array.isArray(minimum) || Array.isArray(maximum)) {
if (
!Array.isArray(minimum) ||
!Array.isArray(maximum) ||
minimum.length !== maximum.length
)
return null;
const values: unknown[] = [];
for (let index = 0; index < minimum.length; index += 1) {
const interpolated = interpolateMechanicValue(
minimum[index],
maximum[index],
ratio,
);
if (!interpolated) return null;
values.push(interpolated.value);
}
return { value: values };
}
if (
!isMechanicDefinitionRecord(minimum) ||
!isMechanicDefinitionRecord(maximum)
)
return null;
const minimumKeys = Object.keys(minimum).sort();
const maximumKeys = Object.keys(maximum).sort();
if (
minimumKeys.length !== maximumKeys.length ||
minimumKeys.some((key, index) => key !== maximumKeys[index])
)
return null;
const entries: [string, unknown][] = [];
for (const key of minimumKeys) {
const interpolated = interpolateMechanicValue(
minimum[key],
maximum[key],
ratio,
);
if (!interpolated) return null;
entries.push([key, interpolated.value]);
}
return { value: Object.fromEntries(entries) };
}
function interpolateMechanicProgram(
minimum: MechanicProgram,
maximum: MechanicProgram,
ratio: number,
): MechanicProgram | null {
const { id: minimumId, ...minimumDefinition } = minimum;
const { id: _maximumId, ...maximumDefinition } = maximum;
const interpolated = interpolateMechanicValue(
minimumDefinition,
maximumDefinition,
ratio,
);
if (!interpolated || !isMechanicDefinitionRecord(interpolated.value))
return null;
return { id: minimumId, ...interpolated.value } as MechanicProgram;
}
function interpolatedRuntimeProgram(
runtime: MechanicRuntimeSelection | null | undefined,
minimumVariant: string,
maximumVariant: string,
step: number,
totalSteps: number,
): MechanicProgram | null {
const minimum = conditionalProgram(rule, minimumVariant);
const maximum = conditionalProgram(rule, maximumVariant);
if (!minimum || !maximum) return minimum ?? maximum;
const minimum = selectedRuntimeProgram(runtime, minimumVariant);
const maximum = selectedRuntimeProgram(runtime, maximumVariant);
if (!minimum || !maximum) return null;
const ratio = Math.max(
0,
Math.min(1, Number(step) / Math.max(1, Number(totalSteps))),
);
const maximumByKey = new Map(
maximum.clauses.flatMap((clause) =>
(clause.outcomes ?? []).map(
(outcome) => [`${clause.id}:${outcome.id}`, outcome] as const,
),
),
);
return {
...minimum,
clauses: minimum.clauses.map((clause) => ({
...clause,
outcomes: clause.outcomes?.map((outcome) => {
const upper = maximumByKey.get(`${clause.id}:${outcome.id}`);
return upper &&
typeof outcome.value === 'number' &&
typeof upper.value === 'number'
? {
...outcome,
value: outcome.value + (upper.value - outcome.value) * ratio,
}
: outcome;
}),
})),
};
if (!Number.isFinite(ratio)) return null;
const interpolated = interpolateMechanicProgram(minimum, maximum, ratio);
if (!interpolated) return null;
if (ratio === 0) return minimum;
if (ratio === 1) return maximum;
return interpolated;
}
export function equippedConditionalProgram(
equipped: any,
equipped: Pick<
LoadoutSummary['equippedTalents'][number],
'key' | 'runtime' | 'tier' | 'isExotic'
>,
): MechanicProgram | null {
if (String(equipped.key).startsWith('chips:')) {
return interpolatedConditionalProgram(
equipped.rule,
if (equipped.key.startsWith('chips:')) {
return interpolatedRuntimeProgram(
equipped.runtime,
'minimum',
'maximum',
Number(equipped.tier),
3,
);
}
if (String(equipped.key).startsWith('protocol:')) {
return conditionalProgram(equipped.rule, 'base');
if (equipped.key.startsWith('protocol:')) {
return selectedRuntimeProgram(equipped.runtime, 'base');
}
const equipmentTalent =
String(equipped.key).startsWith('weapon:') ||
String(equipped.key).startsWith('gear:');
const variant = equipmentTalent
? Number(equipped.tier) >= 2
? 'II'
: 'I'
: 'base';
return (
conditionalProgram(equipped.rule, variant) ??
(equipmentTalent && variant === 'II'
? conditionalProgram(equipped.rule, 'I')
: null)
);
equipped.key.startsWith('weapon:') || equipped.key.startsWith('gear:');
const variant = equipmentTalent && Number(equipped.tier) >= 2 ? 'II' : 'I';
const selected = selectedRuntimeProgram(equipped.runtime, variant);
return selected ??
(equipmentTalent && equipped.isExotic && variant === 'II'
? selectedRuntimeProgram(equipped.runtime, 'I')
: null);
}
export type ActiveMechanicEvaluation = {
@@ -297,15 +352,58 @@ export type ActiveMechanicEvaluation = {
events?: MechanicEvent[];
};
function configuredScenarioInputValues(configured: unknown) {
return configured && typeof configured === 'object' && !Array.isArray(configured)
? (configured as Record<string, unknown>)
: {};
}
function requiredScenarioInputs(program: MechanicProgram) {
const inputs = program.scenarioInputs ?? [];
const assumptionInputs = inputs.filter(
(input) => input.readiness === 'requires_assumption',
);
return assumptionInputs.length ? assumptionInputs : inputs;
}
function isValidScenarioInputValue(
input: NonNullable<MechanicProgram['scenarioInputs']>[number],
value: unknown,
) {
if (input.kind === 'toggle') return typeof value === 'boolean';
if (typeof value !== 'number' || !Number.isFinite(value)) return false;
if (input.min !== undefined && value < input.min) return false;
if (input.max !== undefined && value > input.max) return false;
return true;
}
function unresolvedScenarioInputs(
program: MechanicProgram,
configured: unknown,
simulationReadiness: MechanicRuntimeSelection['simulationReadiness'],
) {
if (simulationReadiness !== 'requires_assumptions') return [];
const inputs = requiredScenarioInputs(program);
if (!inputs.length) return ['no supported required scenario inputs'];
const values = configuredScenarioInputValues(configured);
return inputs.flatMap((input) => {
if (!Object.prototype.hasOwnProperty.call(values, input.key))
return [input.label];
return isValidScenarioInputValue(input, values[input.key])
? []
: [`${input.label} (invalid value)`];
});
}
function mechanicScenarioInputs(
program: MechanicProgram,
configured: unknown,
maximum = false,
) {
const values =
configured && typeof configured === 'object' && !Array.isArray(configured)
? (configured as Record<string, number | boolean>)
: {};
): Record<string, number | boolean> {
const values = configuredScenarioInputValues(configured) as Record<
string,
number | boolean
>;
return Object.fromEntries(
(program.scenarioInputs ?? []).map((input) => [
input.key,
@@ -320,22 +418,59 @@ function mechanicScenarioInputs(
);
}
function mechanicIsEnabled(
state: Pick<LoadoutDraft['conditionalEffects'][string], 'mode'>,
calculationMode: LoadoutDraft['calculationMode'],
) {
return calculationMode === 'maximum' || state.mode !== 'off';
}
function unavailableMechanicEvidence(
sources: ReadonlyArray<{
key: string;
name: string;
program: MechanicProgram;
simulationReadiness: MechanicRuntimeSelection['simulationReadiness'];
}>,
draft: LoadoutDraft,
) {
return sources.flatMap((source) => {
const state = draft.conditionalEffects[source.key] ?? { mode: 'off' };
if (!mechanicIsEnabled(state, draft.calculationMode)) return [];
const inputs = unresolvedScenarioInputs(
source.program,
state.inputs,
source.simulationReadiness,
);
return inputs.length
? [
`Conditional mechanic ${source.name} requires explicit valid scenario inputs: ${inputs.join(', ')}`,
]
: [];
});
}
function evaluateConfiguredMechanic(
program: MechanicProgram,
buildEvents: (
scenarioInputs: Record<string, number | boolean>,
) => MechanicEvent[],
state: any,
state: LoadoutDraft['conditionalEffects'][string],
profile: LoadoutDraft['combatProfile'],
weaponBaseDamage: number,
maxHealth: number,
calculationMode: LoadoutDraft['calculationMode'],
equipmentContext = { protocolTalentUpgrades: 0 },
attributes: Record<string, number> = {},
simulationReadiness: MechanicRuntimeSelection['simulationReadiness'] = 'ready',
): ActiveMechanicEvaluation | null {
if (calculationMode !== 'maximum' && state.mode === 'off') return null;
if (!mechanicIsEnabled(state, calculationMode)) return null;
if (unresolvedScenarioInputs(program, state.inputs, simulationReadiness).length)
return null;
const encounterSeconds = Math.max(1, Number(profile.encounterSeconds));
const maximum = calculationMode === 'maximum' || state.mode === 'peak';
const maximum =
simulationReadiness !== 'requires_assumptions' &&
(calculationMode === 'maximum' || state.mode === 'peak');
const scenarioInputs = mechanicScenarioInputs(program, state.inputs, maximum);
const events = buildEvents(scenarioInputs);
const result = evaluateMechanicProgram(
@@ -803,6 +938,11 @@ export function buildWeaponMechanicTimeline(
allyCount: Number(
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({
@@ -1038,7 +1178,7 @@ function skillModifierWindows(
catalog: Catalog,
summary: LoadoutSummary,
allowedAttributeIds: Set<string>,
): SkillModifierWindow[] {
): { windows: SkillModifierWindow[]; unavailable: string[] } {
const focus = selectedFocus(draft, catalog);
const combatEffects = selectedNodeRanks(draft, catalog).flatMap(
({ node, rank }: any) =>
@@ -1051,21 +1191,25 @@ function skillModifierWindows(
const sources = [
...summary.equippedTalents.map((equipped: any) => ({
key: String(equipped.key),
name: String(equipped.custom?.name ?? 'Talent'),
abilityId: equipped.abilityId ? String(equipped.abilityId) : null,
equipmentContext: {
protocolTalentUpgrades: String(equipped.key).startsWith('protocol:')
? Number(equipped.tier)
: 0,
},
simulationReadiness: equipped.runtime?.simulationReadiness ?? 'ready',
program: equippedConditionalProgram(equipped),
})),
...combatEffects.map((effect: any) => ({
key: String(effect.id),
name: String(effect.definition?.name ?? effect.nodeName ?? 'Specialization effect'),
abilityId: effect.abilityId,
equipmentContext: { protocolTalentUpgrades: 0 },
program: conditionalProgram(effect.rule, 'base'),
simulationReadiness: effect.runtime?.simulationReadiness ?? 'ready',
program: selectedRuntimeProgram(effect.runtime, 'base'),
})),
].filter((source) => source.program);
].filter(hasMechanicProgram);
const primarySelection = draft.weapons[0];
const primaryWeapon = catalog.weapons.find(
(weapon) => weapon.id === primarySelection.itemId,
@@ -1116,6 +1260,7 @@ function skillModifierWindows(
draft.calculationMode,
source.equipmentContext,
baseAttributes,
source.simulationReadiness,
);
return evaluation ? [{ ...source, evaluation }] : [];
});
@@ -1131,7 +1276,10 @@ function skillModifierWindows(
);
}
return windows;
return {
windows,
unavailable: unavailableMechanicEvidence(sources, draft),
};
}
export function evaluateLoadoutCombat(
@@ -1159,6 +1307,7 @@ export function evaluateLoadoutCombat(
? Number(equipped.tier)
: 0,
},
simulationReadiness: equipped.runtime?.simulationReadiness ?? 'ready',
program: equippedConditionalProgram(equipped),
})),
...combatEffects.map((effect: any) => ({
@@ -1167,9 +1316,10 @@ export function evaluateLoadoutCombat(
effect.definition?.name ?? effect.nodeName ?? 'Specialization effect',
),
equipmentContext: { protocolTalentUpgrades: 0 },
program: conditionalProgram(effect.rule, 'base'),
simulationReadiness: effect.runtime?.simulationReadiness ?? 'ready',
program: selectedRuntimeProgram(effect.runtime, 'base'),
})),
].filter((source) => source.program);
].filter(hasMechanicProgram);
const skillDeploymentEvents = loadoutSkillDeploymentEvents(
draft,
catalog,
@@ -1194,6 +1344,7 @@ export function evaluateLoadoutCombat(
missing.push(
`${missingChips} specialization Skill Chip${missingChips === 1 ? '' : 's'}`,
);
missing.push(...unavailableMechanicEvidence(conditionalSources, draft));
return missing;
};
@@ -1244,6 +1395,7 @@ export function evaluateLoadoutCombat(
draft.calculationMode,
source.equipmentContext,
criticalAttributes,
source.simulationReadiness,
);
return evaluation ? [{ ...source, state, evaluation }] : [];
});
@@ -1411,6 +1563,21 @@ export function evaluateLoadoutSkillCombat(
.map((stat) => [stat.id, stat.value]),
);
const abilities = loadoutSkillAbilities(draft, catalog);
const mechanicWindows = skillModifierWindows(
draft,
catalog,
summary,
boundIds,
);
if (mechanicWindows.unavailable.length) {
return {
status: 'INSUFFICIENT_DATA',
revisionId: revisionId || null,
missing: mechanicWindows.unavailable,
abilities: [],
combined: null,
};
}
return calculateSkillDps({
rules: { revisionId, bindings },
abilities,
@@ -1433,7 +1600,7 @@ export function evaluateLoadoutSkillCombat(
];
}),
),
modifierWindows: skillModifierWindows(draft, catalog, summary, boundIds),
modifierWindows: mechanicWindows.windows,
assumptions: {
encounterSeconds: draft.combatProfile.encounterSeconds,
targetCount: Math.max(
+341 -24
View File
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest';
import type { MechanicRuntimeSelection } from '$lib/server/mechanic-runtime-selection';
import { createDraft } from './loadout.js';
import { summarizeLoadout, type LoadoutCatalog } from './loadout-summary.js';
@@ -35,10 +36,29 @@ const catalog = (overrides: Partial<LoadoutCatalog> = {}): LoadoutCatalog => ({
const stat = (summary: ReturnType<typeof summarizeLoadout>, id: string) =>
summary.stats.find((entry) => entry.id === id);
const selectedRuntime = (
sourceId: string,
variantKeys: string[],
): MechanicRuntimeSelection => ({
owner: { type: 'talent', id: sourceId, scope: 'standard', hasTierII: true },
sourceId,
sourceFingerprint: 'a'.repeat(64),
revisionId: `${sourceId}-revision`,
revisionNumber: 1,
simulationReadiness: 'ready',
scenarioInputs: [],
variants: variantKeys.map((key) => ({
key,
label: key,
program: { id: `${sourceId}:${key}`, name: sourceId, clauses: [] },
})),
});
describe('summarizeLoadout', () => {
it('returns an empty deterministic summary for a partial draft', () => {
expect(summarizeLoadout(createDraft(), catalog())).toEqual({
stats: [],
abilityStats: [],
activeBonuses: [],
equippedTalents: [],
});
@@ -192,7 +212,6 @@ describe('summarizeLoadout', () => {
max_value: 45,
},
],
rule: null,
},
},
],
@@ -206,7 +225,7 @@ describe('summarizeLoadout', () => {
});
expect(summary.equippedTalents).toEqual([
expect.objectContaining({
key: 'chips:chip-set',
key: 'chips:chip-set:ability',
tier: 1,
abilityId: 'ability',
description: 'Gain 25% Skill Damage.',
@@ -214,6 +233,166 @@ describe('summarizeLoadout', () => {
]);
});
it('keeps Skill Chip primaries ability-scoped while adding secondaries to Agent stats', () => {
const draft = createDraft();
draft.specialization.focusId = 'focus-1';
draft.chips[0] = {
setId: 'chip-set',
quality: 'Superior',
primary: { statId: 'skill-duration', value: 12 },
secondary: [
{ statId: 'agent-stat', value: 4 },
{ statId: 'other-secondary', value: 6 },
],
};
const summary = summarizeLoadout(
draft,
catalog({
attributes: [
attribute('skill-duration', 'Skill Duration', { unit: '%' }),
attribute('agent-stat', 'Agent Stat', { unit: '%' }),
attribute('other-secondary', 'Other Secondary', { unit: '%' }),
],
chipSets: [
{
id: 'chip-set',
name: 'Signal',
specialization_ability_id: 'ability-2',
compatibleAbilityIds: ['ability-2', 'ability-1'],
twoPiece: null,
talent: null,
},
],
specializations: [
{
id: 'specialization-1',
focuses: [
{
id: 'focus-1',
abilities: [{ id: 'ability-1' }],
nodes: [],
},
],
},
],
}),
);
expect(stat(summary, 'skill-duration')).toBeUndefined();
expect(stat(summary, 'agent-stat')?.value).toBe(4);
expect(stat(summary, 'other-secondary')?.value).toBe(6);
expect(summary.abilityStats).toEqual([
{
abilityId: 'ability-1',
stats: [
{
id: 'skill-duration',
value: 12,
sourceCount: 1,
sources: [
{
source: 'Skill Chip 1 primary',
chipIndex: 0,
chipSetId: 'chip-set',
value: 12,
},
],
},
],
},
]);
});
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([
[0, 10],
[1, 20],
[2, 30],
[3, 40],
])(
'scales a three-chip 2-piece bonus with %i High-end chips',
(highEndCount, expectedValue) => {
const draft = createDraft();
for (let index = 0; index < 3; index += 1) {
draft.chips[index].setId = 'chip-set';
draft.chips[index].quality =
index < highEndCount ? 'High-end' : 'Standard';
}
const summary = summarizeLoadout(
draft,
catalog({
attributes: [
attribute('skill-damage', 'Skill Damage', { unit: '%' }),
],
chipSets: [
{
id: 'chip-set',
name: 'Signal',
specialization_ability_id: 'ability',
twoPiece: {
stat_attribute_id: 'skill-damage',
min_value: 10,
max_value: 40,
},
talent: null,
},
],
}),
);
expect(stat(summary, 'skill-damage')?.value).toBe(expectedValue);
expect(summary.activeBonuses[0]).toMatchObject({
count: 3,
value: expectedValue,
});
},
);
it('scales Protocol primary stats and Talent descriptions independently', () => {
const draft = createDraft();
draft.protocol = { itemId: 'protocol', level: 50, talentUpgrades: 5 };
@@ -238,7 +417,6 @@ describe('summarizeLoadout', () => {
name: 'Recovery',
description_template: 'Restore {1}.',
effects: [{ stat_attribute_id: 'healing', base_value: 2.5 }],
rule: null,
},
},
],
@@ -322,25 +500,59 @@ describe('summarizeLoadout', () => {
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();
draft.weapons[0].itemId = 'weapon';
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 = {
clauses: [
{
outcomes: [
{
kind: 'attribute_modifier',
attributeId: 'talent-stat',
value: 100,
},
],
},
],
};
const summary = summarizeLoadout(
draft,
catalog({
@@ -352,13 +564,13 @@ describe('summarizeLoadout', () => {
{
id: 'weapon',
name: 'Test Weapon',
exotic_talent_id: null,
exotic_talent_id: 'exotic-talent',
baseBonuses: [],
},
],
talents: [
{
id: 'talent',
id: 'talent-one',
name: 'Measured',
template: 'Gain {1} for {2}.',
tiers: [
@@ -377,7 +589,36 @@ describe('summarizeLoadout', () => {
],
},
],
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 }],
},
],
},
],
}),
@@ -385,15 +626,91 @@ describe('summarizeLoadout', () => {
expect(summary.equippedTalents).toEqual([
expect.objectContaining({
id: 'talent',
id: 'talent-one',
tier: 1,
tierLabel: 'TIER I',
description: 'Gain 10% for 2 Shots.',
runtime: null,
}),
expect.objectContaining({
id: 'talent-two',
tier: 2,
description: 'Gain 20% for 3 Shots.',
rule,
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();
});
it('carries selected runtime capabilities without retaining legacy rule payloads', () => {
const draft = createDraft();
draft.gear.Holster.itemId = 'gear';
draft.gear.Holster.talentId = 'talent';
for (const chip of draft.chips.slice(0, 3)) chip.setId = 'chip-set';
draft.protocol.itemId = 'protocol';
const talentRuntime = selectedRuntime('talent-source', ['I', 'II']);
const chipRuntime = selectedRuntime('chip-source', ['minimum', 'maximum']);
const protocolRuntime = selectedRuntime('protocol-source', ['base']);
const summary = summarizeLoadout(
draft,
catalog({
gear: [{ id: 'gear', tier: 0, base_armor_min: null }],
talents: [
{
id: 'talent',
name: 'Talent',
runtime: talentRuntime,
template: 'Talent',
tiers: [{ tier: 'I', effects: [] }],
},
],
chipSets: [
{
id: 'chip-set',
name: 'Chip',
specialization_ability_id: 'ability',
talent: {
name: 'Chip Talent',
runtime: chipRuntime,
description_template: 'Chip',
effects: [],
},
},
],
protocols: [
{
id: 'protocol',
name: 'Protocol',
talent: {
name: 'Protocol Talent',
runtime: protocolRuntime,
description_template: 'Protocol',
effects: [],
},
},
],
}),
);
expect(summary.equippedTalents.map((equipped) => equipped.runtime)).toEqual([
talentRuntime,
chipRuntime,
protocolRuntime,
]);
expect(
summary.equippedTalents.every(
(equipped) => !Object.hasOwn(equipped, 'rule'),
),
).toBe(true);
});
it('uses the authored tier description when a Talent has no template row', () => {
const draft = createDraft();
draft.weapons[0].itemId = 'weapon';
+202 -35
View File
@@ -1,4 +1,10 @@
import { gearSlots, scaled, type LoadoutDraft } from '$lib/builder/loadout';
import {
gearSlots,
scaled,
type LoadoutDraft,
type TalentTier,
} from '$lib/builder/loadout';
import type { MechanicRuntimeSelection } from '$lib/server/mechanic-runtime-selection';
export type LoadoutCatalog = {
attributes: any[];
@@ -7,6 +13,10 @@ export type LoadoutCatalog = {
brands: any[];
weapons: any[];
talents: any[];
skillChipProfile?: {
primaryOptions: any[];
secondaryOptions: any[];
};
chipSets: any[];
protocols: any[];
specializations: any[];
@@ -14,8 +24,33 @@ export type LoadoutCatalog = {
export type LoadoutSummary = {
stats: Array<{ id: string; value: number; sourceCount: number }>;
abilityStats: Array<{
abilityId: string;
stats: Array<{
id: string;
value: number;
sourceCount: number;
sources: Array<{
source: string;
chipIndex: number;
chipSetId: string;
value: number;
}>;
}>;
}>;
activeBonuses: any[];
equippedTalents: any[];
equippedTalents: Array<{
key: string;
id: string;
runtime: MechanicRuntimeSelection | null;
tier: number;
tierLabel?: string;
isExotic?: boolean;
abilityId?: string | null;
source: string;
description: string;
custom?: { name?: string };
}>;
};
const coreKeys = ['firepower', 'toughness', 'engineering'] as const;
@@ -82,18 +117,21 @@ function unitValue(catalog: LoadoutCatalog, value: unknown, statId: string) {
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);
return (
item?.tiers.find((entry: any) => entry.tier === (tier >= 2 ? 'II' : 'I')) ??
item?.tiers[0]
return item?.tiers.find(
(entry: any) => entry.tier === (tier === 2 ? 'II' : 'I'),
);
}
function talentDescription(
catalog: LoadoutCatalog,
talentId: string,
tier: number,
tier: TalentTier,
) {
const item = catalog.talents.find((entry) => entry.id === talentId);
const configured = talentTier(catalog, talentId, tier);
@@ -138,7 +176,56 @@ export function summarizeLoadout(
)?.id ?? '';
const brandCounts = new Map<string, number>();
const activeBonuses: any[] = [];
const equippedTalents: any[] = [];
const equippedTalents: LoadoutSummary['equippedTalents'] = [];
const abilityValues = new Map<string, Map<string, number>>();
const abilitySources = new Map<
string,
Map<
string,
Array<{
source: string;
chipIndex: number;
chipSetId: string;
value: number;
}>
>
>();
const addAbilityStat = (
abilityId: string,
statId: string,
value: unknown,
chipIndex: number,
chipSetId: string,
) => {
if (
!abilityId ||
!statId ||
!catalogAttribute(catalog, statId) ||
!Number.isFinite(Number(value))
)
return;
if (!abilityValues.has(abilityId)) abilityValues.set(abilityId, new Map());
const valuesForAbility = abilityValues.get(abilityId)!;
valuesForAbility.set(
statId,
applyAttributeValue(
catalog,
valuesForAbility.get(statId) ?? 0,
statId,
Number(value),
),
);
if (!abilitySources.has(abilityId))
abilitySources.set(abilityId, new Map());
const sourcesForAbility = abilitySources.get(abilityId)!;
if (!sourcesForAbility.has(statId)) sourcesForAbility.set(statId, []);
sourcesForAbility.get(statId)!.push({
source: `Skill Chip ${chipIndex + 1} primary`,
chipIndex,
chipSetId,
value: Number(value),
});
};
for (const slot of gearSlots) {
const selection = draft.gear[slot];
@@ -185,15 +272,17 @@ export function summarizeLoadout(
equippedTalents.push({
key: `gear:${slot}:${selection.talentId}`,
id: selection.talentId,
rule:
runtime:
catalog.talents.find((entry) => entry.id === selection.talentId)
?.rule ?? null,
tier: Number(item.tier),
?.runtime ?? null,
tier: selection.talentTier,
tierLabel: selection.talentTier === 2 ? 'TIER II' : 'TIER I',
isExotic: false,
source: slot,
description: talentDescription(
catalog,
selection.talentId,
Number(item.tier),
selection.talentTier,
),
});
}
@@ -238,42 +327,98 @@ export function summarizeLoadout(
roll.value,
`${index ? 'Secondary' : 'Primary'} weapon roll`,
);
for (const talentId of [
selection.talent1Id,
selection.talent2Id,
item.exotic_talent_id,
].filter(Boolean)) {
const equippedWeaponTalents = [
{
id: selection.talent1Id,
tier: selection.talent1Tier,
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({
key: `weapon:${index}:${talentId}`,
id: talentId,
rule:
catalog.talents.find((entry) => entry.id === talentId)?.rule ?? null,
tier: selection.tier,
key: `weapon:${index}:${equippedTalent.id}`,
id: equippedTalent.id,
runtime:
catalog.talents.find((entry) => entry.id === equippedTalent.id)
?.runtime ?? null,
tier: equippedTalent.tier,
tierLabel: equippedTalent.tier === 2 ? 'TIER II' : 'TIER I',
isExotic: equippedTalent.isExotic,
source: index ? 'Secondary weapon' : 'Primary weapon',
description: talentDescription(catalog, talentId, selection.tier),
description: talentDescription(
catalog,
equippedTalent.id,
equippedTalent.tier,
),
});
}
}
const chipGroups = new Map<
string,
{ count: number; highEnd: number; item: any }
{ count: number; highEnd: number; item: any; abilityId: string }
>();
const focus = selectedFocus(draft, catalog);
const validChipRoll = (
kind: 'primaryOptions' | 'secondaryOptions',
quality: string,
roll: { statId: string; value: number },
) => {
if (!catalog.skillChipProfile) return true;
const range = catalog.skillChipProfile[kind].find(
(option) => option.statId === roll.statId,
)?.ranges?.[quality];
return (
range &&
roll.value >= Number(range.min) &&
roll.value <= Number(range.max)
);
};
for (let index = 0; index < 9; index += 1) {
const selection = draft.chips[index];
const item = catalog.chipSets.find((entry) => entry.id === selection.setId);
if (!item) continue;
add(
selection.primary.statId,
selection.primary.value,
`Skill Chip ${index + 1} primary`,
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))
addAbilityStat(
abilityId,
selection.primary.statId,
selection.primary.value,
index,
String(item.id),
);
for (const roll of selection.secondary)
add(roll.statId, roll.value, `Skill Chip ${index + 1} secondary`);
const group = chipGroups.get(item.id) ?? { count: 0, highEnd: 0, item };
if (validChipRoll('secondaryOptions', selection.quality, roll))
add(roll.statId, roll.value, `Skill Chip ${index + 1} secondary`);
const groupKey = `${item.id}:${abilityId}`;
const group = chipGroups.get(groupKey) ?? {
count: 0,
highEnd: 0,
item,
abilityId,
};
group.count += 1;
if (selection.quality === 'High-end') group.highEnd += 1;
chipGroups.set(item.id, group);
chipGroups.set(groupKey, group);
}
for (const group of chipGroups.values()) {
if (group.count >= 2 && group.item.twoPiece) {
@@ -298,12 +443,12 @@ export function summarizeLoadout(
}
if (group.count === 3 && group.item.talent) {
equippedTalents.push({
key: `chips:${group.item.id}`,
key: `chips:${group.item.id}:${group.abilityId}`,
id: '',
custom: group.item.talent,
rule: group.item.talent.rule ?? null,
runtime: group.item.talent.runtime ?? null,
tier: group.highEnd,
abilityId: group.item.specialization_ability_id ?? null,
abilityId: group.abilityId || null,
source: '3 Skill Chips',
description: group.item.talent.description_template.replace(
/\{(\d+)\}/g,
@@ -353,7 +498,7 @@ export function summarizeLoadout(
key: `protocol:${protocol.id}`,
id: '',
custom: protocol.talent,
rule: protocol.talent.rule ?? null,
runtime: protocol.talent.runtime ?? null,
tier: draft.protocol.talentUpgrades,
source: 'OS Protocol',
description: protocol.talent.description_template.replace(
@@ -390,6 +535,28 @@ export function summarizeLoadout(
catalogAttribute(catalog, right.id)?.name ?? '',
),
),
abilityStats: [...abilityValues].map(([abilityId, abilityStatValues]) => ({
abilityId,
stats: [...abilityStatValues]
.map(([id, value]) => {
const statSources = abilitySources.get(abilityId)?.get(id) ?? [];
return {
id,
value,
sourceCount: statSources.length,
sources: statSources,
};
})
.sort(
(left, right) =>
(catalogAttribute(catalog, left.id)?.category ?? '').localeCompare(
catalogAttribute(catalog, right.id)?.category ?? '',
) ||
(catalogAttribute(catalog, left.id)?.name ?? '').localeCompare(
catalogAttribute(catalog, right.id)?.name ?? '',
),
),
})),
activeBonuses,
equippedTalents,
};
+185 -2
View File
@@ -1,20 +1,91 @@
import { describe, expect, it } from 'vitest';
import { createDraft, parseLoadoutDraft } from './loadout.js';
import {
createDraft,
migrateLoadoutDraftV5,
parseLoadoutDraft,
skillChipSecondaryCapacity,
type SkillChipQuality,
} from './loadout.js';
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', () => {
it('accepts a current draft and returns an independent clone', () => {
const draft = createDraft();
const parsed = parseLoadoutDraft(draft);
expect(parsed).toEqual(draft);
expect(parsed?.version).toBe(6);
expect(parsed?.chips[0]).toEqual({
setId: '',
quality: 'Standard',
primary: { statId: '', value: 0 },
secondary: [],
});
expect(parsed?.calculationMode).toBe('maximum');
expect(parsed).not.toBe(draft);
parsed!.gear.Holster.level = 1;
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', () => {
const retired = copyDraft() as unknown as Record<string, unknown>;
retired.version = 1;
@@ -23,13 +94,125 @@ describe('parseLoadoutDraft', () => {
expect(parseLoadoutDraft(retired)).toBeNull();
});
it('rejects a version 4 draft that does not contain exactly nine chips', () => {
it('rejects the retired version 4 schema', () => {
const retired = copyDraft() as unknown as Record<string, unknown>;
retired.version = 4;
expect(parseLoadoutDraft(retired)).toBeNull();
});
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();
draft.chips.pop();
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([
['Standard', 0],
['Specialized', 1],
['Superior', 2],
['High-end', 2],
] as const)(
'accepts %s chips with exactly %i secondary rolls',
(quality, capacity) => {
const draft = copyDraft();
draft.chips[0].quality = quality;
draft.chips[0].primary = { statId: 'primary', value: 10 };
draft.chips[0].secondary = Array.from(
{ length: capacity },
(_, index) => ({ statId: `secondary-${index}`, value: index + 1 }),
);
expect(skillChipSecondaryCapacity[quality]).toBe(capacity);
expect(parseLoadoutDraft(draft)).toEqual(draft);
},
);
it.each([
['Standard', 1],
['Specialized', 0],
['Specialized', 2],
['Superior', 1],
['High-end', 3],
] as Array<[SkillChipQuality, number]>)(
'rejects %s chips with %i secondary rolls',
(quality, capacity) => {
const draft = copyDraft();
draft.chips[0].quality = quality;
draft.chips[0].secondary = Array.from(
{ length: capacity },
(_, index) => ({ statId: `secondary-${index}`, value: index + 1 }),
);
expect(parseLoadoutDraft(draft)).toBeNull();
},
);
it('rejects a draft missing the unified combat profile', () => {
const draft = copyDraft() as unknown as Record<string, unknown>;
delete draft.combatProfile;
+77 -9
View File
@@ -1,9 +1,24 @@
export type Roll = { statId: string; value: number };
export type TalentTier = 1 | 2;
export const skillChipQualities = [
'Standard',
'Specialized',
'Superior',
'High-end',
] as const;
export type SkillChipQuality = (typeof skillChipQualities)[number];
export const skillChipSecondaryCapacity: Record<SkillChipQuality, number> = {
Standard: 0,
Specialized: 1,
Superior: 2,
'High-end': 2,
};
export type GearSelection = {
itemId: string;
level: number;
brandId: string;
talentId: string;
talentTier: TalentTier;
health: number;
armor: number;
firepower: number;
@@ -21,14 +36,17 @@ export type WeaponSelection = {
toughness: number;
engineering: number;
talent1Id: string;
talent1Tier: TalentTier;
talent2Id: string;
talent2Tier: TalentTier;
exoticTalentTier: TalentTier;
rolls: [Roll, Roll];
};
export type ChipSelection = {
setId: string;
quality: 'Superior' | 'High-end';
quality: SkillChipQuality;
primary: Roll;
secondary: [Roll, Roll];
secondary: Roll[];
};
export type ProtocolSelection = {
itemId: string;
@@ -68,7 +86,7 @@ export type SpecializationSelection = {
nodeRanks: Record<string, number>;
};
export type LoadoutDraft = {
version: 4;
version: 6;
buildName: string;
calculationMode: CalculationMode;
gear: Record<string, GearSelection>;
@@ -105,6 +123,7 @@ export const blankGear = (): GearSelection => ({
level: 50,
brandId: '',
talentId: '',
talentTier: 1,
health: 0,
armor: 0,
firepower: 0,
@@ -122,14 +141,17 @@ export const blankWeapon = (): WeaponSelection => ({
toughness: 0,
engineering: 0,
talent1Id: '',
talent1Tier: 1,
talent2Id: '',
talent2Tier: 1,
exoticTalentTier: 1,
rolls: [blankRoll(), blankRoll()],
});
export const blankChip = (): ChipSelection => ({
setId: '',
quality: 'Superior',
quality: 'Standard',
primary: blankRoll(),
secondary: [blankRoll(), blankRoll()],
secondary: [],
});
export const defaultCombatProfile = (): CombatProfile => ({
encounterSeconds: 60,
@@ -148,7 +170,7 @@ export const defaultCombatProfile = (): CombatProfile => ({
extraHealthActive: false,
});
export const createDraft = (): LoadoutDraft => ({
version: 4,
version: 6,
buildName: 'Untitled loadout',
calculationMode: 'maximum',
gear: Object.fromEntries(gearSlots.map((slot) => [slot, blankGear()])),
@@ -174,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> =>
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
const hasExactKeys = (value: Record<string, unknown>, keys: string[]) =>
@@ -181,6 +236,8 @@ const hasExactKeys = (value: Record<string, unknown>, keys: string[]) =>
keys.every((key) => key in value);
const isFiniteNumber = (value: unknown): value is number =>
typeof value === 'number' && Number.isFinite(value);
const isTalentTier = (value: unknown): value is TalentTier =>
value === 1 || value === 2;
const isNumberRecord = (value: unknown) =>
isRecord(value) &&
Object.values(value).every(
@@ -200,6 +257,7 @@ const isGearSelection = (value: unknown): value is GearSelection =>
'level',
'brandId',
'talentId',
'talentTier',
'health',
'armor',
'firepower',
@@ -211,6 +269,7 @@ const isGearSelection = (value: unknown): value is GearSelection =>
['itemId', 'brandId', 'talentId'].every(
(key) => typeof value[key] === 'string',
) &&
isTalentTier(value.talentTier) &&
['level', 'health', 'armor', 'firepower', 'toughness', 'engineering'].every(
(key) => isFiniteNumber(value[key]),
) &&
@@ -228,12 +287,18 @@ const isWeaponSelection = (value: unknown): value is WeaponSelection =>
'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]),
) &&
@@ -242,9 +307,12 @@ const isChipSelection = (value: unknown): value is ChipSelection =>
isRecord(value) &&
hasExactKeys(value, ['setId', 'quality', 'primary', 'secondary']) &&
typeof value.setId === 'string' &&
(value.quality === 'Superior' || value.quality === 'High-end') &&
skillChipQualities.some((quality) => value.quality === quality) &&
isRoll(value.primary) &&
isRollPair(value.secondary);
Array.isArray(value.secondary) &&
value.secondary.length ===
skillChipSecondaryCapacity[value.quality as SkillChipQuality] &&
value.secondary.every(isRoll);
const isProtocolSelection = (value: unknown): value is ProtocolSelection =>
isRecord(value) &&
hasExactKeys(value, ['itemId', 'level', 'talentUpgrades']) &&
@@ -353,7 +421,7 @@ export function parseLoadoutDraft(value: unknown): LoadoutDraft | null {
'skillActivations',
'specialization',
]) ||
value.version !== 4 ||
value.version !== 6 ||
typeof value.buildName !== 'string' ||
!['maximum', 'custom'].includes(String(value.calculationMode))
)
@@ -0,0 +1,294 @@
import { describe, expect, it } from 'vitest';
import {
materializeMechanicAuthoredDescription,
mechanicAuthoredFingerprintPayload,
parseMechanicAuthoredSource,
type MechanicAuthoredSource,
} from './mechanic-authored-description.js';
const talentSource = (): MechanicAuthoredSource => ({
version: 1,
owner: { type: 'talent', id: 'talent-1', scope: 'standard' },
template: 'Gain {1}% for {duration} seconds. Gain {1}% again, up to {max_stacks}.',
variants: ['I', 'II'].map((key, index) => ({
key,
materialized: `Gain ${10 + index}% for 5 seconds. Gain ${10 + index}% again, up to 3.`,
slots: [
{ key: 'max_stacks', value: '3', order: 2 },
{ key: '1', value: `${10 + index}`, order: 0, unit: 'percent' },
{ key: 'duration', value: '5', order: 1, unit: 'seconds' },
],
})),
});
describe('mechanic authored descriptions', () => {
it('materializes repeated strict tokens for ordered Talent variants', () => {
expect(materializeMechanicAuthoredDescription(talentSource())).toEqual({
ok: true,
variants: talentSource().variants.map((variant) => ({
key: variant.key,
text: variant.materialized,
slots: variant.slots,
})),
});
});
it.each([
{
owner: { type: 'talent', id: 'exotic', scope: 'exotic' } as const,
keys: ['I'],
},
{
owner: {
type: 'talent',
id: 'exotic',
scope: 'exotic',
hasTierII: true,
} as const,
keys: ['I', 'II'],
},
{
owner: { type: 'os_protocol_talent', id: 'protocol' } as const,
keys: ['base'],
},
{
owner: { type: 'skill_chip_talent', id: 'chip' } as const,
keys: ['minimum', 'maximum'],
},
])('enforces the $owner.type variant contract', ({ owner, keys }) => {
const source: MechanicAuthoredSource = {
version: 1,
owner,
template: 'Gain {1}%.',
variants: keys.map((key) => ({
key,
materialized: 'Gain 10%.',
slots: [{ key: '1', value: '10', order: 0 }],
})),
};
expect(materializeMechanicAuthoredDescription(source).ok).toBe(true);
});
it('requires Specialization rank source identity', () => {
const result = materializeMechanicAuthoredDescription({
version: 1,
owner: { type: 'specialization_tree_rank', id: 'rank-1' },
template: 'Gain {1}%.',
variants: [
{
key: 'base',
materialized: 'Gain 10%.',
slots: [{ key: '1', value: '10', order: 0 }],
},
],
});
expect(result).toMatchObject({
ok: false,
errors: [{ code: 'invalid_specialization_source' }],
});
});
it('rejects malformed and slot-injected placeholders', () => {
const malformed = talentSource();
malformed.template = 'Gain {1.';
malformed.variants.forEach((variant) => {
variant.materialized = 'Gain {1.';
variant.slots = [];
});
expect(materializeMechanicAuthoredDescription(malformed)).toMatchObject({
ok: false,
errors: expect.arrayContaining([
expect.objectContaining({ code: 'unknown_token' }),
]),
});
const injected = talentSource();
injected.variants[0].slots.find((slot) => slot.key === '1')!.value =
'{raw}';
injected.variants[0].materialized =
'Gain {raw}% for 5 seconds. Gain {raw}% again, up to 3.';
expect(materializeMechanicAuthoredDescription(injected)).toMatchObject({
ok: false,
errors: expect.arrayContaining([
expect.objectContaining({ code: 'invalid_slot' }),
expect.objectContaining({ code: 'unresolved_token' }),
]),
});
});
it('rejects units embedded in authored slot values', () => {
const source = talentSource();
source.variants[0].slots.find((slot) => slot.key === '1')!.value = '10%';
expect(materializeMechanicAuthoredDescription(source)).toMatchObject({
ok: false,
errors: expect.arrayContaining([
expect.objectContaining({ code: 'invalid_slot' }),
]),
});
});
it('rejects unknown, unresolved, unused, and mismatched authored values', () => {
const source = talentSource();
source.template = 'Gain {1}% for {cooldown} seconds.';
source.variants[0].slots.push({ key: '2', value: '4', order: 4 });
const result = materializeMechanicAuthoredDescription(source);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(new Set(result.errors.map((error) => error.code))).toEqual(
new Set([
'unknown_token',
'unresolved_token',
'unused_slot',
'materialization_mismatch',
]),
);
});
it('rejects a missing required slot rather than retaining its placeholder', () => {
const source = talentSource();
source.variants[0].slots = source.variants[0].slots.filter(
(slot) => slot.key !== 'duration',
);
const result = materializeMechanicAuthoredDescription(source);
expect(result).toMatchObject({
ok: false,
errors: expect.arrayContaining([
expect.objectContaining({ code: 'unresolved_token' }),
]),
});
});
it('preserves exact authored whitespace and materialized prose', () => {
const source = talentSource();
source.template = ' Gain {1}%.\nNext line. ';
for (const variant of source.variants) {
const value = variant.slots.find((slot) => slot.key === '1')?.value;
variant.slots = variant.slots.filter((slot) => slot.key === '1');
variant.materialized = ` Gain ${value}%.\nNext line. `;
}
const result = materializeMechanicAuthoredDescription(source);
expect(result.ok && result.variants[0].text).toBe(
' Gain 10%.\nNext line. ',
);
});
it('parses unknown source values without throwing on malformed input', () => {
expect(parseMechanicAuthoredSource(null)).toMatchObject({
ok: false,
errors: [{ code: 'invalid_source' }],
});
expect(
parseMechanicAuthoredSource({
...talentSource(),
owner: {
type: new String('os_protocol_talent'),
id: 'protocol-1',
},
}),
).toMatchObject({ ok: false, errors: [{ code: 'invalid_source' }] });
expect(
parseMechanicAuthoredSource({
...talentSource(),
variants: [{ key: 'I', materialized: 10, slots: [] }],
}),
).toMatchObject({ ok: false, errors: [{ code: 'invalid_source' }] });
expect(parseMechanicAuthoredSource(talentSource())).toMatchObject({
ok: true,
});
});
it('rejects malformed typed values and whitespace-only authored content', () => {
expect(
materializeMechanicAuthoredDescription({
...talentSource(),
owner: {
type: 'talent',
id: 'talent-1',
scope: 'exotic',
hasTierII: 'false',
},
}),
).toMatchObject({ ok: false, errors: [{ code: 'invalid_source' }] });
const whitespace = talentSource();
whitespace.template = ' ';
whitespace.variants.forEach((variant) => {
variant.materialized = ' ';
variant.slots = [];
});
expect(materializeMechanicAuthoredDescription(whitespace)).toMatchObject({
ok: false,
errors: expect.arrayContaining([
expect.objectContaining({ code: 'invalid_template' }),
]),
});
});
it('strictly parses Talent Tier II and unknown source fields', () => {
expect(
parseMechanicAuthoredSource({
...talentSource(),
owner: {
type: 'talent',
id: 'talent-1',
scope: 'exotic',
hasTierII: 'false',
},
}),
).toMatchObject({ ok: false, errors: [{ code: 'invalid_source' }] });
expect(
parseMechanicAuthoredSource({ ...talentSource(), unexpected: true }),
).toMatchObject({ ok: false, errors: [{ code: 'invalid_source' }] });
});
it('normalizes omitted and false optional Talent Tier II flags identically', () => {
const omitted = talentSource();
const explicit = talentSource();
explicit.owner = {
type: 'talent',
id: 'talent-1',
scope: 'standard',
hasTierII: false,
};
expect(mechanicAuthoredFingerprintPayload(omitted)).toEqual(
mechanicAuthoredFingerprintPayload(explicit),
);
});
it('reports unsupported parser contracts specifically', () => {
expect(
parseMechanicAuthoredSource({ ...talentSource(), version: 2 }),
).toMatchObject({
ok: false,
errors: [{ code: 'unsupported_contract', path: 'version' }],
});
});
it('rejects duplicate slot ordering', () => {
const source = talentSource();
source.variants[0].slots[1].order = source.variants[0].slots[0].order;
const result = materializeMechanicAuthoredDescription(source);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.errors).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: 'invalid_slot' }),
]),
);
});
it('creates a versioned canonical fingerprint payload with ordered slots', () => {
expect(mechanicAuthoredFingerprintPayload(talentSource())).toMatchObject({
kind: 'mechanic_authored_source',
version: 1,
owner: { type: 'talent', id: 'talent-1' },
variants: [
{
key: 'I',
slots: [{ key: '1' }, { key: 'duration' }, { key: 'max_stacks' }],
},
{ key: 'II' },
],
});
});
});
@@ -0,0 +1,431 @@
export const MECHANIC_AUTHORED_CONTRACT_VERSION = 1 as const;
export type MechanicAuthoredOwner =
| {
type: 'talent';
id: string;
scope: 'standard' | 'exotic';
hasTierII?: boolean;
}
| { type: 'os_protocol_talent'; id: string }
| { type: 'skill_chip_talent'; id: string }
| { type: 'specialization_tree_rank'; id: string };
export type MechanicAuthoredSlot = {
key: string;
value: string;
order: number;
unit?: string;
attributeId?: string;
};
export type MechanicAuthoredVariant = {
key: string;
materialized: string;
slots: MechanicAuthoredSlot[];
};
export type SpecializationAuthoredInputs = {
rankDescription: string;
effectId: string;
effectTemplate: string;
};
export type MechanicAuthoredSource = {
version: typeof MECHANIC_AUTHORED_CONTRACT_VERSION;
owner: MechanicAuthoredOwner;
template: string;
variants: MechanicAuthoredVariant[];
specialization?: SpecializationAuthoredInputs;
};
export type MaterializedMechanicVariant = {
key: string;
text: string;
slots: MechanicAuthoredSlot[];
};
export type MechanicAuthoredDescriptionError = {
code:
| 'invalid_source'
| 'unsupported_contract'
| 'invalid_owner'
| 'invalid_template'
| 'invalid_variant'
| 'invalid_slot'
| 'unknown_token'
| 'unresolved_token'
| 'unused_slot'
| 'materialization_mismatch'
| 'invalid_specialization_source';
path: string;
message: string;
};
export type MechanicAuthoredDescriptionResult =
| { ok: true; variants: MaterializedMechanicVariant[] }
| { ok: false; errors: MechanicAuthoredDescriptionError[] };
export type ParsedMechanicAuthoredSourceResult =
| { ok: true; value: MechanicAuthoredSource }
| { ok: false; errors: MechanicAuthoredDescriptionError[] };
export type MechanicAuthoredFingerprintPayload = {
kind: 'mechanic_authored_source';
version: typeof MECHANIC_AUTHORED_CONTRACT_VERSION;
owner: MechanicAuthoredOwner;
template: string;
variants: Array<{
key: string;
materialized: string;
slots: MechanicAuthoredSlot[];
}>;
specialization?: SpecializationAuthoredInputs;
};
const TOKEN_PATTERN = /\{([^{}]+)\}/g;
const NUMERIC_SLOT_VALUE_PATTERN = /^[+-]?(?:\d+(?:\.\d+)?|\.\d+)$/;
const ALLOWED_NAMED_TOKENS = new Set(['duration', 'max_stacks']);
const objectValue = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
const hasOnlyKeys = (
value: Record<string, unknown>,
allowed: readonly string[],
) => Object.keys(value).every((key) => allowed.includes(key));
function requiredVariantKeys(owner: MechanicAuthoredOwner): readonly string[] {
switch (owner.type) {
case 'talent':
return owner.scope === 'standard' || owner.hasTierII
? ['I', 'II']
: ['I'];
case 'os_protocol_talent':
case 'specialization_tree_rank':
return ['base'];
case 'skill_chip_talent':
return ['minimum', 'maximum'];
}
}
function tokenKeys(template: string) {
return [...template.matchAll(TOKEN_PATTERN)].map((match) => match[1]);
}
function isAllowedToken(key: string) {
return ALLOWED_NAMED_TOKENS.has(key) || /^[1-9]\d*$/.test(key);
}
function sourceErrors(source: MechanicAuthoredSource) {
const errors: MechanicAuthoredDescriptionError[] = [];
if (source.version !== MECHANIC_AUTHORED_CONTRACT_VERSION) {
errors.push({
code: 'unsupported_contract',
path: 'version',
message: `Authored-description contract version ${String(source.version)} is not supported.`,
});
}
if (!source.owner.id.trim()) {
errors.push({
code: 'invalid_owner',
path: 'owner.id',
message: 'The owner ID is required.',
});
}
if (!source.template.trim()) {
errors.push({
code: 'invalid_template',
path: 'template',
message: 'The authored template is required.',
});
}
const tokens = tokenKeys(source.template);
if (/[{}]/.test(source.template.replace(TOKEN_PATTERN, ''))) {
errors.push({
code: 'unknown_token',
path: 'template',
message: 'The authored template contains malformed placeholder braces.',
});
}
for (const token of tokens) {
if (!isAllowedToken(token)) {
errors.push({
code: 'unknown_token',
path: 'template',
message: `The authored template contains unsupported token {${token}}.`,
});
}
}
if (source.owner.type === 'specialization_tree_rank') {
if (
!source.specialization?.rankDescription.trim() ||
!source.specialization.effectId.trim() ||
!source.specialization.effectTemplate.trim()
) {
errors.push({
code: 'invalid_specialization_source',
path: 'specialization',
message:
'Specialization rank description, effect identity, and effect template are required.',
});
}
} else if (source.specialization !== undefined) {
errors.push({
code: 'invalid_specialization_source',
path: 'specialization',
message:
'Specialization inputs are valid only for a Specialization tree-rank owner.',
});
}
const expected = requiredVariantKeys(source.owner);
const actual = source.variants.map((variant) => variant.key);
if (
actual.length !== expected.length ||
actual.some((key, index) => key !== expected[index])
) {
errors.push({
code: 'invalid_variant',
path: 'variants',
message: `Expected authored variants in this order: ${expected.join(', ')}.`,
});
}
return { errors, tokens };
}
function materializeKnownMechanicAuthoredDescription(
source: MechanicAuthoredSource,
): MechanicAuthoredDescriptionResult {
const { errors, tokens } = sourceErrors(source);
const usedTokens = new Set(tokens.filter(isAllowedToken));
const variants: MaterializedMechanicVariant[] = [];
source.variants.forEach((variant, variantIndex) => {
const path = `variants[${variantIndex}]`;
const slots = new Map<string, MechanicAuthoredSlot>();
const slotOrders = new Set<number>();
for (const [slotIndex, slot] of variant.slots.entries()) {
if (
!isAllowedToken(slot.key) ||
!Number.isInteger(slot.order) ||
slot.order < 0 ||
!NUMERIC_SLOT_VALUE_PATTERN.test(slot.value) ||
slots.has(slot.key) ||
slotOrders.has(slot.order)
) {
errors.push({
code: 'invalid_slot',
path: `${path}.slots[${slotIndex}]`,
message: `Authored slot ${slot.key || '(empty)'} must contain a plain numeric value with a unique key and order.`,
});
continue;
}
slots.set(slot.key, slot);
slotOrders.add(slot.order);
if (!usedTokens.has(slot.key)) {
errors.push({
code: 'unused_slot',
path: `${path}.slots[${slotIndex}]`,
message: `Authored slot ${slot.key} is not referenced by the template.`,
});
}
}
for (const token of usedTokens) {
if (!slots.has(token)) {
errors.push({
code: 'unresolved_token',
path: `${path}.slots`,
message: `Authored token {${token}} has no value for variant ${variant.key}.`,
});
}
}
const text = source.template.replace(TOKEN_PATTERN, (match, key: string) =>
isAllowedToken(key) ? (slots.get(key)?.value ?? match) : match,
);
if (/[{}]/.test(text)) {
errors.push({
code: 'unresolved_token',
path: `${path}.materialized`,
message: `Materialized variant ${variant.key} contains an unresolved placeholder.`,
});
}
if (variant.materialized !== text) {
errors.push({
code: 'materialization_mismatch',
path: `${path}.materialized`,
message: `Stored materialization for variant ${variant.key} does not match the authored source.`,
});
}
variants.push({ key: variant.key, text, slots: variant.slots });
});
return errors.length ? { ok: false, errors } : { ok: true, variants };
}
function parseMechanicAuthoredSourceShape(
value: unknown,
): ParsedMechanicAuthoredSourceResult {
const invalid = (
path: string,
message: string,
): ParsedMechanicAuthoredSourceResult => ({
ok: false,
errors: [{ code: 'invalid_source', path, message }],
});
if (!objectValue(value))
return invalid('', 'The authored source must be an object.');
if (
!hasOnlyKeys(value, [
'version',
'owner',
'template',
'variants',
'specialization',
])
)
return invalid('', 'The authored source contains an unknown field.');
if (value.version !== MECHANIC_AUTHORED_CONTRACT_VERSION)
return {
ok: false,
errors: [
{
code: 'unsupported_contract',
path: 'version',
message: 'The authored source has an unsupported contract version.',
},
],
};
if (!objectValue(value.owner) || typeof value.owner.id !== 'string')
return invalid('owner', 'The authored source owner is invalid.');
const ownerType = value.owner.type;
if (
typeof ownerType !== 'string' ||
![
'talent',
'os_protocol_talent',
'skill_chip_talent',
'specialization_tree_rank',
].includes(ownerType)
)
return invalid('owner.type', 'The authored source owner type is invalid.');
if (ownerType === 'talent') {
if (!hasOnlyKeys(value.owner, ['type', 'id', 'scope', 'hasTierII']))
return invalid('owner', 'The Talent owner contains an unknown field.');
if (value.owner.scope !== 'standard' && value.owner.scope !== 'exotic')
return invalid('owner.scope', 'The Talent scope is invalid.');
if (
value.owner.hasTierII !== undefined &&
typeof value.owner.hasTierII !== 'boolean'
)
return invalid(
'owner.hasTierII',
'Talent Tier II presence must be boolean.',
);
} else if (!hasOnlyKeys(value.owner, ['type', 'id'])) {
return invalid(
'owner',
'The authored source owner contains an unknown field.',
);
}
if (typeof value.template !== 'string' || !Array.isArray(value.variants))
return invalid(
'template',
'The authored template or variants are invalid.',
);
for (const [variantIndex, variant] of value.variants.entries()) {
if (
!objectValue(variant) ||
!hasOnlyKeys(variant, ['key', 'materialized', 'slots']) ||
typeof variant.key !== 'string' ||
typeof variant.materialized !== 'string' ||
!Array.isArray(variant.slots)
)
return invalid(
`variants[${variantIndex}]`,
'The authored variant is invalid.',
);
for (const [slotIndex, slot] of variant.slots.entries()) {
if (
!objectValue(slot) ||
!hasOnlyKeys(slot, ['key', 'value', 'order', 'unit', 'attributeId']) ||
typeof slot.key !== 'string' ||
typeof slot.value !== 'string' ||
typeof slot.order !== 'number' ||
(slot.unit !== undefined && typeof slot.unit !== 'string') ||
(slot.attributeId !== undefined && typeof slot.attributeId !== 'string')
)
return invalid(
`variants[${variantIndex}].slots[${slotIndex}]`,
'The authored slot is invalid.',
);
}
}
if (value.specialization !== undefined) {
if (
!objectValue(value.specialization) ||
!hasOnlyKeys(value.specialization, [
'rankDescription',
'effectId',
'effectTemplate',
]) ||
typeof value.specialization.rankDescription !== 'string' ||
typeof value.specialization.effectId !== 'string' ||
typeof value.specialization.effectTemplate !== 'string'
)
return invalid(
'specialization',
'The Specialization authored inputs are invalid.',
);
}
return { ok: true, value: value as unknown as MechanicAuthoredSource };
}
export function materializeMechanicAuthoredDescription(
value: unknown,
): MechanicAuthoredDescriptionResult {
const parsed = parseMechanicAuthoredSourceShape(value);
return parsed.ok
? materializeKnownMechanicAuthoredDescription(parsed.value)
: parsed;
}
export function parseMechanicAuthoredSource(
value: unknown,
): ParsedMechanicAuthoredSourceResult {
const parsed = parseMechanicAuthoredSourceShape(value);
if (!parsed.ok) return parsed;
const materialized = materializeKnownMechanicAuthoredDescription(
parsed.value,
);
return materialized.ok ? parsed : materialized;
}
export function mechanicAuthoredFingerprintPayload(
source: MechanicAuthoredSource,
): MechanicAuthoredFingerprintPayload | null {
const materialized = materializeMechanicAuthoredDescription(source);
if (!materialized.ok) return null;
return {
kind: 'mechanic_authored_source',
version: MECHANIC_AUTHORED_CONTRACT_VERSION,
owner:
source.owner.type === 'talent'
? {
type: source.owner.type,
id: source.owner.id,
scope: source.owner.scope,
...(source.owner.hasTierII ? { hasTierII: true } : {}),
}
: { type: source.owner.type, id: source.owner.id },
template: source.template,
variants: materialized.variants.map((variant) => ({
key: variant.key,
materialized: variant.text,
slots: [...variant.slots].sort((left, right) => left.order - right.order),
})),
...(source.specialization ? { specialization: source.specialization } : {}),
};
}
+338 -5
View File
@@ -2,9 +2,13 @@ import { describe, expect, it } from 'vitest';
import {
currentHealthPercentBandPredicate,
eventFieldComparisonPredicate,
eventFieldNumericExpression,
mechanicTargetingForMode,
mechanicTargetingMode,
parseConditionalMechanicDraft,
readCurrentHealthPercentBand,
readEventFieldComparison,
readEventFieldNumericExpression,
registeredEventFields,
readStatePoolThreshold,
statePoolThresholdPredicate,
@@ -200,6 +204,212 @@ 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', () => {
expect(registeredEventFields('enemy_killed')).toEqual(
expect.arrayContaining([
{
path: 'values.assignedAbilityActive',
label: 'Assigned ability active',
type: 'boolean',
},
]),
);
expect(registeredEventFields('assist')).toEqual(
expect.arrayContaining([
{
path: 'values.assignedAbilityActive',
label: 'Assigned ability active',
type: 'boolean',
},
]),
);
const value = draft();
value.clauses[0].event = 'enemy_killed';
value.clauses[0].recipeKey = undefined;
value.clauses[0].reducer = { kind: 'none' };
value.clauses[0].if = eventFieldComparisonPredicate({
path: 'values.assignedAbilityActive',
operator: 'eq',
value: true,
});
const result = parseConditionalMechanicDraft(value, {
variantKeys: ['I', 'II'],
allowedAttributeIds: new Set(['weapon-damage']),
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.programs.I.clauses[0]).toMatchObject({
when: 'enemy_killed',
if: value.clauses[0].if,
});
});
it('parses deterministic outcome and clause proc chances with ability mutations', () => {
const value = draft();
value.clauses[0].event = 'skill_hit';
value.clauses[0].recipeKey = undefined;
value.clauses[0].procChancePercent = 10;
value.clauses[0].requiresAssignedAbilityActive = true;
value.clauses[0].outcomes[0].probabilityPercent = 29;
value.clauses[0].outcomes[0].statePool = undefined;
value.clauses[0].outcomes[0].perStack = false;
value.pools = [
{
key: 'extension-stacks',
label: 'Extension stacks',
stateKind: 'stack',
scope: 'agent',
initial: 0,
min: 0,
max: 8,
},
{
key: 'extension-cooldown',
label: 'Extension cooldown',
stateKind: 'flag',
scope: 'agent',
initial: 0,
min: 0,
max: 1,
expiresAfterSeconds: 15,
},
];
value.clauses[0].mutations = [
{ kind: 'add', pool: 'extension-stacks', value: 1 },
{ kind: 'extend_duration', target: 'assigned_ability', seconds: 0.3 },
{
kind: 'start_cooldown_on_max',
stackPool: 'extension-stacks',
cooldownPool: 'extension-cooldown',
},
];
const result = parseConditionalMechanicDraft(value, {
variantKeys: ['I', 'II'],
allowedAttributeIds: new Set(['weapon-damage']),
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.programs.I.clauses[0]).toMatchObject({
procChancePercent: 10,
requiresAssignedAbilityActive: true,
outcomes: [{ probabilityPercent: 29 }],
});
expect(result.value.programs.I.clauses[0].mutations).toEqual(
expect.arrayContaining([
{ kind: 'extend_duration', target: 'assigned_ability', seconds: 0.3 },
{
kind: 'start_cooldown_on_max',
stackPool: 'extension-stacks',
cooldownPool: 'extension-cooldown',
},
]),
);
});
it('rejects an unregistered Extra Health event field deterministically', () => {
const value = draft();
value.clauses[0].event = 'extra_health_changed';
@@ -685,9 +895,9 @@ describe('conditional mechanic authoring payload', () => {
});
});
it('rejects invalid pre-event timing for current Health percentage references', () => {
const invalid = draft();
invalid.clauses[0].if = {
it('preserves pre-event timing for current Health percentage references', () => {
const value = draft();
value.clauses[0].if = {
kind: 'compare',
left: {
source: 'combat',
@@ -697,11 +907,13 @@ describe('conditional mechanic authoring payload', () => {
operator: 'lt',
right: 50,
};
const result = parseConditionalMechanicDraft(invalid, {
const result = parseConditionalMechanicDraft(value, {
variantKeys: ['I', 'II'],
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', () => {
@@ -735,4 +947,125 @@ describe('conditional mechanic authoring payload', () => {
);
}
});
it('exposes aiming and round-trips event-target explosion editor state', () => {
expect(registeredEventFields('weapon_hit')).toContainEqual({
path: 'values.aiming',
label: 'Aiming down sights',
type: 'boolean',
});
const targeting = mechanicTargetingForMode('event_target_explosion');
expect(targeting).toEqual({
kind: 'event_target_area',
center: 'event_target',
validTargets: 'enemies',
radiusMeters: 5,
});
expect(mechanicTargetingMode(targeting)).toBe('event_target_explosion');
expect(mechanicTargetingForMode('none', targeting)).toBeUndefined();
const valid = draft();
valid.clauses[0].if = eventFieldComparisonPredicate({
path: 'values.aiming',
operator: 'eq',
value: true,
});
valid.clauses[0].outcomes[0] = {
...valid.clauses[0].outcomes[0],
kind: 'direct_damage',
scope: 'target',
phase: 'after_event',
attributeId: undefined,
targeting,
};
const parsed = parseConditionalMechanicDraft(valid, {
variantKeys: ['I', 'II'],
allowedAttributeIds: new Set(['weapon-damage']),
});
expect(parsed.ok).toBe(true);
if (parsed.ok) {
expect(parsed.value.draft.clauses[0].outcomes[0].targeting).toEqual(
targeting,
);
}
});
it('rejects incomplete, mis-scoped, and non-event area damage', () => {
for (const configure of [
(candidate: ConditionalMechanicDraftV1) => {
candidate.clauses[0].outcomes[0].targeting = {
kind: 'event_target_area',
center: 'event_target',
validTargets: 'enemies',
radiusMeters: 0,
};
},
(candidate: ConditionalMechanicDraftV1) => {
candidate.clauses[0].outcomes[0].scope = 'self';
},
(candidate: ConditionalMechanicDraftV1) => {
candidate.clauses[0].outcomes[0].phase = 'while_active';
},
(candidate: ConditionalMechanicDraftV1) => {
candidate.clauses[0].event = 'enemy_killed';
},
]) {
const invalid = draft();
invalid.clauses[0].outcomes[0] = {
...invalid.clauses[0].outcomes[0],
kind: 'direct_damage',
scope: 'target',
phase: 'after_event',
attributeId: undefined,
targeting: {
kind: 'event_target_area',
center: 'event_target',
validTargets: 'enemies',
radiusMeters: 5,
},
};
configure(invalid);
expect(
parseConditionalMechanicDraft(invalid, {
variantKeys: ['I', 'II'],
allowedAttributeIds: new Set(['weapon-damage']),
}).ok,
).toBe(false);
}
});
it('requires explicit equality for optional aiming evidence', () => {
for (const predicate of [
{
kind: 'compare' as const,
left: { source: 'event' as const, path: 'values.aiming' },
operator: 'ne' as const,
right: true,
},
{
kind: 'not' as const,
predicate: {
kind: 'compare' as const,
left: { source: 'event' as const, path: 'values.aiming' },
operator: 'eq' as const,
right: true,
},
},
{
kind: 'compare' as const,
left: { source: 'event' as const, path: 'values.aiming' },
operator: 'ne' as const,
right: { source: 'combat' as const, path: 'currentHealth' },
},
]) {
const invalid = draft();
invalid.clauses[0].if = predicate;
expect(
parseConditionalMechanicDraft(invalid, {
variantKeys: ['I', 'II'],
allowedAttributeIds: new Set(['weapon-damage']),
}).ok,
).toBe(false);
}
});
});
+220 -28
View File
@@ -1,12 +1,14 @@
import {
MECHANIC_EVENT_KINDS,
MECHANIC_EVENT_REGISTRY,
mechanicOutcomeTargetingError,
validateMechanicProgram,
type ClauseMutation,
type MechanicClause,
type MechanicEventKind,
type MechanicOutcome,
type MechanicOutcomeScope,
type MechanicOutcomeTargeting,
type MechanicProgram,
type MechanicResource,
type NumericExpression,
@@ -37,9 +39,28 @@ export type ConditionalMechanicPoolDraft = PoolDefinition & {
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 = {
value: number;
expression?: NumericExpression;
expression?: ConditionalNumericExpression;
};
export type ConditionalMechanicOutcomeDraft = Omit<
@@ -198,6 +219,59 @@ export function registeredEventFields(event: MechanicEventKind) {
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 =
| 'none'
| 'allies_within_radius'
| 'enemies_within_radius'
| 'event_target_explosion';
export function mechanicTargetingMode(
targeting: MechanicOutcomeTargeting | undefined,
): MechanicTargetingMode {
if (!targeting) return 'none';
if (targeting.kind === 'event_target_area') return 'event_target_explosion';
return targeting.population === 'allies'
? 'allies_within_radius'
: 'enemies_within_radius';
}
export function mechanicTargetingForMode(
mode: MechanicTargetingMode,
current?: MechanicOutcomeTargeting,
): MechanicOutcomeTargeting | undefined {
if (mode === 'none') return undefined;
const radiusMeters =
current?.radiusMeters ?? (mode === 'event_target_explosion' ? 5 : 20);
if (mode === 'event_target_explosion') {
return {
kind: 'event_target_area',
center: 'event_target',
validTargets: 'enemies',
radiusMeters,
};
}
return {
kind: 'within_radius',
population: mode === 'allies_within_radius' ? 'allies' : 'enemies',
radiusMeters,
};
}
export type StatePoolThresholdComparison = {
pool: string;
operator: PredicateComparisonOperator;
@@ -258,6 +332,7 @@ export const CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES = {
'maxStacks',
'targeting',
'descriptionSlot',
'probabilityPercent',
],
phase: ['on_event', 'after_event', 'on_enter', 'on_exit', 'while_active'],
kind: [
@@ -291,6 +366,25 @@ export const CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES = {
continuous:
'Valid only for restoration. The value is the total restoration, delivered at a constant rate across durationSeconds. Each activation creates an independent stream; overlapping streams add and overheal is lost when it occurs.',
},
probabilitySemantics:
'probabilityPercent is an optional inclusive 0 through 100 percentage. In custom simulation, each eligible outcome rolls independently from the evaluation random stream. Maximum mode resolves every nonzero probability as successful.',
targeting: {
withinRadius: {
kind: 'within_radius',
population: ['allies', 'enemies'],
radiusMeters: 'positive finite number',
semantics:
'Legacy radius metadata. It preserves authored range but does not select or fan out runtime targets.',
},
eventTargetArea: {
kind: 'event_target_area',
center: 'event_target',
validTargets: 'enemies',
radiusMeters: 'positive finite number',
semantics:
'Valid only for target-scoped direct damage in on_event or after_event phases on a weapon_hit clause. The triggering event must supply an authoritative event-target area sample. Every valid enemy at or inside the radius receives one equal damage emission, ordered by entity ID.',
},
},
} as const;
const phases = new Set<OutcomePhase>(
CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES.phase,
@@ -366,11 +460,14 @@ function isReference(value: unknown): value is ValueReference {
Boolean(value.path.trim()) &&
(value.timing === undefined ||
value.timing === 'pre_event' ||
value.timing === 'current') &&
!(value.path === 'currentHealthPercent' && value.timing === 'pre_event')
value.timing === 'current')
);
case 'equipment':
return typeof value.path === 'string' && Boolean(value.path.trim());
case 'assigned_ability':
return (
value.path === 'active' || value.path === 'remainingDurationSeconds'
);
case 'attribute':
return (
typeof value.attributeId === 'string' &&
@@ -389,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(
value: unknown,
allowedAttributeIds: ReadonlySet<string> | undefined,
@@ -448,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) {
return scalar(value) || isReference(value);
}
@@ -527,6 +655,17 @@ function isMutation(value: unknown): value is ClauseMutation {
isExpression(value.value))
);
}
if (value.kind === 'extend_duration') {
return value.target === 'assigned_ability' && isExpression(value.seconds);
}
if (value.kind === 'start_cooldown_on_max') {
return (
typeof value.stackPool === 'string' &&
Boolean(value.stackPool.trim()) &&
typeof value.cooldownPool === 'string' &&
Boolean(value.cooldownPool.trim())
);
}
return (
['add', 'set', 'consume', 'derive'].includes(value.kind) &&
typeof value.pool === 'string' &&
@@ -724,30 +863,21 @@ function parseOutcome(
) {
errors.push(`${path} description slot must be a positive integer.`);
}
if (value.targeting !== undefined) {
if (
!object(value.targeting) ||
value.targeting.kind !== 'within_radius' ||
!['allies', 'enemies'].includes(String(value.targeting.population)) ||
!finite(value.targeting.radiusMeters) ||
value.targeting.radiusMeters <= 0
) {
errors.push(`${path} has invalid radius targeting.`);
} else if (
value.targeting.population === 'allies' &&
value.scope !== 'allies' &&
value.scope !== 'self_and_allies'
) {
errors.push(
`${path} ally radius targeting requires allies or self-and-allies scope.`,
);
} else if (
value.targeting.population === 'enemies' &&
value.scope !== 'target'
) {
errors.push(`${path} enemy radius targeting requires target scope.`);
}
if (
value.probabilityPercent !== undefined &&
(!finite(value.probabilityPercent) ||
value.probabilityPercent < 0 ||
value.probabilityPercent > 100)
) {
errors.push(`${path} probability must be from 0 through 100 percent.`);
}
const targetingError = mechanicOutcomeTargetingError({
kind,
scope: value.scope as MechanicOutcomeScope,
phase: value.phase as OutcomePhase,
targeting: value.targeting,
});
if (targetingError) errors.push(`${path} ${targetingError}`);
if (!object(value.variants)) {
errors.push(`${path} variants must be an object.`);
return null;
@@ -773,7 +903,7 @@ function parseOutcome(
}
if (
configured.expression !== undefined &&
!isExpression(configured.expression)
!isConditionalExpression(configured.expression)
) {
errors.push(
`${path} variant "${variant}" has an invalid value expression.`,
@@ -828,6 +958,22 @@ function parseClauses(
if (candidate.weight !== undefined && !isExpression(candidate.weight)) {
errors.push(`${path} has an invalid weight expression.`);
}
if (
candidate.procChancePercent !== undefined &&
(!finite(candidate.procChancePercent) ||
candidate.procChancePercent < 0 ||
candidate.procChancePercent > 100)
) {
errors.push(`${path} proc chance must be from 0 through 100 percent.`);
}
if (
candidate.requiresAssignedAbilityActive !== undefined &&
typeof candidate.requiresAssignedAbilityActive !== 'boolean'
) {
errors.push(
`${path} assigned-ability activity requirement must be a boolean.`,
);
}
if (
candidate.cooldownSeconds !== undefined &&
(!finite(candidate.cooldownSeconds) || candidate.cooldownSeconds <= 0)
@@ -873,6 +1019,37 @@ function parseClauses(
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(
draft: ConditionalMechanicDraftV1,
options: ParseConditionalMechanicDraftOptions,
@@ -902,6 +1079,15 @@ function programsForDraft(
? {}
: { cooldownSeconds: clause.cooldownSeconds }),
...(clause.weight === undefined ? {} : { weight: clause.weight }),
...(clause.procChancePercent === undefined
? {}
: { procChancePercent: clause.procChancePercent }),
...(clause.requiresAssignedAbilityActive === undefined
? {}
: {
requiresAssignedAbilityActive:
clause.requiresAssignedAbilityActive,
}),
mutations: clause.mutations,
outcomes: clause.outcomes.map(
({
@@ -912,7 +1098,13 @@ function programsForDraft(
}) => ({
...outcome,
id: outcomeKey,
value: variants[variant].expression ?? variants[variant].value,
value:
variants[variant].expression === undefined
? variants[variant].value
: resolveVariantExpression(
variants[variant].expression,
variants[variant].value,
),
}),
),
})),
@@ -0,0 +1,534 @@
import { describe, expect, it } from 'vitest';
import {
canonicalMechanicProgramJson,
classifyMechanicLifecycle,
classifyMechanicVariantLifecycle,
hashMechanicAuthoredSource,
hashMechanicProgram,
hashMechanicProgramVariants,
mechanicProgramSemanticPayload,
type MechanicLifecycleInput,
type MechanicModelRevision,
type MechanicProgramHashSource,
} from './mechanic-lifecycle.js';
import type { MechanicProgram } from './mechanic.js';
const program = (): MechanicProgram => ({
id: 'generated-program-id',
name: 'Sustained fire',
scenarioInputs: [
{
key: 'distance',
label: 'Distance',
kind: 'number',
readiness: 'requires_assumption',
description: 'Distance to target',
defaultValue: 10,
bestCaseValue: 5,
min: 0,
max: 100,
},
{
key: 'headshot',
label: 'Headshots',
kind: 'toggle',
defaultValue: false,
bestCaseValue: true,
},
],
pools: [
{
key: 'stacks',
label: 'Stacks',
scope: 'agent',
initial: 0,
min: 0,
max: 3,
bestCaseValue: 3,
},
{
key: 'cooldown',
label: 'Cooldown',
scope: 'agent',
initial: 0,
min: 0,
expiresAfterSeconds: 10,
},
],
clauses: [
{
id: 'generated-clause-id',
label: 'On weapon hit',
when: 'weapon_hit',
reducer: { kind: 'none' },
mutations: [
{
kind: 'add',
pool: 'stacks',
value: { ref: { source: 'scenario', key: 'headshot' } },
},
],
outcomes: [
{
id: 'generated-outcome-id',
label: 'Weapon damage',
phase: 'on_event',
kind: 'attribute_modifier',
scope: 'equipped_weapon',
value: 5,
basis: 'flat',
attributeId: 'weaponDamage',
perStack: true,
},
],
},
],
});
const hashSource = (): MechanicProgramHashSource => ({
...program(),
simulationReadiness: 'captured_only',
sourceVariants: ['I', 'II'],
});
const revision = (
overrides: Partial<MechanicModelRevision> = {},
): MechanicModelRevision => ({
program: program(),
sourceFingerprint: 'a'.repeat(64),
programHash: '0'.repeat(64),
mechanicContractVersion: 1,
simulationReadiness: 'ready',
...overrides,
});
const lifecycleInput = (
selectedRevision: MechanicModelRevision | null,
publiclyRenderable = true,
) => ({
selectedRevision,
currentSourceFingerprint: 'a'.repeat(64),
...(publiclyRenderable
? { resolveAttributeLabel: (attributeId: string) => attributeId }
: {}),
});
describe('Mechanic Program lifecycle and semantic hashing', () => {
it('builds explicit canonical JSON and excludes generated IDs and provenance', () => {
const source = hashSource() as MechanicProgramHashSource &
Record<string, unknown>;
source.persistenceRowId = 'row-1';
source.updatedAt = '2026-08-27T00:00:00Z';
source.editorState = { activeClause: 'generated-clause-id' };
source.recipeProvenance = 'recipe-a';
const json = canonicalMechanicProgramJson(source);
expect(json).toContain('"kind":"mechanic_program"');
expect(json).toContain('"version":1');
expect(json).not.toContain('generated-program-id');
expect(json).not.toContain('generated-clause-id');
expect(json).not.toContain('generated-outcome-id');
expect(json).not.toContain('persistenceRowId');
expect(json).not.toContain('recipeProvenance');
});
it('does not change when keyed Scenario Inputs or pools are reordered', async () => {
const original = hashSource();
const reordered: MechanicProgramHashSource = {
...original,
scenarioInputs: [...(original.scenarioInputs ?? [])].reverse(),
pools: [...(original.pools ?? [])].reverse(),
};
await expect(hashMechanicProgram(original)).resolves.toBe(
await hashMechanicProgram(reordered),
);
});
it('changes when clause, mutation, or outcome execution/render order changes', async () => {
const original = program();
const reorderedClause: MechanicProgram = {
...original,
clauses: [
...original.clauses,
{
id: 'second-clause',
label: 'Later clause',
when: 'tick',
reducer: { kind: 'none' },
mutations: [],
},
],
};
const reorderedMutation: MechanicProgram = {
...original,
clauses: [
{
...original.clauses[0],
mutations: [{ kind: 'none' }, ...original.clauses[0].mutations],
},
],
};
const reorderedOutcome: MechanicProgram = {
...original,
clauses: [
{
...original.clauses[0],
outcomes: [
...(original.clauses[0].outcomes ?? []),
{
...(original.clauses[0].outcomes?.[0] ?? {
id: 'second-outcome',
label: 'Second',
phase: 'on_event',
kind: 'marker',
scope: 'self',
value: 0,
basis: 'flat',
}),
id: 'second-outcome',
label: 'Second',
},
],
},
],
};
const originalHash = await hashMechanicProgram(original);
await expect(hashMechanicProgram(reorderedClause)).resolves.not.toBe(
originalHash,
);
await expect(hashMechanicProgram(reorderedMutation)).resolves.not.toBe(
originalHash,
);
await expect(hashMechanicProgram(reorderedOutcome)).resolves.not.toBe(
originalHash,
);
});
it('rejects invalid pool scopes and reducer kinds from canonical hashes', async () => {
const invalidScope = program();
(invalidScope.pools![0] as { scope: string }).scope = 'squad';
await expect(hashMechanicProgram(invalidScope)).rejects.toThrow(
'unsupported scope',
);
const invalidReducer = program();
(invalidReducer.clauses[0] as { reducer: { kind: string } }).reducer = {
kind: 'unknown',
};
await expect(hashMechanicProgram(invalidReducer)).rejects.toThrow(
'unsupported kind',
);
});
it('keeps generated identities out of the semantic hash', async () => {
const changedIds: MechanicProgram = {
...program(),
id: 'another-program-id',
clauses: [
{
...program().clauses[0],
id: 'another-clause-id',
outcomes: [
{
...(program().clauses[0].outcomes?.[0] as NonNullable<
MechanicProgram['clauses'][number]['outcomes']
>[number]),
id: 'another-outcome-id',
},
],
},
],
};
await expect(hashMechanicProgram(program())).resolves.toBe(
await hashMechanicProgram(changedIds),
);
});
it('classifies selected models with fail-closed lifecycle boundaries', async () => {
await expect(
classifyMechanicLifecycle(lifecycleInput(null)),
).resolves.toEqual({
status: 'absent',
reason: 'no_selected_revision',
});
await expect(
classifyMechanicLifecycle(
lifecycleInput(
revision({
program: { ...program(), clauses: [] },
simulationReadiness: 'captured_only',
}),
),
),
).resolves.toEqual({ status: 'absent', reason: 'empty_captured_program' });
await expect(
classifyMechanicLifecycle(
lifecycleInput(revision({ program: { ...program(), name: '' } })),
),
).resolves.toMatchObject({ status: 'absent', reason: 'invalid_program' });
await expect(
classifyMechanicLifecycle({
selectedRevision: revision({ sourceFingerprint: 'b'.repeat(64) }),
currentSourceFingerprint: 'c'.repeat(64),
resolveAttributeLabel: (attributeId) => attributeId,
}),
).resolves.toEqual({ status: 'stale', reason: 'fingerprint_mismatch' });
await expect(
classifyMechanicLifecycle(
lifecycleInput(revision({ mechanicContractVersion: 999 })),
),
).resolves.toEqual({ status: 'stale', reason: 'unsupported_contract' });
await expect(
classifyMechanicLifecycle(lifecycleInput(revision(), false)),
).resolves.toMatchObject({
status: 'absent',
reason: 'unrenderable_program',
});
await expect(
classifyMechanicLifecycle({
selectedRevision: revision(),
currentSourceFingerprint: 'a'.repeat(64),
} as MechanicLifecycleInput),
).resolves.toMatchObject({
status: 'absent',
reason: 'unrenderable_program',
});
});
it('rejects non-SHA fingerprints and publicly unrenderable program fields', async () => {
await expect(
classifyMechanicLifecycle({
selectedRevision: revision({ sourceFingerprint: 'same' }),
currentSourceFingerprint: 'same',
resolveAttributeLabel: (attributeId) => attributeId,
}),
).resolves.toMatchObject({ status: 'absent', reason: 'invalid_program' });
const invalidPhase = program();
(invalidPhase.clauses[0].outcomes![0] as { phase: string }).phase =
'sometimes';
const selectedRevision = revision({ program: invalidPhase });
selectedRevision.programHash = await hashMechanicProgram({
...invalidPhase,
simulationReadiness: selectedRevision.simulationReadiness,
});
await expect(
classifyMechanicLifecycle(lifecycleInput(selectedRevision)),
).resolves.toMatchObject({
status: 'absent',
reason: 'unrenderable_program',
});
});
it.each(['ready', 'requires_assumptions', 'captured_only'] as const)(
'classifies a matching non-empty %s model as current',
async (simulationReadiness) => {
const selectedRevision = revision({ simulationReadiness });
selectedRevision.programHash = await hashMechanicProgram({
...selectedRevision.program!,
simulationReadiness,
});
await expect(
classifyMechanicLifecycle(lifecycleInput(selectedRevision)),
).resolves.toEqual({
status: 'current',
fingerprint: 'a'.repeat(64),
programHash: selectedRevision.programHash,
simulationReadiness,
});
},
);
it('fails closed when the selected program hash is missing or does not match', async () => {
await expect(
classifyMechanicLifecycle(
lifecycleInput({
...revision(),
programHash: undefined,
} as unknown as MechanicModelRevision),
),
).resolves.toEqual({ status: 'absent', reason: 'invalid_program_hash' });
await expect(
classifyMechanicLifecycle(
lifecycleInput(revision({ programHash: '0'.repeat(64) })),
),
).resolves.toEqual({ status: 'absent', reason: 'invalid_program_hash' });
});
it('distinguishes an explicit zero weight from an omitted weight', async () => {
const implicit = program();
const zeroWeight: MechanicProgram = {
...implicit,
clauses: [{ ...implicit.clauses[0], weight: 0 }],
};
await expect(hashMechanicProgram(zeroWeight)).resolves.not.toBe(
await hashMechanicProgram(implicit),
);
});
it('normalizes omitted runtime defaults to their explicit equivalents', async () => {
const implicit = program();
const explicit: MechanicProgram = {
...implicit,
scenarioInputs: implicit.scenarioInputs?.map((input) => ({
...input,
readiness: input.readiness ?? 'ready',
})),
clauses: implicit.clauses.map((clause) => ({
...clause,
mutations: clause.mutations.map((mutation) =>
mutation.kind === 'transfer'
? {
...mutation,
value: mutation.value ?? 'all',
clearSource: mutation.clearSource ?? true,
}
: mutation,
),
outcomes: clause.outcomes?.map((outcome) => ({
...outcome,
delivery: outcome.delivery ?? 'instant',
combatTiming: outcome.combatTiming ?? 'current',
perStack: outcome.perStack ?? false,
})),
})),
};
await expect(hashMechanicProgram(implicit)).resolves.toBe(
await hashMechanicProgram(explicit),
);
});
it('hashes ordered program variants as one revision graph', async () => {
const tierOne = program();
const tierTwo = program();
tierTwo.clauses[0].outcomes![0].value = 10;
const forward = await hashMechanicProgramVariants({
simulationReadiness: 'ready',
variants: [
{ key: 'I', label: 'Tier I', program: tierOne },
{ key: 'II', label: 'Tier II', program: tierTwo },
],
});
const sameSemantics = await hashMechanicProgramVariants({
simulationReadiness: 'ready',
variants: [
{ key: 'I', label: 'Renamed display label', program: tierOne },
{ key: 'II', label: 'Tier II', program: tierTwo },
],
});
const reversed = await hashMechanicProgramVariants({
simulationReadiness: 'ready',
variants: [
{ key: 'II', label: 'Tier II', program: tierTwo },
{ key: 'I', label: 'Tier I', program: tierOne },
],
});
expect(forward).toBe(sameSemantics);
expect(reversed).not.toBe(forward);
});
it('classifies the complete selected variant graph and fails closed when one variant is invalid', async () => {
const variants = [
{ key: 'I', label: 'Tier I', program: program() },
{ key: 'II', label: 'Tier II', program: program() },
];
variants[1].program.clauses[0].outcomes![0].value = 10;
const programHash = await hashMechanicProgramVariants({
simulationReadiness: 'ready',
variants,
});
await expect(
classifyMechanicVariantLifecycle({
currentSourceFingerprint: 'a'.repeat(64),
resolveAttributeLabel: (attributeId) => attributeId,
selectedRevision: {
variants,
sourceFingerprint: 'a'.repeat(64),
programHash,
mechanicContractVersion: 1,
simulationReadiness: 'ready',
},
}),
).resolves.toMatchObject({ status: 'current', programHash });
variants[1].program.pools = [
{ key: 'bad', label: 'Bad', scope: 'party' as 'agent', initial: 0 },
];
await expect(
classifyMechanicVariantLifecycle({
currentSourceFingerprint: 'a'.repeat(64),
resolveAttributeLabel: (attributeId) => attributeId,
selectedRevision: {
variants,
sourceFingerprint: 'a'.repeat(64),
programHash,
mechanicContractVersion: 1,
simulationReadiness: 'ready',
},
}),
).resolves.toMatchObject({ status: 'absent', reason: 'invalid_program' });
});
it('hashes a valid authored source and rejects unresolved source values', async () => {
const source = {
version: 1 as const,
owner: { type: 'os_protocol_talent' as const, id: 'protocol-1' },
template: 'Gain {1}%.',
variants: [
{
key: 'base',
materialized: 'Gain 10%.',
slots: [{ key: '1', value: '10', order: 0 }],
},
],
};
await expect(hashMechanicAuthoredSource(source)).resolves.toMatchObject({
ok: true,
fingerprint: expect.stringMatching(/^[0-9a-f]{64}$/),
payload: { kind: 'mechanic_authored_source', version: 1 },
});
source.variants[0].slots = [];
await expect(hashMechanicAuthoredSource(source)).resolves.toMatchObject({
ok: false,
errors: expect.arrayContaining([
expect.objectContaining({ code: 'unresolved_token' }),
]),
});
});
it('returns a stable fixed SHA-256 vector as lowercase hexadecimal', async () => {
const hash = await hashMechanicProgram({
id: 'generated-id',
name: 'x',
clauses: [
{
id: 'generated-clause-id',
when: 'tick',
reducer: { kind: 'none' },
mutations: [],
},
],
});
expect(hash).toBe(
'4ba3a6b9ddc7831375779c865928a715bc3dbd2b8aac77b889fc7fcc672d8339',
);
expect(hash).toMatch(/^[0-9a-f]{64}$/);
});
it('exposes canonical payload fields in stable key order', () => {
const payload = mechanicProgramSemanticPayload(hashSource());
expect(Object.keys(payload)).toEqual([
'kind',
'version',
'name',
'scenarioInputs',
'pools',
'clauses',
'simulationReadiness',
'sourceVariants',
]);
});
});
+845
View File
@@ -0,0 +1,845 @@
import {
mechanicAuthoredFingerprintPayload,
materializeMechanicAuthoredDescription,
type MechanicAuthoredDescriptionError,
type MechanicAuthoredFingerprintPayload,
type MechanicAuthoredSource,
} from './mechanic-authored-description.js';
import {
renderPublicMechanicPrograms,
type MechanicAttributeLabelResolver,
type MechanicPublicProgramVariant,
} from './mechanic-public-renderer.js';
import {
validateMechanicProgram,
type ClauseMutation,
type EntitySelector,
type MechanicClause,
type MechanicOutcome,
type MechanicOutcomeTargeting,
type MechanicProgram,
type NumericExpression,
type PoolDefinition,
type Predicate,
type Reducer,
type ScenarioInputDefinition,
type Scalar,
type ValueReference,
} from './mechanic.js';
/** The version of the canonical Mechanic Program hash contract. */
export const MECHANIC_PROGRAM_HASH_VERSION = 1 as const;
export const MECHANIC_PROGRAM_HASH_KIND = 'mechanic_program' as const;
export const MECHANIC_PROGRAM_VARIANTS_HASH_KIND =
'mechanic_program_variants' as const;
export type MechanicLifecycle = 'absent' | 'stale' | 'current';
export type MechanicAuthoredFingerprintResult =
| {
ok: true;
fingerprint: string;
payload: MechanicAuthoredFingerprintPayload;
}
| { ok: false; errors: MechanicAuthoredDescriptionError[] };
export type MechanicSimulationReadiness =
'ready' | 'requires_assumptions' | 'captured_only';
export type CanonicalJsonValue =
| null
| boolean
| number
| string
| CanonicalJsonValue[]
| { [key: string]: CanonicalJsonValue };
export type CanonicalMechanicProgram = {
kind: typeof MECHANIC_PROGRAM_HASH_KIND;
version: typeof MECHANIC_PROGRAM_HASH_VERSION;
name: string;
simulationReadiness?: MechanicSimulationReadiness;
sourceVariants?: CanonicalJsonValue;
scenarioInputs: Array<{ [key: string]: CanonicalJsonValue }>;
pools: Array<{ [key: string]: CanonicalJsonValue }>;
clauses: Array<{ [key: string]: CanonicalJsonValue }>;
};
/**
* A program plus source metadata which belongs to the model contract rather
* than to the executable graph. Metadata is optional because the current
* runtime MechanicProgram type predates model revisions.
*/
export type MechanicProgramHashSource = MechanicProgram & {
simulationReadiness?: MechanicSimulationReadiness;
sourceVariants?: CanonicalJsonValue;
};
export type MechanicProgramHashInput = MechanicProgramHashSource;
export type MechanicProgramVariantsHashInput = {
simulationReadiness: MechanicSimulationReadiness;
variants: MechanicPublicProgramVariant[];
};
export type MechanicModelRevision = {
program: MechanicProgram | null;
sourceFingerprint: string;
programHash: string;
sourceVariants?: CanonicalJsonValue;
mechanicContractVersion: number;
simulationReadiness: MechanicSimulationReadiness;
};
export type MechanicLifecycleInput = {
selectedRevision: MechanicModelRevision | null;
currentSourceFingerprint: string;
resolveAttributeLabel?: MechanicAttributeLabelResolver;
supportedMechanicContractVersion?: number;
};
export type MechanicModelVariantRevision = Omit<
MechanicModelRevision,
'program' | 'sourceVariants'
> & {
variants: MechanicPublicProgramVariant[];
};
export type MechanicVariantLifecycleInput = Omit<
MechanicLifecycleInput,
'selectedRevision'
> & {
selectedRevision: MechanicModelVariantRevision | null;
};
export type MechanicLifecycleReason =
| 'no_selected_revision'
| 'empty_program'
| 'empty_captured_program'
| 'invalid_program'
| 'unrenderable_program'
| 'fingerprint_mismatch'
| 'unsupported_contract'
| 'invalid_program_hash'
| 'hash_unavailable';
export type MechanicLifecycleResult =
| {
status: 'absent';
reason:
| 'no_selected_revision'
| 'empty_program'
| 'empty_captured_program'
| 'invalid_program'
| 'unrenderable_program'
| 'invalid_program_hash'
| 'hash_unavailable';
errors?: string[];
}
| {
status: 'stale';
reason: 'fingerprint_mismatch' | 'unsupported_contract';
}
| {
status: 'current';
fingerprint: string;
programHash: string;
simulationReadiness: MechanicSimulationReadiness;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function canonicalizeValue(value: unknown): CanonicalJsonValue {
if (value === null) return null;
if (typeof value === 'string' || typeof value === 'boolean') return value;
if (typeof value === 'number') {
if (!Number.isFinite(value)) {
throw new TypeError(
'Mechanic Program hash values must be finite numbers.',
);
}
return value;
}
if (Array.isArray(value)) return value.map(canonicalizeValue);
if (isRecord(value)) {
const result: { [key: string]: CanonicalJsonValue } = {};
for (const key of Object.keys(value).sort()) {
const child = value[key];
if (child !== undefined) result[key] = canonicalizeValue(child);
}
return result;
}
throw new TypeError('Mechanic Program hash values must be JSON values.');
}
function canonicalObject(fields: Record<string, unknown>): {
[key: string]: CanonicalJsonValue;
} {
return canonicalizeValue(fields) as { [key: string]: CanonicalJsonValue };
}
function stableJson(value: CanonicalJsonValue): string {
return JSON.stringify(value);
}
function stableSemanticSort<T extends CanonicalJsonValue>(values: T[]): T[] {
return values
.map((value, index) => ({ value, index, key: stableJson(value) }))
.sort((left, right) => {
if (left.key < right.key) return -1;
if (left.key > right.key) return 1;
return left.index - right.index;
})
.map(({ value }) => value);
}
function canonicalScenarioInput(input: ScenarioInputDefinition): {
[key: string]: CanonicalJsonValue;
} {
return canonicalObject({
key: input.key,
label: input.label,
kind: input.kind,
readiness: input.readiness ?? 'ready',
description: input.description,
defaultValue: input.defaultValue,
bestCaseValue: input.bestCaseValue,
min: input.min,
max: input.max,
});
}
function canonicalPool(pool: PoolDefinition): {
[key: string]: CanonicalJsonValue;
} {
if (pool.scope !== 'agent' && pool.scope !== 'target') {
throw new TypeError('Mechanic pools have an unsupported scope.');
}
return canonicalObject({
key: pool.key,
label: pool.label,
scope: pool.scope,
initial: pool.initial,
min: pool.min,
max: pool.max,
bestCaseValue: pool.bestCaseValue,
expiresAfterSeconds: pool.expiresAfterSeconds,
});
}
function canonicalEntitySelector(selector: EntitySelector): {
[key: string]: CanonicalJsonValue;
} {
return canonicalObject({
kind: selector.kind,
path: 'path' in selector ? selector.path : undefined,
});
}
function canonicalReference(reference: ValueReference): {
[key: string]: CanonicalJsonValue;
} {
return canonicalObject({
source: reference.source,
path: 'path' in reference ? reference.path : undefined,
timing: 'timing' in reference ? reference.timing : undefined,
key: 'key' in reference ? reference.key : undefined,
attributeId: 'attributeId' in reference ? reference.attributeId : undefined,
pool: 'pool' in reference ? reference.pool : undefined,
entity:
'entity' in reference && reference.entity
? canonicalEntitySelector(reference.entity)
: undefined,
});
}
function canonicalExpression(
expression: NumericExpression,
): CanonicalJsonValue {
if (typeof expression === 'number') return canonicalizeValue(expression);
if (!isRecord(expression)) {
throw new TypeError('Numeric expressions must be typed JSON values.');
}
if ('ref' in expression) {
if (!isRecord(expression.ref))
throw new TypeError('Numeric expression references must be objects.');
return canonicalObject({
ref: canonicalReference(expression.ref as ValueReference),
});
}
const op = expression.op;
switch (op) {
case 'floor':
return canonicalObject({
op,
value: canonicalExpression(expression.value as NumericExpression),
});
case 'clamp':
return canonicalObject({
op,
value: canonicalExpression(expression.value as NumericExpression),
min: canonicalExpression(expression.min as NumericExpression),
max: canonicalExpression(expression.max as NumericExpression),
});
case 'add':
case 'subtract':
case 'multiply':
case 'divide':
case 'min':
case 'max':
return canonicalObject({
op,
left: canonicalExpression(expression.left as NumericExpression),
right: canonicalExpression(expression.right as NumericExpression),
});
default:
throw new TypeError('Numeric expressions have an unsupported operator.');
}
}
function canonicalOperand(value: ValueReference | Scalar): CanonicalJsonValue {
return isRecord(value) && 'source' in value
? canonicalReference(value as ValueReference)
: canonicalizeValue(value);
}
function canonicalPredicate(predicate: Predicate): CanonicalJsonValue {
if (!isRecord(predicate)) {
throw new TypeError('Predicates must be typed JSON values.');
}
switch (predicate.kind) {
case 'true':
return { kind: 'true' };
case 'exists':
return canonicalObject({
kind: 'exists',
value: canonicalReference(predicate.value as ValueReference),
});
case 'compare':
return canonicalObject({
kind: 'compare',
left: canonicalOperand(predicate.left as ValueReference | Scalar),
operator: predicate.operator,
right: canonicalOperand(predicate.right as ValueReference | Scalar),
});
case 'all':
case 'any':
return canonicalObject({
kind: predicate.kind,
// Logical member order is retained because deterministic rendering
// presents the members in this order.
predicates: (predicate.predicates as Predicate[]).map(
canonicalPredicate,
),
});
case 'not':
return canonicalObject({
kind: 'not',
predicate: canonicalPredicate(predicate.predicate as Predicate),
});
default:
throw new TypeError('Predicates have an unsupported kind.');
}
}
function canonicalReducer(reducer: Reducer): CanonicalJsonValue {
if (!isRecord(reducer)) {
throw new TypeError('Reducers must be typed JSON values.');
}
const groupBy =
'groupBy' in reducer && reducer.groupBy
? canonicalReference(reducer.groupBy as ValueReference)
: undefined;
switch (reducer.kind) {
case 'none':
return { kind: 'none' };
case 'count':
return canonicalObject({
kind: reducer.kind,
threshold: reducer.threshold,
reset: reducer.reset ?? 'zero',
groupBy,
});
case 'sliding_count':
return canonicalObject({
kind: reducer.kind,
threshold: reducer.threshold,
reset: reducer.reset ?? 'zero',
windowSeconds: reducer.windowSeconds,
groupBy,
});
case 'modulo':
return canonicalObject({ kind: 'modulo', every: reducer.every, groupBy });
case 'consecutive_count':
return canonicalObject({
kind: 'consecutive_count',
threshold: reducer.threshold,
groupBy,
});
case 'distinct_count':
return canonicalObject({
kind: 'distinct_count',
threshold: reducer.threshold,
windowSeconds: reducer.windowSeconds,
distinctBy: canonicalReference(reducer.distinctBy),
});
case 'duration':
return canonicalObject({
kind: 'duration',
seconds: reducer.seconds,
repeat: reducer.repeat ?? false,
groupBy,
});
case 'periodic':
return canonicalObject({
kind: 'periodic',
intervalSeconds: canonicalExpression(reducer.intervalSeconds),
immediate: reducer.immediate ?? false,
groupBy,
});
case 'derived_scalar':
return canonicalObject({
kind: 'derived_scalar',
value: canonicalExpression(reducer.value),
});
default:
throw new TypeError('Reducers have an unsupported kind.');
}
}
function canonicalMutation(mutation: ClauseMutation): CanonicalJsonValue {
if (!isRecord(mutation)) {
throw new TypeError('Mutations must be typed JSON values.');
}
const entity =
'entity' in mutation && mutation.entity
? canonicalEntitySelector(mutation.entity as EntitySelector)
: undefined;
switch (mutation.kind) {
case 'none':
return { kind: 'none' };
case 'add':
case 'set':
case 'consume':
case 'derive':
return canonicalObject({
kind: mutation.kind,
pool: mutation.pool,
entity,
value: canonicalExpression(mutation.value),
});
case 'clear':
return canonicalObject({ kind: 'clear', pool: mutation.pool, entity });
case 'transfer':
return canonicalObject({
kind: 'transfer',
pool: mutation.pool,
from: canonicalEntitySelector(mutation.from),
to: canonicalEntitySelector(mutation.to),
value:
mutation.value === undefined
? 'all'
: mutation.value === 'all'
? 'all'
: canonicalExpression(mutation.value),
clearSource: mutation.clearSource ?? true,
});
case 'extend_duration':
return canonicalObject({
kind: 'extend_duration',
target: mutation.target,
seconds: canonicalExpression(mutation.seconds),
});
case 'start_cooldown_on_max':
return canonicalObject({
kind: 'start_cooldown_on_max',
stackPool: mutation.stackPool,
cooldownPool: mutation.cooldownPool,
});
default:
throw new TypeError('Mutations have an unsupported kind.');
}
}
function canonicalTargeting(
targeting: MechanicOutcomeTargeting,
): CanonicalJsonValue {
return canonicalObject({
kind: targeting.kind,
population: 'population' in targeting ? targeting.population : undefined,
center: 'center' in targeting ? targeting.center : undefined,
validTargets:
'validTargets' in targeting ? targeting.validTargets : undefined,
radiusMeters: targeting.radiusMeters,
});
}
function canonicalOutcome(outcome: MechanicOutcome): {
[key: string]: CanonicalJsonValue;
} {
// Outcome IDs are generated identities. Every other field is either typed
// behavior or text consumed by deterministic rendering.
return canonicalObject({
label: outcome.label,
phase: outcome.phase,
kind: outcome.kind,
scope: outcome.scope,
value: canonicalExpression(outcome.value),
basis: outcome.basis,
resource: outcome.resource,
delivery: outcome.delivery ?? 'instant',
durationSeconds: outcome.durationSeconds,
combatTiming: outcome.combatTiming ?? 'current',
attributeId: outcome.attributeId,
statePool: outcome.statePool,
perStack: outcome.perStack ?? false,
minStacks: outcome.minStacks,
maxStacks: outcome.maxStacks,
targeting: outcome.targeting
? canonicalTargeting(outcome.targeting)
: undefined,
probabilityPercent: outcome.probabilityPercent,
});
}
function canonicalClause(clause: MechanicClause): {
[key: string]: CanonicalJsonValue;
} {
// Clause IDs and recipe keys are generated/persistence provenance. The
// remaining fields preserve execution and deterministic rendering order.
return canonicalObject({
label: clause.label,
when: clause.when,
if: clause.if ? canonicalPredicate(clause.if) : undefined,
reducer: canonicalReducer(clause.reducer),
cooldownSeconds: clause.cooldownSeconds,
weight:
clause.weight !== undefined
? canonicalExpression(clause.weight)
: undefined,
procChancePercent: clause.procChancePercent,
requiresAssignedAbilityActive: clause.requiresAssignedAbilityActive,
mutations: clause.mutations.map(canonicalMutation),
outcomes: (clause.outcomes ?? []).map(canonicalOutcome),
});
}
/**
* Builds the versioned semantic payload used by the Mechanic Program hash.
*
* Keyed Scenario Inputs and pools are sets and therefore sorted by their
* canonical semantics. Clauses, mutations, predicate members, and outcomes
* stay in source order because order affects execution or rendered text.
*/
export function mechanicProgramSemanticPayload(
input: MechanicProgramHashInput,
): CanonicalMechanicProgram {
const scenarioInputs = stableSemanticSort(
(input.scenarioInputs ?? []).map(canonicalScenarioInput),
);
const pools = stableSemanticSort((input.pools ?? []).map(canonicalPool));
const payload: CanonicalMechanicProgram = {
kind: MECHANIC_PROGRAM_HASH_KIND,
version: MECHANIC_PROGRAM_HASH_VERSION,
name: input.name,
scenarioInputs,
pools,
clauses: input.clauses.map(canonicalClause),
};
if (input.simulationReadiness !== undefined) {
payload.simulationReadiness = input.simulationReadiness;
}
if (input.sourceVariants !== undefined)
payload.sourceVariants = canonicalizeValue(input.sourceVariants);
return payload;
}
/** Returns canonical JSON bytes as a string, without a hidden domain separator. */
export function canonicalMechanicProgramJson(
input: MechanicProgramHashInput,
): string {
return stableJson(canonicalizeValue(mechanicProgramSemanticPayload(input)));
}
async function sha256Utf8(value: string): Promise<string> {
const crypto = globalThis.crypto;
if (!crypto?.subtle) {
throw new Error('Web Crypto SHA-256 is unavailable.');
}
const bytes = new TextEncoder().encode(value);
const digest = await crypto.subtle.digest('SHA-256', bytes);
return Array.from(new Uint8Array(digest), (byte) =>
byte.toString(16).padStart(2, '0'),
).join('');
}
/** Computes the source fingerprint only after the authored contract validates. */
export async function hashMechanicAuthoredSource(
source: MechanicAuthoredSource,
): Promise<MechanicAuthoredFingerprintResult> {
const validation = materializeMechanicAuthoredDescription(source);
if (!validation.ok) return validation;
const payload = mechanicAuthoredFingerprintPayload(source);
if (!payload) {
return {
ok: false,
errors: [
{
code: 'invalid_source',
path: '',
message: 'The validated authored source could not be serialized.',
},
],
};
}
return {
ok: true,
fingerprint: await sha256Utf8(stableJson(canonicalizeValue(payload))),
payload,
};
}
/** Computes lowercase SHA-256 over UTF-8 canonical Mechanic Program JSON. */
export async function hashMechanicProgram(
input: MechanicProgramHashInput,
): Promise<string> {
return sha256Utf8(canonicalMechanicProgramJson(input));
}
/**
* Hashes one owner revision across its explicit ordered program variants.
* Variant labels are presentation metadata; keys, order, and program semantics
* are revision data.
*/
export async function hashMechanicProgramVariants(
input: MechanicProgramVariantsHashInput,
): Promise<string> {
return sha256Utf8(
stableJson(
canonicalizeValue({
kind: MECHANIC_PROGRAM_VARIANTS_HASH_KIND,
version: MECHANIC_PROGRAM_HASH_VERSION,
simulationReadiness: input.simulationReadiness,
variants: input.variants.map((variant) => ({
key: variant.key,
program: mechanicProgramSemanticPayload(variant.program),
})),
}),
),
);
}
const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/;
function lifecycleAbsent(
reason: Extract<MechanicLifecycleResult, { status: 'absent' }>['reason'],
errors?: string[],
): MechanicLifecycleResult {
return errors?.length
? { status: 'absent', reason, errors }
: { status: 'absent', reason };
}
/**
* Classifies one selected model without ever treating readiness as currency.
* Validation and renderability are checked before currency, so malformed
* records fail closed as absent rather than leaking a stale or current model.
*/
export async function classifyMechanicLifecycle(
input: MechanicLifecycleInput,
): Promise<MechanicLifecycleResult> {
const revision = input.selectedRevision;
if (!revision || !revision.program) {
return lifecycleAbsent('no_selected_revision');
}
const program = revision.program;
let validation;
try {
validation = validateMechanicProgram(program);
} catch {
return lifecycleAbsent('invalid_program', [
'The selected mechanic could not be validated safely.',
]);
}
if (!validation.ok)
return lifecycleAbsent('invalid_program', validation.errors);
if (program.clauses.length === 0) {
return lifecycleAbsent(
revision.simulationReadiness === 'captured_only'
? 'empty_captured_program'
: 'empty_program',
);
}
const rendering = renderPublicMechanicPrograms(
[{ key: 'base', label: 'Base', program }],
{ resolveAttributeLabel: input.resolveAttributeLabel },
);
if (!rendering.ok) {
return lifecycleAbsent(
'unrenderable_program',
rendering.errors.map((error) => error.message),
);
}
if (
!['ready', 'requires_assumptions', 'captured_only'].includes(
revision.simulationReadiness,
)
) {
return { status: 'stale', reason: 'unsupported_contract' };
}
const supportedContractVersion =
input.supportedMechanicContractVersion ?? MECHANIC_PROGRAM_HASH_VERSION;
if (
revision.mechanicContractVersion !== supportedContractVersion ||
!Number.isInteger(revision.mechanicContractVersion) ||
revision.mechanicContractVersion <= 0
) {
return { status: 'stale', reason: 'unsupported_contract' };
}
if (
!SHA256_HEX_PATTERN.test(revision.sourceFingerprint) ||
!SHA256_HEX_PATTERN.test(input.currentSourceFingerprint)
) {
return lifecycleAbsent('invalid_program', [
'The selected revision and authored source require lowercase SHA-256 fingerprints.',
]);
}
if (revision.sourceFingerprint !== input.currentSourceFingerprint) {
return { status: 'stale', reason: 'fingerprint_mismatch' };
}
if (!SHA256_HEX_PATTERN.test(revision.programHash))
return lifecycleAbsent('invalid_program_hash');
let programHash: string;
try {
programHash = await hashMechanicProgram({
...program,
simulationReadiness: revision.simulationReadiness,
...(revision.sourceVariants !== undefined
? { sourceVariants: revision.sourceVariants }
: {}),
});
} catch {
return lifecycleAbsent('hash_unavailable');
}
if (programHash !== revision.programHash) {
return lifecycleAbsent('invalid_program_hash');
}
return {
status: 'current',
fingerprint: input.currentSourceFingerprint,
programHash,
simulationReadiness: revision.simulationReadiness,
};
}
/**
* Classifies a selected owner revision as one unit across every required
* program variant. One malformed or unrenderable variant fails the complete
* revision closed.
*/
export async function classifyMechanicVariantLifecycle(
input: MechanicVariantLifecycleInput,
): Promise<MechanicLifecycleResult> {
const revision = input.selectedRevision;
if (!revision || revision.variants.length === 0) {
return lifecycleAbsent('no_selected_revision');
}
for (const variant of revision.variants) {
let validation;
try {
validation = validateMechanicProgram(variant.program);
} catch {
return lifecycleAbsent('invalid_program', [
`Mechanic variant ${variant.key} could not be validated safely.`,
]);
}
if (!validation.ok) {
return lifecycleAbsent(
'invalid_program',
validation.errors.map((error) => `${variant.key}: ${error}`),
);
}
}
if (revision.variants.every((variant) => variant.program.clauses.length === 0)) {
return lifecycleAbsent(
revision.simulationReadiness === 'captured_only'
? 'empty_captured_program'
: 'empty_program',
);
}
if (revision.variants.some((variant) => variant.program.clauses.length === 0)) {
return lifecycleAbsent('invalid_program', [
'Every mechanic variant must contain the selected revision graph.',
]);
}
const rendering = renderPublicMechanicPrograms(revision.variants, {
resolveAttributeLabel: input.resolveAttributeLabel,
});
if (!rendering.ok) {
return lifecycleAbsent(
'unrenderable_program',
rendering.errors.map((error) => error.message),
);
}
if (
!['ready', 'requires_assumptions', 'captured_only'].includes(
revision.simulationReadiness,
)
) {
return { status: 'stale', reason: 'unsupported_contract' };
}
const supportedContractVersion =
input.supportedMechanicContractVersion ?? MECHANIC_PROGRAM_HASH_VERSION;
if (
revision.mechanicContractVersion !== supportedContractVersion ||
!Number.isInteger(revision.mechanicContractVersion) ||
revision.mechanicContractVersion <= 0
) {
return { status: 'stale', reason: 'unsupported_contract' };
}
if (
!SHA256_HEX_PATTERN.test(revision.sourceFingerprint) ||
!SHA256_HEX_PATTERN.test(input.currentSourceFingerprint)
) {
return lifecycleAbsent('invalid_program', [
'The selected revision and authored source require lowercase SHA-256 fingerprints.',
]);
}
if (revision.sourceFingerprint !== input.currentSourceFingerprint) {
return { status: 'stale', reason: 'fingerprint_mismatch' };
}
if (!SHA256_HEX_PATTERN.test(revision.programHash)) {
return lifecycleAbsent('invalid_program_hash');
}
let programHash: string;
try {
programHash = await hashMechanicProgramVariants({
simulationReadiness: revision.simulationReadiness,
variants: revision.variants,
});
} catch {
return lifecycleAbsent('hash_unavailable');
}
if (programHash !== revision.programHash) {
return lifecycleAbsent('invalid_program_hash');
}
return {
status: 'current',
fingerprint: input.currentSourceFingerprint,
programHash,
simulationReadiness: revision.simulationReadiness,
};
}
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest';
import { evaluateMechanicProgram } from './mechanic.js';
import {
adaptConditionalEffectProgram,
type ConditionalEffectClauseRow,
@@ -200,6 +201,143 @@ describe('conditional-effect persistence adapter', () => {
});
});
it('preserves event-target area damage semantics on an outcome', () => {
const result = adaptConditionalEffectProgram(
{
rule: rule(),
statePools: [],
clauses: [
clause({
trigger_id: null,
event_kind: 'weapon_hit',
state_pool_id: null,
mutations_json: '[]',
}),
],
outcomes: [
outcome({
application_kind: 'direct_damage',
effect_scope: 'target',
stat_attribute_id: null,
application_phase: 'after_event',
state_pool_id: null,
targeting_json: JSON.stringify({
kind: 'event_target_area',
center: 'event_target',
validTargets: 'enemies',
radiusMeters: 5,
}),
}),
],
triggers: [],
},
{ name: 'Critical Explosion', variantKey: 'I' },
);
expect(result.errors).toEqual([]);
expect(result.program?.clauses[0].outcomes?.[0]).toMatchObject({
kind: 'direct_damage',
scope: 'target',
phase: 'after_event',
targeting: {
kind: 'event_target_area',
center: 'event_target',
validTargets: 'enemies',
radiusMeters: 5,
},
});
});
it('adapts assigned-ability activity predicates for kill and assist events', () => {
for (const eventKind of ['enemy_killed', 'assist'] as const) {
const result = adaptConditionalEffectProgram(
{
rule: rule(),
statePools: [],
clauses: [
clause({
trigger_id: null,
event_kind: eventKind,
parameters_json: '{}',
state_pool_id: null,
mutations_json: '[]',
conditions_json: JSON.stringify({
kind: 'compare',
left: {
source: 'event',
path: 'values.assignedAbilityActive',
},
operator: 'eq',
right: true,
}),
}),
],
outcomes: [outcome({ state_pool_id: null })],
triggers: [],
},
{ variantKey: 'I' },
);
expect(result).toMatchObject({ status: 'READY', errors: [] });
expect(result.program?.clauses[0]).toMatchObject({
when: eventKind,
if: {
kind: 'compare',
left: {
source: 'event',
path: 'values.assignedAbilityActive',
},
operator: 'eq',
right: true,
},
});
}
});
it('preserves outcome probability metadata', () => {
const result = adaptConditionalEffectProgram(
{
rule: rule(),
statePools: [],
clauses: [
clause({
trigger_id: null,
event_kind: 'shot_fired',
recipe_parameters_json: JSON.stringify({
procChancePercent: 10,
requiresAssignedAbilityActive: true,
}),
parameters_json: '{}',
state_pool_id: null,
mutations_json: '[]',
}),
],
outcomes: [
outcome({
state_pool_id: null,
application_kind: 'restore_ammo',
effect_scope: 'self',
stat_attribute_id: null,
resource_kind: 'ammo',
probability_percent: 29,
}),
],
triggers: [],
},
{ variantKey: 'I' },
);
expect(result).toMatchObject({ status: 'READY', errors: [] });
expect(result.program?.clauses[0].outcomes?.[0]).toMatchObject({
probabilityPercent: 29,
resource: 'ammo',
});
expect(result.program?.clauses[0]).toMatchObject({
procChancePercent: 10,
requiresAssignedAbilityActive: true,
});
});
it('loads continuous restoration delivery metadata', () => {
const result = adaptConditionalEffectProgram(
{
@@ -713,7 +851,7 @@ describe('conditional-effect persistence adapter', () => {
left: {
source: 'combat',
path: 'currentHealthPercent',
timing: 'current',
timing: 'pre_event',
},
operator: 'lt',
right: 25,
@@ -775,4 +913,77 @@ describe('conditional-effect persistence adapter', () => {
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,
});
});
});
+109 -9
View File
@@ -13,6 +13,7 @@ import {
type Predicate,
type Reducer,
type ScenarioInputDefinition,
type ValueReference,
} from './mechanic.js';
type DatabaseValue = string | number | bigint | boolean | null | undefined;
@@ -54,6 +55,7 @@ export type ConditionalEffectClauseRow = {
event_kind?: DatabaseValue;
recipe_key?: DatabaseValue;
parameters_json: DatabaseValue;
recipe_parameters_json?: DatabaseValue;
conditions_json: DatabaseValue;
reducer_json: DatabaseValue;
weight_expression_json?: DatabaseValue;
@@ -85,6 +87,7 @@ export type ConditionalEffectOutcomeRow = {
delivery_mode?: DatabaseValue;
delivery_duration_seconds?: DatabaseValue;
targeting_json?: DatabaseValue;
probability_percent?: DatabaseValue;
value_scaling: DatabaseValue;
unit: DatabaseValue;
damage_basis: DatabaseValue;
@@ -205,14 +208,16 @@ function isValueReference(value: unknown) {
if (value.source === 'event' || value.source === 'equipment') {
return typeof value.path === 'string' && Boolean(value.path.trim());
}
if (value.source === 'assigned_ability') {
return value.path === 'active' || value.path === 'remainingDurationSeconds';
}
if (value.source === 'combat') {
return (
typeof value.path === 'string' &&
Boolean(value.path.trim()) &&
(value.timing === undefined ||
value.timing === 'pre_event' ||
value.timing === 'current') &&
!(value.path === 'currentHealthPercent' && value.timing === 'pre_event')
value.timing === 'current')
);
}
if (value.source === 'attribute') {
@@ -253,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 {
if (!object(value) || typeof value.kind !== 'string') return false;
if (value.kind === 'true') return true;
@@ -313,6 +367,19 @@ function isMutation(value: unknown): value is ClauseMutation {
isNumericExpression(value.value))
);
}
if (value.kind === 'extend_duration') {
return (
value.target === 'assigned_ability' && isNumericExpression(value.seconds)
);
}
if (value.kind === 'start_cooldown_on_max') {
return (
typeof value.stackPool === 'string' &&
Boolean(value.stackPool.trim()) &&
typeof value.cooldownPool === 'string' &&
Boolean(value.cooldownPool.trim())
);
}
return (
['add', 'set', 'consume', 'derive'].includes(value.kind) &&
typeof value.pool === 'string' &&
@@ -656,11 +723,8 @@ function parseOutcome(
) {
errors.push(`${path} has an unsupported application phase.`);
}
let value: NumericExpression = requiredNumber(
row.value,
`${path} value`,
errors,
);
const variantValue = requiredNumber(row.value, `${path} value`, errors);
let value: NumericExpression = variantValue;
if (
row.value_expression_json !== null &&
row.value_expression_json !== undefined &&
@@ -672,7 +736,8 @@ function parseOutcome(
errors,
);
if (parsed.ok) {
if (isNumericExpression(parsed.value)) value = parsed.value;
const resolved = resolveVariantExpression(parsed.value, variantValue);
if (resolved !== null) value = resolved;
else
errors.push(
`${path} value expression is not a valid numeric expression.`,
@@ -731,6 +796,17 @@ function parseOutcome(
if (delivery === 'instant' && durationSeconds !== null) {
errors.push(`${path} instant delivery cannot set a duration.`);
}
const probabilityPercent = number(row.probability_percent);
if (
row.probability_percent !== undefined &&
row.probability_percent !== null &&
row.probability_percent !== '' &&
(probabilityPercent === null ||
probabilityPercent < 0 ||
probabilityPercent > 100)
) {
errors.push(`${path} probability must be from 0 through 100 percent.`);
}
let targeting: MechanicOutcome['targeting'] | undefined;
if (
row.targeting_json !== null &&
@@ -766,6 +842,7 @@ function parseOutcome(
? { delivery, durationSeconds }
: {}),
...(targeting ? { targeting } : {}),
...(probabilityPercent === null ? {} : { probabilityPercent }),
...(kind === 'attribute_modifier' && nullableText(row.stat_attribute_id)
? { attributeId: text(row.stat_attribute_id) }
: {}),
@@ -903,7 +980,7 @@ export function adaptConditionalEffectProgram(
continue;
}
const parametersJson = parseJson(
row.parameters_json,
row.recipe_parameters_json ?? row.parameters_json,
`${path} parameters`,
errors,
);
@@ -976,6 +1053,25 @@ export function adaptConditionalEffectProgram(
}
}
const recipeKey = nullableText(row.recipe_key);
const procChancePercent = parametersJson.value.procChancePercent;
if (
procChancePercent !== undefined &&
(!finite(procChancePercent) ||
procChancePercent < 0 ||
procChancePercent > 100)
) {
errors.push(`${path} proc chance must be from 0 through 100 percent.`);
}
const requiresAssignedAbilityActive =
parametersJson.value.requiresAssignedAbilityActive;
if (
requiresAssignedAbilityActive !== undefined &&
typeof requiresAssignedAbilityActive !== 'boolean'
) {
errors.push(
`${path} assigned-ability activity requirement must be a boolean.`,
);
}
clauses.push({
id,
label: text(row.label) || text(row.clause_key) || undefined,
@@ -989,6 +1085,10 @@ export function adaptConditionalEffectProgram(
? {}
: { cooldownSeconds: number(row.cooldown_seconds) as number }),
...(weight === undefined ? {} : { weight }),
...(typeof procChancePercent === 'number' ? { procChancePercent } : {}),
...(typeof requiresAssignedAbilityActive === 'boolean'
? { requiresAssignedAbilityActive }
: {}),
mutations: mutationsJson.value,
...(clauseOutcomes.length ? { outcomes: clauseOutcomes } : {}),
});
@@ -0,0 +1,714 @@
import { describe, expect, it } from 'vitest';
import {
renderPublicMechanicPrograms,
type MechanicPublicProgramVariant,
} from './mechanic-public-renderer.js';
import { MECHANIC_EVENT_KINDS } from './mechanic.js';
import type { MechanicProgram } from './mechanic.js';
const baseProgram = (
overrides: Partial<MechanicProgram> = {},
): MechanicProgram => ({
id: 'public-renderer-test',
name: 'Public renderer test',
clauses: [
{
id: 'hit',
label: 'Hit target',
when: 'weapon_hit',
if: {
kind: 'compare',
left: { source: 'event', path: 'values.headshot' },
operator: 'eq',
right: true,
},
reducer: { kind: 'none' },
mutations: [{ kind: 'none' }],
outcomes: [
{
id: 'damage',
label: 'Headshot damage',
phase: 'on_event',
kind: 'direct_damage',
scope: 'target',
value: 12.5,
basis: 'flat',
},
],
},
],
...overrides,
});
const variants = (
...programs: Array<[string, MechanicProgram]>
): MechanicPublicProgramVariant[] =>
programs.map(([key, program]) => ({ key, program }));
const render = (
program: MechanicProgram,
options?: {
resolveAttributeLabel?: (attributeId: string) => string | null | undefined;
},
) => renderPublicMechanicPrograms([{ key: 'base', program }], options);
describe('public mechanic renderer', () => {
it('renders deterministic, readable prose for ordered variants', () => {
const input = variants(
['I', baseProgram()],
['II', baseProgram({ id: 'public-renderer-test-ii' })],
);
expect(renderPublicMechanicPrograms(input)).toEqual({
ok: true,
variants: [
{
key: 'I',
label: 'Tier I',
text: 'Tier I\n\nHit target: When Weapon hit occurs, provided that the event’s Headshot is equal to true. Headshot damage: when this event occurs, deal 12.5 damage to the target immediately.',
},
{
key: 'II',
label: 'Tier II',
text: 'Tier II\n\nHit target: When Weapon hit occurs, provided that the event’s Headshot is equal to true. Headshot damage: when this event occurs, deal 12.5 damage to the target immediately.',
},
],
});
});
it('preserves the supplied order of variants', () => {
const result = renderPublicMechanicPrograms(
variants(
['II', baseProgram({ id: 'tier-ii' })],
['I', baseProgram({ id: 'tier-i' })],
),
);
expect(result.ok && result.variants.map((variant) => variant.key)).toEqual([
'II',
'I',
]);
});
it('renders every event registry label without exposing the script format', () => {
const program = baseProgram({
clauses: MECHANIC_EVENT_KINDS.map((when) => ({
id: when,
when,
label: when,
reducer: { kind: 'none' as const },
mutations: [{ kind: 'none' as const }],
})),
});
const result = render(program);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.variants[0].text).not.toContain('WHEN ');
expect(result.variants[0].text).not.toContain('EVENT.');
expect(result.variants[0].text).not.toContain('{');
for (const clause of program.clauses) {
expect(result.variants[0].text).toContain(
(
{
shot_fired: 'Shot fired',
weapon_hit: 'Weapon hit',
skill_deployed: 'Skill deployed',
signature_ability_deployed: 'Signature Ability deployed',
skill_hit: 'Skill hit',
skill_multishot_triggered: 'Skill Multi-Shot triggered',
agent_hit: 'Agent hit',
health_damage_taken: 'Health damage taken',
damage_dealt: 'Damage dealt',
enemy_killed: 'Enemy killed',
assist: 'Assist',
ally_downed: 'Ally downed',
agent_downed: 'Agent downed',
ally_rescued: 'Ally rescued',
grenade_thrown: 'Grenade thrown',
medical_kit_used: 'Medical Kit used',
healing_applied: 'Healing applied',
extra_health_granted: 'Extra Health granted',
extra_health_changed: 'Extra Health changed',
movement_snapshot: 'Movement snapshot',
proximity_snapshot: 'Proximity snapshot',
firing_started: 'Firing started',
firing_stopped: 'Firing stopped',
reload_started: 'Reload started',
reload_completed: 'Reload completed',
weapon_swapped: 'Weapon swapped',
tick: 'Simulation tick',
} as Record<string, string>
)[clause.when],
);
}
});
it('covers conditions, references, reducers, state, mutations, and outcome metadata', () => {
const program = baseProgram({
scenarioInputs: [
{
key: 'stacks-to-add',
label: 'Stacks to add',
kind: 'number',
defaultValue: 1.25,
bestCaseValue: 2.5,
min: 0,
max: 3,
},
],
pools: [
{
key: 'stacks',
label: 'Momentum stacks',
scope: 'agent',
initial: 0,
min: 0,
max: 4,
bestCaseValue: 3,
expiresAfterSeconds: 8.75,
},
{
key: 'target-mark',
label: 'Target mark',
scope: 'target',
initial: 0,
min: 0,
max: 1,
},
{
key: 'cooldown',
label: 'Momentum cooldown',
scope: 'agent',
initial: 0,
min: 0,
max: 1,
expiresAfterSeconds: 12,
},
],
clauses: [
{
id: 'advanced',
label: 'Build momentum',
when: 'weapon_hit',
if: {
kind: 'all',
predicates: [
{
kind: 'compare',
left: { source: 'event', path: 'values.distanceMeters' },
operator: 'lte',
right: 15.5,
},
{
kind: 'not',
predicate: {
kind: 'exists',
value: { source: 'event', path: 'values.critical' },
},
},
{
kind: 'compare',
left: { source: 'attribute', attributeId: 'firepower' },
operator: 'gte',
right: { source: 'scenario', key: 'stacks-to-add' },
},
],
},
reducer: {
kind: 'count',
threshold: 2,
reset: 'subtract',
groupBy: { source: 'event', path: 'targetId' },
},
cooldownSeconds: 4.5,
weight: {
op: 'clamp',
value: { ref: { source: 'pool', pool: 'stacks' } },
min: 0,
max: 1,
},
procChancePercent: 37.5,
requiresAssignedAbilityActive: true,
mutations: [
{
kind: 'add',
pool: 'stacks',
value: { ref: { source: 'scenario', key: 'stacks-to-add' } },
},
{
kind: 'set',
pool: 'target-mark',
entity: { kind: 'event_target' },
value: 1,
},
{ kind: 'consume', pool: 'stacks', value: 0.5 },
{
kind: 'derive',
pool: 'stacks',
value: { op: 'floor', value: { ref: { source: 'reducer' } } },
},
{
kind: 'transfer',
pool: 'target-mark',
from: { kind: 'event_target' },
to: { kind: 'agent' },
value: 'all',
clearSource: false,
},
{
kind: 'extend_duration',
target: 'assigned_ability',
seconds: 2.25,
},
{
kind: 'start_cooldown_on_max',
stackPool: 'stacks',
cooldownPool: 'cooldown',
},
],
outcomes: [
{
id: 'buff',
label: 'Momentum buff',
phase: 'while_active',
kind: 'attribute_modifier',
scope: 'self',
value: {
op: 'multiply',
left: 2,
right: {
ref: { source: 'attribute', attributeId: 'firepower' },
},
},
basis: 'flat',
attributeId: 'firepower',
statePool: 'stacks',
perStack: true,
minStacks: 1,
maxStacks: 4,
combatTiming: 'current',
probabilityPercent: 88.25,
},
{
id: 'restore',
label: 'Regeneration',
phase: 'after_event',
kind: 'restoration',
scope: 'self_and_allies',
value: 10,
basis: 'max_health_percent',
resource: 'health',
delivery: 'continuous',
durationSeconds: 3.5,
},
{
id: 'cost',
label: 'Ammo cost',
phase: 'on_event',
kind: 'resource_cost',
scope: 'self',
value: 2,
basis: 'flat',
resource: 'ammo',
delivery: 'instant',
},
{
id: 'reload',
label: 'Reload inactive weapon',
phase: 'on_exit',
kind: 'weapon_reload',
scope: 'inactive_weapon',
value: { op: 'add', left: 1.125, right: 0.25 },
basis: 'flat',
},
{
id: 'area',
label: 'Area strike',
phase: 'on_event',
kind: 'direct_damage',
scope: 'target',
value: 15,
basis: 'weapon_base_percent',
targeting: {
kind: 'event_target_area',
center: 'event_target',
validTargets: 'enemies',
radiusMeters: 4,
},
},
{
id: 'marker',
label: 'Marker',
phase: 'on_enter',
kind: 'marker',
scope: 'assigned_ability',
value: 1,
basis: 'flat',
},
],
},
],
});
const result = render(program, {
resolveAttributeLabel: (id) =>
id === 'firepower' ? 'Firepower' : undefined,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.variants[0].text).toMatchInlineSnapshot(`
"Base
The “Stacks to add” scenario input is a number, from 0, to 3, default 1.25, maximum 2.5.
State “Momentum cooldown” tracks the Agent, starting at 0, from 0 to 1. It expires 12 seconds after it is written. State “Momentum stacks” tracks the Agent, starting at 0, from 0 to 4. The maximum scenario value is 3. It expires 8.75 seconds after it is written. State “Target mark” tracks each target, starting at 0, from 0 to 1.
Build momentum: When Weapon hit occurs, provided that all of these conditions are met: (the event’s Target distance is at most 15.5); (it is not the case that the event’s Critical hit is present); (the Firepower Attribute value is at least the “Stacks to add” scenario input), after 2 matching events grouped by the event’s Target ID while retaining any matches beyond the threshold, only while the assigned ability is active, with a weight of the value of the “Momentum stacks” state for the Agent limited from 0 to 1, with a 37.5% chance to trigger, then unavailable for 4.5 seconds. Add the “Stacks to add” scenario input to the “Momentum stacks” state for the Agent. Set 1 as the “Target mark” state for the event target. Consume 0.5 from the “Momentum stacks” state for the Agent. Derive the whole-number value of the reduced value as the “Momentum stacks” state for the Agent. Transfer all of the “Target mark” state from the event target to the Agent, without clearing the source. Extend the assigned ability by 2.25 seconds. Start the “Momentum cooldown” cooldown when the “Momentum stacks” state reaches its maximum. Momentum buff: while the state is active, modify “Firepower” by the product of 2 and the Firepower Attribute value for the Agent using the “Momentum stacks” state per stack when stacks are from 1 through 4 immediately with a 88.25% chance. Regeneration: after this event, restore a total of 10% of maximum Health to Health for the Agent and allies continuously over 3.5 seconds. Ammo cost: when this event occurs, spend 2 of Ammo from the Agent immediately. Reload inactive weapon: when the state ends, reload the inactive weapon after the sum of 1.125 and 0.25 seconds. Area strike: when this event occurs, deal damage equal to 15% of weapon base damage to the target, affecting valid enemies within 4 meters of the event target immediately. Marker: when the state begins, record a marker of 1 for the assigned ability immediately."
`);
});
it('renders a custom variant label and resolves all attribute references', () => {
const result = renderPublicMechanicPrograms(
[
{
key: 'rank-1',
label: 'Rank 1',
program: baseProgram({
clauses: [
{
...baseProgram().clauses[0],
if: {
kind: 'exists',
value: { source: 'attribute', attributeId: 'armor' },
},
outcomes: [
{
...baseProgram().clauses[0].outcomes![0],
kind: 'attribute_modifier',
attributeId: 'armor',
scope: 'self',
},
],
},
],
}),
},
],
{ resolveAttributeLabel: (id) => (id === 'armor' ? 'Armor' : null) },
);
expect(result).toMatchObject({
ok: true,
variants: [{ key: 'rank-1', label: 'Rank 1' }],
});
if (result.ok) expect(result.variants[0].text).toContain('“Armor”');
});
it('fails closed for an invalid program before attempting to render it', () => {
const invalid = baseProgram({
clauses: [{ ...baseProgram().clauses[0], when: 'not-an-event' as never }],
});
const result = render(invalid);
expect(result).toMatchObject({
ok: false,
errors: [{ code: 'invalid_program', path: 'variants[base]' }],
});
});
it('validates true predicates instead of dropping their structure', () => {
const program = baseProgram({
clauses: [
{
...baseProgram().clauses[0],
if: { kind: 'true', unexpected: true } as never,
},
],
});
const result = render(program);
expect(result).toMatchObject({
ok: false,
errors: [
{
code: 'unsupported_fragment',
path: 'variants[base].clauses[0].if.unexpected',
},
],
});
});
it('rejects overlapping or non-array input shapes', () => {
const result = renderPublicMechanicPrograms({
variants: [{ key: 'base', program: baseProgram() }],
} as never);
expect(result).toMatchObject({
ok: false,
errors: [{ code: 'invalid_program', path: 'variants' }],
});
});
it('rejects combat references outside the combat reference registry', () => {
const result = render(
baseProgram({
clauses: [
{
...baseProgram().clauses[0],
outcomes: [
{
...baseProgram().clauses[0].outcomes![0],
value: {
ref: { source: 'combat', path: 'maxHealth' },
},
},
],
},
],
}),
);
expect(result).toMatchObject({
ok: false,
errors: [
{
code: 'unsupported_fragment',
path: 'variants[base].clauses[0].outcomes[0].value.ref.path',
},
],
});
});
it('fails closed when an Attribute ID cannot be resolved', () => {
const result = render(
baseProgram({
clauses: [
{
...baseProgram().clauses[0],
outcomes: [
{
...baseProgram().clauses[0].outcomes![0],
kind: 'attribute_modifier',
attributeId: 'missing-attribute',
},
],
},
],
}),
{ resolveAttributeLabel: () => undefined },
);
expect(result).toMatchObject({
ok: false,
errors: [
{
code: 'unresolved_attribute',
path: 'variants[base].clauses[0].outcomes[0].attributeId',
},
],
});
});
it('does not render ignored combat timing on flat outcomes', () => {
const program = baseProgram({
clauses: [
{
...baseProgram().clauses[0],
outcomes: [
{
...baseProgram().clauses[0].outcomes![0],
basis: 'flat',
combatTiming: 'pre_event',
},
],
},
],
});
const result = render(program);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.variants[0].text).not.toContain('before this event');
expect(result.variants[0].text).not.toContain('using current values');
}
});
it('fails closed for targeting the runtime does not expand', () => {
const result = render(
baseProgram({
clauses: [
{
...baseProgram().clauses[0],
outcomes: [
{
...baseProgram().clauses[0].outcomes![0],
targeting: {
kind: 'within_radius',
population: 'enemies',
radiusMeters: 4,
},
},
],
},
],
}),
);
expect(result).toMatchObject({
ok: false,
errors: [
{
code: 'unsupported_fragment',
path: 'variants[base].clauses[0].outcomes[0].targeting',
},
],
});
});
it('sorts set-like inputs and pools by semantic key', () => {
const first = baseProgram({
scenarioInputs: [
{
key: 'z-input',
label: 'Z input',
kind: 'toggle',
defaultValue: false,
bestCaseValue: true,
},
{
key: 'a-input',
label: 'A input',
kind: 'toggle',
defaultValue: false,
bestCaseValue: true,
},
],
pools: [
{ key: 'z-pool', label: 'Z pool', scope: 'agent', initial: 0 },
{ key: 'a-pool', label: 'A pool', scope: 'agent', initial: 0 },
],
});
const second = baseProgram({
scenarioInputs: [...first.scenarioInputs!].reverse(),
pools: [...first.pools!].reverse(),
});
const firstResult = render(first);
const secondResult = render(second);
expect(firstResult.ok && secondResult.ok).toBe(true);
if (firstResult.ok && secondResult.ok)
expect(firstResult.variants[0].text).toBe(secondResult.variants[0].text);
});
it('requires renderable labels instead of humanizing generated IDs', () => {
const missingVariantLabel = renderPublicMechanicPrograms([
{ key: 'generated-variant-id', program: baseProgram() },
]);
expect(missingVariantLabel).toMatchObject({
ok: false,
errors: [
{
code: 'unsupported_fragment',
path: 'variants[generated-variant-id].label',
},
],
});
const missingClauseLabel = render(
baseProgram({
clauses: [{ ...baseProgram().clauses[0], label: undefined }],
}),
);
expect(missingClauseLabel).toMatchObject({
ok: false,
errors: [
{
code: 'unsupported_fragment',
path: 'variants[base].clauses[0].label',
},
],
});
const missingOutcomeLabel = render(
baseProgram({
clauses: [
{
...baseProgram().clauses[0],
outcomes: [
{
...baseProgram().clauses[0].outcomes![0],
label: undefined as never,
},
],
},
],
}),
);
expect(missingOutcomeLabel).toMatchObject({
ok: false,
errors: [
{
code: 'unsupported_fragment',
path: 'variants[base].clauses[0].outcomes[0].label',
},
],
});
const missingPoolLabel = render(
baseProgram({
pools: [
{
key: 'generated-pool-id',
label: undefined as never,
scope: 'agent',
initial: 0,
},
],
}),
);
expect(missingPoolLabel).toMatchObject({
ok: false,
errors: [
{ code: 'unsupported_fragment', path: 'variants[base].pools[0].label' },
],
});
});
it('fails closed for unsupported fields instead of leaking them', () => {
const program = baseProgram();
(
program.clauses[0] as MechanicProgram['clauses'][number] & {
script?: string;
}
).script = 'WHEN raw';
const result = render(program);
expect(result).toMatchObject({
ok: false,
errors: [
{
code: 'unsupported_fragment',
path: 'variants[base].clauses[0].script',
},
],
});
});
it('does not round represented numeric values or depend on locale settings', () => {
const program = baseProgram({
clauses: [
{
...baseProgram().clauses[0],
outcomes: [
{ ...baseProgram().clauses[0].outcomes![0], value: 1.23456789 },
],
},
],
});
const result = render(program);
expect(result.ok).toBe(true);
if (result.ok) expect(result.variants[0].text).toContain('1.23456789');
});
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+44 -24
View File
@@ -23,7 +23,9 @@
let coverUploading = $state(false);
let initialized = $state(false);
let lastContent = '';
let saveSequence = 0;
let saveTimer: number | undefined;
let saveInFlight = false;
let saveQueued = false;
const groupedTags = $derived.by(() => {
const groups: Record<string, ComposerTag[]> = {};
for (const tag of data.tags as ComposerTag[]) (groups[tag.group] ??= []).push(tag);
@@ -35,6 +37,40 @@
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(() => {
const content = JSON.stringify({ title, deck, document, selectedTags, channel, coverImageId, coverAlt, coverCaption });
if (!initialized) {
@@ -44,29 +80,13 @@
}
if (content === lastContent) return;
lastContent = content;
const sequence = ++saveSequence;
saveQueued = true;
saveState = 'saving';
const timer = window.setTimeout(async () => {
try {
const response = await fetch('/api/articles/draft', {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload())
});
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);
if (!saveInFlight) scheduleSave();
return () => {
if (saveTimer !== undefined) window.clearTimeout(saveTimer);
saveTimer = undefined;
};
});
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>
<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}/>
<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>
</section>
+12 -2
View File
@@ -24,6 +24,16 @@
function link(mark: ArticleMark) {
return mark.type === 'link' ? mark.attrs?.href ?? '#' : '#';
}
function orderedListStart(node: ArticleNode) {
const start = Number(node.attrs?.start ?? 1);
return Number.isInteger(start) && start > 1 ? start : undefined;
}
function orderedListType(node: ArticleNode): 'a' | 'A' | 'i' | 'I' | undefined {
const type = node.attrs?.type;
return ['a', 'A', 'i', 'I'].includes(String(type)) ? type as 'a' | 'A' | 'i' | 'I' : undefined;
}
</script>
{#snippet renderText(text: string, marks: ArticleMark[], index = 0)}
@@ -32,7 +42,7 @@
{#if mark.type === 'bold'}<strong>{@render renderText(text, marks, index + 1)}</strong>
{:else if mark.type === 'italic'}<em>{@render renderText(text, marks, index + 1)}</em>
{:else if mark.type === 'code'}<code>{@render renderText(text, marks, index + 1)}</code>
{:else}<a href={link(mark)} rel="nofollow ugc noopener noreferrer">{@render renderText(text, marks, index + 1)}</a>{/if}
{:else}<a href={link(mark)} title={mark.attrs?.title} rel="nofollow ugc noopener noreferrer">{@render renderText(text, marks, index + 1)}</a>{/if}
{/if}
{/snippet}
@@ -46,7 +56,7 @@
{:else if node.type === 'heading' && Number(node.attrs?.level) === 2}<h2>{@render children(node)}</h2>
{:else if node.type === 'heading'}<h3>{@render children(node)}</h3>
{:else if node.type === 'bulletList'}<ul>{@render children(node)}</ul>
{:else if node.type === 'orderedList'}<ol>{@render children(node)}</ol>
{:else if node.type === 'orderedList'}<ol start={orderedListStart(node)} type={orderedListType(node)}>{@render children(node)}</ol>
{:else if node.type === 'listItem'}<li>{@render children(node)}</li>
{:else if node.type === 'blockquote'}<blockquote>{@render children(node)}</blockquote>
{:else if node.type === 'horizontalRule'}<hr/>
+2 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import AgentIdentityLink from '$lib/components/AgentIdentityLink.svelte';
import ArticleContent from '$lib/components/ArticleContent.svelte';
import ArticleDiscussion from '$lib/components/ArticleDiscussion.svelte';
import AgentIdentityLink from '$lib/components/AgentIdentityLink.svelte';
import PendingForm from '$lib/components/PendingForm.svelte';
import { untrack } from 'svelte';
let { data, form = null } = $props();
@@ -25,7 +25,7 @@
<header class="article-reader-head">
<div class="article-reader-channel"><span>{data.article.channel ?? (data.article.kind === 'guide' ? 'COMMUNITY GUIDE' : 'NEWS')}</span>{#if data.article.featured}<b>FEATURED</b>{/if}{#if data.article.recommended}<b>RECOMMENDED</b>{/if}</div>
<h1>{data.article.title}</h1><p>{data.article.deck}</p>
<div class="article-reader-byline">{#if data.article.source}<div class="article-source-identity"><strong>{data.article.source.name}</strong><span>{publicationMeta}</span></div>{:else}<AgentIdentityLink size="compact" linked={Boolean(data.article.authorPublicSlug)} agent={{id:data.article.ownerId,displayName:data.article.author,publicSlug:data.article.authorPublicSlug,avatarUrl:`/avatars/user/${data.article.ownerId}`}} meta={publicationMeta}/>{/if}<div>{#if data.article.kind === 'guide' && data.article.owner}<a class="secondary" href={`/guides/${encodeURIComponent(data.article.slug)}/edit`}>EDIT GUIDE</a>{:else if data.article.kind === 'news' && data.canManageNews && !data.article.source}<a class="secondary" href={`/news/${encodeURIComponent(data.article.slug)}/edit`}>OPEN NEWSROOM</a>{/if}</div></div>
<div class="article-reader-byline">{#if data.article.source}<div class="article-source-identity"><strong>{data.article.source.name}</strong><span>{publicationMeta}</span></div>{:else}<AgentIdentityLink size="compact" linked={Boolean(data.article.authorPublicSlug)} agent={{id:data.article.ownerId,displayName:data.article.author,publicSlug:data.article.authorPublicSlug,avatarUrl:`/avatars/user/${data.article.ownerId}`}} meta={publicationMeta}/>{/if}<div>{#if data.article.kind === 'guide' && data.article.owner}<a class="secondary" href={`/guides/${encodeURIComponent(data.article.slug)}/edit`}>EDIT GUIDE</a>{:else if data.article.kind === 'news' && data.canManageNews && !data.article.source}<a class="secondary" href={`/news/${encodeURIComponent(data.article.slug)}/edit`}>Edit Article</a>{/if}</div></div>
{#if data.article.source}<aside class="article-source-notice"><div><span>OFFICIAL SOURCE</span><strong>Originally published by {data.article.source.name}</strong></div><a href={data.article.source.url} target="_blank" rel="noopener noreferrer">READ ON UBISOFT</a></aside>{/if}
{#if data.tags.length}<nav class="article-reader-tags" aria-label="Article tags">{#each data.tags as tag}<a href={`/${path}?tag=${encodeURIComponent(tag.slug)}`}>{tag.name}</a>{/each}</nav>{/if}
</header>
+4 -4
View File
@@ -5,7 +5,7 @@
items,
value = $bindable(''),
label,
placeholder = 'Search available items…',
placeholder = 'Search…',
disabledIds = [],
onchange
}: {
@@ -27,19 +27,19 @@
<span>{label}</span>
<button type="button" class="builder-picker-current" aria-expanded={open} onclick={() => open = !open}>
<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>
{#if open}
<div class="builder-picker-panel">
<input type="search" bind:value={query} {placeholder} aria-label={placeholder}/>
<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}
<button type="button" class:selected={item.id === value} disabled={disabledIds.includes(item.id)} onclick={() => choose(item.id)}>
<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>
</button>
{:else}<p>NO MATCHING ITEMS</p>{/each}
{:else}<p>NO MATCHES</p>{/each}
</div>
</div>
{/if}
+24 -15
View File
@@ -24,6 +24,8 @@
} = $props();
let query = $state('');
let open = $state(false);
const selected = $derived(items.find((item) => item.id === value));
const visible = $derived(items.filter((item) =>
!query.trim() || `${item.name} ${item.category ?? ''}`.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase())
));
@@ -31,26 +33,33 @@
function choose(id: string) {
value = id;
onchange?.(id);
if (searchable) {
open = false;
query = '';
}
}
</script>
<fieldset class="builder-radio-grid" class:icon-only={iconOnly} style={`--builder-radio-columns:${Math.max(1, columns)}`}>
<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()}`}/>
{/if}
<div>
{#each visible as item}
<label title={item.name}>
<input type="radio" {name} value={item.id} checked={value === item.id} {required} aria-label={item.name} onchange={() => choose(item.id)}/>
<span>
<ItemIcon src={item.iconUrl} alt=""/>
<b>{item.name}</b>
{#if item.category}<small>{item.category}</small>{/if}
</span>
</label>
{:else}
<p>NO MATCHING OPTIONS</p>
{/each}
</div>
{#if !searchable || open}
<div>
{#each visible as item}
<label title={item.name}>
<input type="radio" {name} value={item.id} checked={value === item.id} {required} aria-label={item.name} onchange={() => choose(item.id)}/>
<span>
<ItemIcon src={item.iconUrl} alt=""/>
<b>{item.name}</b>
{#if item.category}<small>{item.category}</small>{/if}
</span>
</label>
{:else}
<p>NO MATCHING OPTIONS</p>
{/each}
</div>
{/if}
</fieldset>
+79 -9
View File
@@ -6,19 +6,45 @@
import type { GameIconName } from '$lib/game-icons';
import type { DatabaseItem } from '$lib/database/items';
import MarkdownContent from './MarkdownContent.svelte';
import WeaponModSlotIcon from './WeaponModSlotIcon.svelte';
import DatabaseMechanicGroupCard from './database/DatabaseMechanicGroup.svelte';
import DatabaseMechanicStatusBadge from './database/DatabaseMechanicStatusBadge.svelte';
import { databaseMechanicGroupStatus, type DatabaseMechanicGroup } from '$lib/database/items';
let {
item,
hideDiscussionUtility = false,
anchor = true
anchor = true,
detail = false
}: {
item: DatabaseItem & { gameIcon?: GameIconName };
hideDiscussionUtility?: boolean;
anchor?: boolean;
detail?: boolean;
} = $props();
let protocolLevel = $state(1);
let talentUpgrades = $state(0);
const number = (value: number) => Number.isInteger(value) ? String(value) : value.toFixed(2).replace(/\.?0+$/, '');
const valueWithUnit = (value: number, unit: string) => `${number(value)}${unit ? (unit === '%' ? '%' : ` ${unit}`) : ''}`;
const protocolPrimaryValue = () => item.protocolPreview
? item.protocolPreview.primaryBaseValue * (1 + (protocolLevel - 1) / 49)
: 0;
const talentDescription = () => item.protocolPreview?.talentDescription?.replace(/\{(\d+)\}/g, (placeholder, raw) => {
const effect = item.protocolPreview?.talentEffects[Number(raw) - 1];
return effect ? valueWithUnit(effect.baseValue * (1 + talentUpgrades / 5), effect.unit) : placeholder;
});
const mechanicGroups = () => item.mechanicGroups ?? [];
const hasMechanicGroups = () => mechanicGroups().length > 0;
const isSpecializationMechanic = (group: DatabaseMechanicGroup) => group.ownerType === 'specialization_tree_rank';
const hasSpecializationMechanics = () => mechanicGroups().some(isSpecializationMechanic);
const primaryMechanicGroup = () => {
const groups = mechanicGroups();
return groups.length === 1 && !isSpecializationMechanic(groups[0]) ? groups[0] : null;
};
</script>
<article id={anchor ? `database-item-${item.id}` : undefined} class="database-item-card" class:talent-card={item.variant === 'talent'}>
<article id={anchor ? `database-item-${item.id}` : undefined} class="database-item-card" class:talent-card={item.variant === 'talent'} class:protocol-quality-standard={item.topBorderTone === 'standard'} class:protocol-quality-specialized={item.topBorderTone === 'specialized'} class:protocol-quality-superior={item.topBorderTone === 'superior'} class:protocol-quality-highend={item.topBorderTone === 'highend'}>
<header>
{#if item.renderUrl}<img class="database-item-render" src={item.renderUrl} alt="" loading="lazy"/>{/if}
<div class="database-item-icon">
@@ -26,18 +52,48 @@
</div>
<div class="database-item-identity">
<small>{item.kicker}</small>
<h2>{item.name}</h2>
<div class="database-item-name-line">
<h2>{item.name}</h2>
{#if primaryMechanicGroup()}
<DatabaseMechanicStatusBadge status={databaseMechanicGroupStatus(primaryMechanicGroup()!)} />
{/if}
</div>
</div>
{#if item.headerBadges?.length || item.headerBadge || item.headerMetaIcon}
<div class="database-header-status">
{#if item.headerMetaIcon}<GameIcon name={item.headerMetaIcon}/>{/if}
{#each item.headerBadges ?? [] as badge}<span class="database-header-badge">{badge}</span>{/each}
{#if item.headerBadge}<span class="database-header-badge">{item.headerBadge}</span>{/if}
{#each item.headerBadges ?? [] as badge}<span class="database-header-badge" class:exotic={badge === 'EXOTIC'}>{badge}</span>{/each}
{#if item.headerBadge}<span class="database-header-badge" class:protocol-badge-standard={item.headerBadgeTone === 'standard'} class:protocol-badge-specialized={item.headerBadgeTone === 'specialized'} class:protocol-badge-superior={item.headerBadgeTone === 'superior'} class:protocol-badge-highend={item.headerBadgeTone === 'highend'}>{item.headerBadge}</span>{/if}
</div>
{/if}
</header>
{#if item.badges?.length}<div class="database-item-badges">{#each item.badges as badge}<span class={badge.tone ?? ''}>{badge.label}</span>{/each}</div>{/if}
<DatabaseFacts facts={item.facts} layout={item.factLayout}/>
{#if item.protocolPreview}
<section class="database-protocol-preview">
<div class="database-protocol-primary"><span>{item.protocolPreview.primaryStat}</span><b>{Number.isFinite(protocolPrimaryValue()) ? valueWithUnit(protocolPrimaryValue(), item.protocolPreview.primaryUnit) : 'NOT CONFIGURED'}</b></div>
{#if item.protocolPreview.talentDescription && !hasMechanicGroups()}
<div class="database-protocol-talent"><p>{talentDescription()}</p></div>
{/if}
<div class="database-protocol-controls"><label class="database-protocol-level"><span>LVL {protocolLevel}</span><input type="range" min="1" max="50" step="1" bind:value={protocolLevel} aria-label={`${item.name} level`}/></label>{#if item.protocolPreview.talentDescription}<div class="database-protocol-diamonds" role="group" aria-label={`${item.name} talent upgrades`}>{#each [1, 2, 3, 4, 5] as upgrade}<button type="button" class:active={talentUpgrades >= upgrade} aria-label={`Set talent upgrades to ${upgrade}`} aria-pressed={talentUpgrades === upgrade} onclick={() => talentUpgrades = talentUpgrades === upgrade ? 0 : upgrade}>◆</button>{/each}</div>{/if}</div>
</section>
{:else}
<DatabaseFacts facts={item.facts} layout={item.factLayout}/>
{/if}
{#if item.weaponModSlots}
<div class="database-weapon-mod-slots">
<div><span>MOD SLOTS</span></div>
{#if item.weaponModSlots.length}
<ul aria-label={`${item.name} Weapon Mod slots`}>
{#each item.weaponModSlots as modSlot}
<li class:upgrade-slot={modSlot.unlockedAtTierOne} class:has-slot-icon={Boolean(modSlot.iconUrl)} title={`${modSlot.slot}${modSlot.unlockedAtTierOne ? ' - Unlocked when this weapon upgrades to Tier 1' : ''}`}>
{#if modSlot.iconUrl}<WeaponModSlotIcon slot={modSlot.slot} iconUrl={modSlot.iconUrl}/><span class="sr-only">{modSlot.slot}</span>{:else}<span>{modSlot.slot}</span>{/if}
{#if modSlot.unlockedAtTierOne}<small>T1+</small>{/if}
</li>
{/each}
</ul>
{:else}<p>MOD SLOT DATA NOT CONFIGURED</p>{/if}
</div>
{/if}
{#if item.lines?.length}
<div class="database-item-lines">{#each item.lines as line}<div><span>{line.label}</span><b>{line.value}</b></div>{/each}</div>
{/if}
@@ -48,15 +104,29 @@
{/each}
</div>
{/if}
{#if item.variant === 'talent'}
{#if hasMechanicGroups() && !hasSpecializationMechanics()}
<div class="database-mechanic-groups">
{#each mechanicGroups() as group (group.id)}
<DatabaseMechanicGroupCard {group} {detail}/>
{/each}
</div>
{/if}
{#if item.variant === 'talent' && !hasMechanicGroups()}
<div class="database-talent-tiers">
<section><span>TIER I</span><p>{item.description ?? 'Description is not available.'}</p></section>
<section><span>TIER II</span><p>{item.secondaryDescription ?? 'Description is not available.'}</p></section>
{#if item.secondaryDescription}<section><span>TIER II</span><p>{item.secondaryDescription}</p></section>{/if}
</div>
{:else}
{:else if !item.protocolPreview && (!hasMechanicGroups() || hasSpecializationMechanics())}
{#if item.description}<p class:data-description={item.descriptionStyle === 'data'}>{item.description}</p>{/if}
{#if item.secondaryDescription}<p class="secondary-description" class:data-description={item.descriptionStyle === 'data'}>{item.secondaryDescription}</p>{/if}
{/if}
{#if hasSpecializationMechanics()}
<div class="database-mechanic-groups database-specialization-effect-groups">
{#each mechanicGroups().filter(isSpecializationMechanic) as group (group.id)}
<DatabaseMechanicGroupCard {group} {detail} showIdentity/>
{/each}
</div>
{/if}
{#if item.afterLines?.length}
<div class="database-item-lines after-description">{#each item.afterLines as line}<div><span>{line.label}</span><b>{line.value}</b></div>{/each}</div>
{/if}
+1 -1
View File
@@ -14,4 +14,4 @@
const resolvedAlt = $derived(src || !alt ? alt : `${alt} — icon awaiting contribution`);
</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()}/>
+4 -3
View File
@@ -18,6 +18,7 @@
let closeButton = $state<HTMLButtonElement | null>(null);
let dialog = $state<HTMLDivElement | null>(null);
const openPreview = async (item: DatabaseRelatedItem, button: HTMLButtonElement) => {
if (!item.preview) return;
selected = item;
trigger = button;
await tick();
@@ -32,8 +33,8 @@
};
const trapDialogFocus = (event: KeyboardEvent) => {
if (event.key !== 'Tab' || !dialog) return;
const focusable = [...dialog.querySelectorAll<HTMLElement>('a[href],button:not(:disabled),[tabindex]:not([tabindex="-1"])')]
.filter((element) => !element.hasAttribute('hidden'));
const focusable = [...dialog.querySelectorAll<HTMLElement>('a[href],button:not(:disabled),input:not([type="hidden"]):not(:disabled),select:not(:disabled),textarea:not(:disabled),[tabindex]:not([tabindex="-1"])')]
.filter((element) => !element.hasAttribute('hidden') && element.getClientRects().length > 0);
const first = focusable[0];
const last = focusable.at(-1);
if (!first || !last) return;
@@ -65,7 +66,7 @@
<div class="database-preview-backdrop" role="presentation" onclick={(event) => { if (event.target === event.currentTarget) void closePreview(); }}>
<div class="database-preview-dialog" bind:this={dialog} role="dialog" aria-modal="true" aria-labelledby={`database-preview-${selected.id}`} tabindex="-1" onkeydown={trapDialogFocus}>
<header><div><small>DATABASE QUICK VIEW</small><strong id={`database-preview-${selected.id}`}>{selected.preview.name}</strong></div><button type="button" bind:this={closeButton} aria-label="Close details" onclick={() => void closePreview()}>×</button></header>
<DatabaseItemCard item={selected.preview}/>
<DatabaseItemCard item={selected.preview} anchor={false} detail/>
</div>
</div>
{/if}
@@ -0,0 +1,125 @@
<script lang="ts">
import PendingForm from '$lib/components/PendingForm.svelte';
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
import {
MOD_QUALITIES,
MOD_SECONDARY_CAPACITY,
MOD_SECONDARY_QUALITIES,
type ModSecondaryRange
} from '$lib/mods/secondary-pool';
let {
kind,
pool,
stats,
error,
saved = false,
resultToken = ''
}: {
kind: 'Weapon Mod' | 'Gear Mod';
pool: ModSecondaryRange[];
stats: Array<{ id: string; name: string; unit: string }>;
error?: string;
saved?: boolean;
resultToken?: string;
} = $props();
const poolId = $derived(kind === 'Weapon Mod' ? 'weapon-mod-secondary-pool' : 'gear-mod-secondary-pool');
const action = '?/saveSecondaryPool';
const qualityClass = (quality: string) => `quality-${quality.toLowerCase().replace('-', '')}`;
const blankSecondary = (): ModSecondaryRange => ({
statId: '',
values: Object.fromEntries(
MOD_SECONDARY_QUALITIES.map((quality) => [quality, {
level1Min: 0,
level1Max: 0,
level20Min: 0,
level20Max: 0
}])
) as ModSecondaryRange['values']
});
let editing = $state(false);
let draft = $state<ModSecondaryRange[]>([]);
let handledResultToken = $state('');
function openEditor() {
draft = pool.length ? structuredClone(pool) : [blankSecondary(), blankSecondary()];
editing = true;
}
function addSecondary() {
if (draft.length < 50) draft = [...draft, blankSecondary()];
}
function removeSecondary(index: number) {
if (draft.length > 2) draft = draft.filter((_, itemIndex) => itemIndex !== index);
}
$effect(() => {
if (saved && resultToken && resultToken !== handledResultToken) {
handledResultToken = resultToken;
editing = false;
}
});
</script>
<section class="mod-secondary-pool-registry">
<div class="selection-head">
<div>
<span>SHARED {kind.toUpperCase()} SECONDARY POOL</span>
<small>ONE CANONICAL POOL FOR EVERY REGULAR {kind.toUpperCase()}</small>
</div>
<button type="button" class="row-action" onclick={openEditor}>EDIT SHARED POOL →</button>
</div>
<div class="mod-secondary-capacity">
{#each MOD_QUALITIES as quality}
<span><b class={qualityClass(quality)}>{quality.toUpperCase()}</b><strong>{MOD_SECONDARY_CAPACITY[quality]}</strong><small>SECONDARY {MOD_SECONDARY_CAPACITY[quality] === 1 ? 'ROLL' : 'ROLLS'}</small></span>
{/each}
<span class="pool-total"><b>ATTRIBUTE POOL</b><strong>{pool.length}</strong><small>SHARED OPTIONS</small></span>
</div>
</section>
{#if editing}
<div class="dashboard-editor mod-secondary-pool-editor">
<div class="editor-title">
<div><p class="eyebrow">SHARED CONFIGURATION</p><h3>{kind.toUpperCase()} SECONDARY ATTRIBUTES</h3></div>
<button aria-label={`Close ${kind} secondary pool editor`} onclick={() => editing = false}>×</button>
</div>
<PendingForm {action}>
<input type="hidden" name="id" value={poolId}/>
<input type="hidden" name="name" value={`Shared ${kind} Secondary Pool`}/>
<input type="hidden" name="secondaryRanges" value={JSON.stringify(draft)}/>
<p class="console-note">Standard quality has no secondary roll. Specialized selects one option; Superior and High-end select two distinct options. Ranges scale linearly from Level 1 to Level 20.</p>
<div class="selection-head pool-editor-head">
<div><span>AVAILABLE ATTRIBUTES</span><small>SPECIALIZED · SUPERIOR · HIGH-END LEVEL ENDPOINTS</small></div>
<button type="button" class="row-action" onclick={addSecondary} disabled={draft.length >= 50}>ADD ATTRIBUTE +</button>
</div>
<div class="mod-secondary-pool-options">
{#each draft as range, index}
<div class="mod-secondary-pool-option">
<strong>{String(index + 1).padStart(2, '0')}</strong>
<SearchableAssignment
items={stats}
mode="single"
compact
subjectLabel="POSSIBLE ATTRIBUTE"
searchPlaceholder="Search attributes…"
excludedIds={draft.filter((_, itemIndex) => itemIndex !== index).map((item) => item.statId)}
bind:value={range.statId}
/>
{#each MOD_SECONDARY_QUALITIES as quality}
<div class="mod-secondary-quality-range">
<b class={qualityClass(quality)}>{quality.toUpperCase()}</b>
<label><span>LEVEL 1</span><div><input type="number" step="any" bind:value={range.values[quality].level1Min} aria-label={`${quality} ${stats.find((item) => item.id === range.statId)?.name ?? 'attribute'} Level 1 minimum`} required/><i>–</i><input type="number" step="any" bind:value={range.values[quality].level1Max} aria-label={`${quality} ${stats.find((item) => item.id === range.statId)?.name ?? 'attribute'} Level 1 maximum`} required/></div></label>
<label><span>LEVEL 20</span><div><input type="number" step="any" bind:value={range.values[quality].level20Min} aria-label={`${quality} ${stats.find((item) => item.id === range.statId)?.name ?? 'attribute'} Level 20 minimum`} required/><i>–</i><input type="number" step="any" bind:value={range.values[quality].level20Max} aria-label={`${quality} ${stats.find((item) => item.id === range.statId)?.name ?? 'attribute'} Level 20 maximum`} required/></div></label>
</div>
{/each}
<button type="button" aria-label={`Remove shared secondary attribute option ${index + 1}`} onclick={() => removeSecondary(index)} disabled={draft.length <= 2}>×</button>
</div>
{/each}
</div>
{#if error}<p class="error">{error}</p>{/if}
<div class="form-actions"><button type="button" class="secondary" onclick={() => editing = false}>CANCEL</button><button class="primary">SAVE SHARED POOL →</button></div>
</PendingForm>
</div>
{/if}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+303 -155
View File
@@ -1,5 +1,16 @@
<script lang="ts">
type Entry = { name: string; kind: 'text' | 'file'; value?: string; fileName?: string; type?: string };
import {
formatMechanicToken,
formatMechanicValue,
normalizePhase2OwnerPreview,
parseProposalPreviewPayload,
safePreviewValue,
isPreviewFieldHidden,
type AuthoredSourcePreview,
type AttachmentPreview,
type MechanicPreviewState,
type ProposalPreviewEntry
} from './ProposalChangePreview.helpers';
let {
module,
@@ -13,8 +24,10 @@
currentSnapshot?: Record<string, unknown> | null;
} = $props();
const entries = $derived(JSON.parse(payload) as Entry[]);
const proposed = $derived(Object.fromEntries(entries.filter((entry) => entry.kind === 'text').map((entry) => [entry.name, entry.value ?? ''])));
const parsedPayload = $derived(parseProposalPreviewPayload(payload));
const entries = $derived(parsedPayload.entries as ProposalPreviewEntry[]);
const proposed = $derived(parsedPayload.fields);
const phase2OwnerPreview = $derived(normalizePhase2OwnerPreview(module, action, payload, currentSnapshot));
const label = (value: string) => value.replace(/([A-Z])/g, ' $1').replaceAll('_', ' ').trim().toUpperCase();
const shown = (value: unknown) => value === null || value === undefined || value === '' ? 'NOT SET' : String(value);
const jsonValue = (value: unknown) => {
@@ -29,8 +42,6 @@
gear_max: 'gearMax',
weapon_min: 'weaponMin',
weapon_max: 'weaponMax',
skill_chip_min: 'chipMin',
skill_chip_max: 'chipMax',
cap_value: 'capValue'
};
return proposed[names[key] ?? key];
@@ -45,14 +56,8 @@
: 'TREE NODE'
);
const specializationRecord = $derived((currentSnapshot?.record ?? null) as Record<string, unknown> | null);
const skillChipRecord = $derived((currentSnapshot?.record ?? null) as Record<string, unknown> | null);
const skillChipFields = $derived([
{ label: 'NAME', current: skillChipRecord?.name, proposed: proposed.name },
{ label: 'SPECIALIZATION ABILITY', current: skillChipRecord?.specialization_ability_id, proposed: proposed.abilityId },
{ label: '2-PIECE ATTRIBUTE', current: (currentSnapshot?.twoPiece as any)?.stat_attribute_id, proposed: proposed.twoPieceStatId },
{ label: '2-PIECE RANGE', current: `${(currentSnapshot?.twoPiece as any)?.min_value ?? '—'} – ${(currentSnapshot?.twoPiece as any)?.max_value ?? '—'}`, proposed: `${proposed.twoPieceMin || '—'} – ${proposed.twoPieceMax || '—'}` },
{ label: '3-PIECE TALENT', current: (currentSnapshot?.talent as any)?.name, proposed: proposed.talentName }
]);
const currentSharedSkillChipProfiles = $derived((Array.isArray(currentSnapshot) ? currentSnapshot : []) as any[]);
const proposedSharedSkillChipProfiles = $derived((jsonValue(proposed.profiles) ?? []) as any[]);
const specializationFields = $derived(
action === 'saveProgression'
? [
@@ -78,6 +83,7 @@
{ label: 'SPECIALIZATION', current: specializationRecord?.specialization_id, proposed: proposed.specializationId },
{ label: 'DURATION · SECONDS', current: specializationRecord?.duration_seconds, proposed: proposed.durationSeconds },
{ 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 }
]
: action === 'saveCombatEffect'
@@ -115,23 +121,80 @@
const proposedCalculationStages = $derived((jsonValue(proposed.stagesJson) ?? []) as any[]);
const proposedCalculationProfiles = $derived((jsonValue(proposed.profilesJson) ?? []) as any[]);
const proposedSkillCalculationProfiles = $derived((jsonValue(proposed.skillProfilesJson) ?? []) as any[]);
const phase2Record = $derived((currentSnapshot?.record ?? null) as Record<string, unknown> | null);
const phase2TargetDisplay = (side: 'current' | 'proposed') =>
side === 'current'
? phase2OwnerPreview?.canonicalTargetId
: phase2OwnerPreview?.canonicalTargetId ?? 'ASSIGNED ON APPROVAL';
const phase2HasCurrentTarget = $derived(Boolean(phase2OwnerPreview?.canonicalTargetId));
const ownerIdentityFields = (side: 'current' | 'proposed', family: NonNullable<typeof phase2OwnerPreview>['family']) => {
const value = (key: string) => side === 'current' ? phase2Record?.[key] : proposed[key];
if (family === 'talent') return [
{ label: 'NAME', value: value('name') },
{ label: 'CATEGORY', value: value('category') },
{ label: 'OWNER TYPE', value: side === 'current' ? (currentSnapshot?.authoredSource as any)?.owner?.type : 'talent' },
{ label: 'OWNER ID', value: phase2TargetDisplay(side) }
];
if (family === 'protocol-talent') return [
{ label: 'NAME', value: value('name') },
{ label: 'QUALITY', value: value('quality') },
{ label: 'ATTRIBUTE TYPE', value: side === 'current' ? phase2Record?.primary_attribute_type : proposed.attributeType },
{ label: 'PRIMARY ATTRIBUTE', value: side === 'current' ? (currentSnapshot?.primaryEffect as any)?.stat_attribute_id : proposed.primaryStatId },
{ label: 'TALENT', value: side === 'current' ? (currentSnapshot?.talent as any)?.name : proposed.hasTalent === '1' ? proposed.talentName : 'NOT ASSIGNED' }
];
if (family === 'skill-chip-talent') return [
{ label: 'NAME', value: value('name') },
{ label: 'SPECIALIZATION ABILITY', value: side === 'current' ? phase2Record?.specialization_ability_id : proposed.abilityId },
{ label: '2-PIECE ATTRIBUTE', value: side === 'current' ? (currentSnapshot?.twoPiece as any)?.stat_attribute_id : proposed.twoPieceStatId },
{ label: '2-PIECE RANGE', value: side === 'current' ? `${(currentSnapshot?.twoPiece as any)?.min_value ?? '—'} – ${(currentSnapshot?.twoPiece as any)?.max_value ?? '—'}` : `${proposed.twoPieceMin || '—'} – ${proposed.twoPieceMax || '—'}` },
{ label: '3-PIECE TALENT', value: side === 'current' ? (currentSnapshot?.talent as any)?.name : proposed.hasTalent === '1' ? proposed.talentName : 'NOT ASSIGNED' }
];
if (family === 'specialization-node') return [
{ label: 'NAME', value: value('name') },
{ label: 'FOCUS', value: side === 'current' ? phase2Record?.focus_id : proposed.focusId },
{ label: 'ROW', value: side === 'current' ? phase2Record?.row_number : proposed.rowNumber },
{ label: 'COLUMN', value: side === 'current' ? phase2Record?.column_number : proposed.columnNumber },
{ label: 'MAXIMUM RANK', value: side === 'current' ? phase2Record?.max_rank : proposed.maxRank },
{ label: 'RANK DEFINITIONS', value: side === 'current' ? (currentSnapshot?.ranks as any[])?.length ?? 0 : (jsonValue(proposed.ranksJson) as any[])?.length ?? 0 }
];
return [
{ label: 'NAME', value: value('name') },
{ label: 'SPECIALIZATION', value: side === 'current' ? phase2Record?.specialization_id : proposed.specializationId },
{ label: 'DESCRIPTION TEMPLATE', value: side === 'current' ? phase2Record?.description_template : proposed.descriptionTemplate },
{ label: 'ATTACHMENTS', value: side === 'current' ? (currentSnapshot?.attachments as any[])?.length ?? 0 : phase2OwnerPreview?.proposedAttachments.length ?? 0 }
];
};
const ownerRelationshipRows = (side: 'current' | 'proposed', family: NonNullable<typeof phase2OwnerPreview>['family']) => {
if (family === 'talent') {
if (side === 'proposed') return [`CATEGORY · ${shown(proposed.category)}`, `RECORD ID · ${shown(phase2TargetDisplay(side))}`];
const relationships = (currentSnapshot?.relationships ?? {}) as any;
return [
`WEAPON SCOPE · ${shown(relationships.weaponScope?.scope)}`,
`WEAPON SLOT · ${shown(relationships.weaponSlot?.slot)}`,
`OWNER WEAPON · ${shown(relationships.ownerWeapon?.id)}${relationships.ownerWeapon?.name ? ` · ${relationships.ownerWeapon.name}` : ''}`,
`GEAR TALENTS · ${(relationships.gearTalents ?? []).map((item: any) => item.gear_id).join(', ') || 'NONE'}`,
`WEAPON TALENT OPTIONS · ${(relationships.weaponTalentOptions ?? []).map((item: any) => `${item.weapon_id} · SLOT ${item.talent_slot}`).join(', ') || 'NONE'}`
];
}
if (family === 'protocol-talent') return side === 'current'
? [`PROTOCOL ID · ${shown(phase2TargetDisplay(side))}`, `TALENT ID · ${shown((currentSnapshot?.talent as any)?.id)}`]
: [`PROTOCOL ID · ${shown(phase2TargetDisplay(side))}`, `TALENT · ${proposed.hasTalent === '1' ? shown(proposed.talentName) : 'NOT ASSIGNED'}`];
if (family === 'skill-chip-talent') return side === 'current'
? [`SKILL CHIP SET ID · ${shown(phase2TargetDisplay(side))}`, `TALENT ID · ${shown((currentSnapshot?.talent as any)?.id)}`]
: [`SKILL CHIP SET ID · ${shown(phase2TargetDisplay(side))}`, `TALENT · ${proposed.hasTalent === '1' ? shown(proposed.talentName) : 'NOT ASSIGNED'}`];
if (family === 'specialization-node') return side === 'current'
? [`NODE ID · ${shown(phase2TargetDisplay(side))}`, `FOCUS ID · ${shown(phase2Record?.focus_id)}`]
: [`NODE ID · ${shown(phase2TargetDisplay(side))}`, `FOCUS ID · ${shown(proposed.focusId)}`];
return side === 'current'
? [`COMBAT EFFECT ID · ${shown(phase2TargetDisplay(side))}`, `ATTACHMENT IDS · ${(currentSnapshot?.attachments as any[] ?? []).map((item: any) => item.id).join(', ') || 'NONE'}`]
: [`COMBAT EFFECT ID · ${shown(phase2TargetDisplay(side))}`, `FAN-OUT ATTACHMENTS · ${phase2OwnerPreview?.proposedAttachments.length ?? 0}`];
};
const effectSummary = (effect: any) => {
const target = effect.statId || effect.stat_attribute_id || effect.abilityId || effect.ability_id || effect.label || 'CUSTOM EFFECT';
const value = effect.value === null || effect.value === undefined || effect.value === '' ? '' : `${effect.value}${effect.unit ? ` ${effect.unit}` : ''}`;
return `${target}${value ? ` · ${value}` : ''}`;
};
const conditionalRuleSummary = (effect: any) => {
const rule = effect.conditionalRule ?? {};
const clauses = Array.isArray(rule.clauses) ? rule.clauses : [];
const outcomes = clauses.reduce((total: number, clause: any) =>
total + (Array.isArray(clause.outcomes) ? clause.outcomes.length : 0), 0);
return `${effect.effectId} · ${readableToken(rule.simulationReadiness)} · ${clauses.length} BRANCH${clauses.length === 1 ? '' : 'ES'} · ${outcomes} OUTCOME${outcomes === 1 ? '' : 'S'}`;
};
const readableToken = (value: unknown) => shown(value).replaceAll('_', ' ').toUpperCase();
const storedJson = (value: unknown, fallback: unknown) => {
if (typeof value !== 'string') return value ?? fallback;
try { return JSON.parse(value); } catch { return fallback; }
};
const attackProfileLines = (attack: Record<string, unknown> | null) => {
if (!attack || ('enabled' in attack && !attack.enabled)) return [];
const delivery = String(attack.delivery ?? '').toLowerCase();
@@ -155,90 +218,100 @@
}
return lines;
};
const conditionalRuleLines = (source: unknown) => {
if (!source || typeof source !== 'object' || Array.isArray(source)) return [];
const wrapper = source as Record<string, any>;
const persisted = wrapper.rule && typeof wrapper.rule === 'object';
const rule = persisted ? wrapper.rule : wrapper;
const inputs = persisted
? storedJson(rule.scenario_inputs_json, [])
: rule.scenarioInputs;
const pools = persisted ? wrapper.statePools : rule.pools;
const clauses = persisted ? wrapper.clauses : rule.clauses;
const lines = [
`READINESS · ${readableToken(rule.simulationReadiness ?? rule.simulation_readiness)}`,
`MODEL · ${Array.isArray(inputs) ? inputs.length : 0} SCENARIO INPUTS · ${Array.isArray(pools) ? pools.length : 0} STATE POOLS · ${Array.isArray(clauses) ? clauses.length : 0} BRANCHES`
];
if (Array.isArray(inputs)) {
for (const input of inputs) {
lines.push(`INPUT · ${shown(input.label ?? input.key)} · ${readableToken(input.kind)} · ${readableToken(input.readiness ?? 'ready')}`);
}
}
if (Array.isArray(pools)) {
for (const pool of pools) {
const poolKey = pool.key ?? pool.state_key;
const scope = pool.scope ?? pool.state_scope;
const maximum = pool.max ?? pool.maximum_value;
const expiry = pool.expiresAfterSeconds ?? pool.duration_seconds;
lines.push(`STATE · ${shown(poolKey)} · ${readableToken(scope)} · MAX ${shown(maximum)}${expiry ? ` · EXPIRES ${expiry}s` : ''}`);
}
}
if (Array.isArray(clauses)) {
for (const [index, clause] of clauses.entries()) {
const event = clause.event ?? clause.event_kind;
const recipe = clause.recipeKey ?? clause.recipe_key;
const reducer = persisted
? storedJson(clause.reducer_json, { kind: 'none' })
: clause.reducer;
const mutations = persisted
? storedJson(clause.mutations_json, [])
: clause.mutations;
const branchOutcomes = persisted
? (wrapper.outcomes ?? []).filter((outcome: any) => outcome.clause_id === clause.id)
: clause.outcomes ?? [];
lines.push([
`BRANCH ${index + 1}`,
readableToken(event),
recipe ? `RECIPE ${readableToken(recipe)}` : '',
`REDUCER ${readableToken(reducer?.kind)}`,
clause.cooldownSeconds ?? clause.cooldown_seconds
? `${clause.cooldownSeconds ?? clause.cooldown_seconds}s COOLDOWN`
: '',
`${Array.isArray(mutations) ? mutations.length : 0} MUTATIONS`,
`${branchOutcomes.length} OUTCOMES`
].filter(Boolean).join(' · '));
for (const outcome of branchOutcomes) {
if (persisted) {
lines.push([
`↳ ${readableToken(outcome.variant_key)}`,
readableToken(outcome.application_kind),
readableToken(outcome.effect_scope),
outcome.stat_attribute_id || readableToken(outcome.resource_kind ?? outcome.value_basis),
`${shown(outcome.value)}${outcome.value_expression_json ? ' · EXPRESSION' : ''}`,
readableToken(outcome.application_phase)
].join(' · '));
continue;
}
const variants = outcome.variants && typeof outcome.variants === 'object'
? Object.entries(outcome.variants).map(([variant, configured]: [string, any]) =>
`${readableToken(variant)} ${shown(configured?.value)}${configured?.expression ? ' (EXPRESSION)' : ''}`
).join(' · ')
: 'NO VARIANTS';
lines.push([
`↳ ${shown(outcome.label ?? outcome.key)}`,
readableToken(outcome.kind),
readableToken(outcome.scope),
outcome.attributeId || readableToken(outcome.resource ?? outcome.basis),
variants,
readableToken(outcome.phase)
].join(' · '));
}
}
}
return lines;
};
</script>
{#snippet authoredSourceBlock(source: AuthoredSourcePreview | null)}
{#if source}
<section class="proposal-mechanic-section authored-source-preview">
<header><span>AUTHORED SOURCE · VERSION {shown(source.version)}</span><b>{source.variants.length} ORDERED VARIANTS</b></header>
<div class="proposal-source-template"><span>TEMPLATE</span><p>{source.template}</p></div>
{#if source.specialization}
<div class="proposal-source-identity">
<div><span>RANK DESCRIPTION</span><p>{shown(source.specialization.rankDescription)}</p></div>
<div><span>REUSABLE EFFECT ID</span><p>{shown(source.specialization.effectId)}</p></div>
<div><span>REUSABLE EFFECT TEMPLATE</span><p>{shown(source.specialization.effectTemplate)}</p></div>
</div>
{/if}
<div class="proposal-source-variants">
{#each source.variants as variant, variantIndex}
<article>
<header><span>VARIANT {variantIndex + 1} · {variant.key}</span><b>{variant.slots.length} SLOTS</b></header>
<p class="proposal-materialized-text">{variant.materialized || 'NOT MATERIALIZED'}</p>
{#each variant.slots as slot, slotIndex}
<small>SLOT {slotIndex + 1} · ORDER {slot.order} · {slot.key} = {shown(slot.value)}{slot.unit ? ` · UNIT ${slot.unit}` : ''}{slot.attributeId ? ` · ATTRIBUTE ${slot.attributeId}` : ''}</small>
{:else}<small>NO TEMPLATE SLOTS</small>{/each}
</article>
{:else}<p class="proposal-structured-empty">NO AUTHORED VARIANTS</p>{/each}
</div>
</section>
{/if}
{/snippet}
{#snippet mechanicStateBlock(state: MechanicPreviewState, heading: string)}
<section class="proposal-mechanic-section mechanic-state-preview">
<header><span>{heading}</span><b>{state.selectedRevision ? `REVISION ${shown(state.selectedRevision.revisionNumber)}` : state.modelDraft ? 'MODEL SUBMISSION' : 'NO MODEL SELECTED'}</b></header>
<div class="proposal-model-summary">
<div><span>SELECTION STATE</span><strong>{state.selectedRevision ? formatMechanicToken(state.selectedRevisionStatus) : state.modelDraft ? 'PENDING REVISION' : 'ABSENT'}</strong></div>
<div><span>READINESS</span><strong>{formatMechanicToken((state.selectedRevision ?? state.modelDraft)?.readiness)}</strong></div>
<div><span>SCENARIO INPUTS</span><strong>{(state.selectedRevision ?? state.modelDraft)?.scenarioInputs.length ?? 0}</strong></div>
<div><span>STATE POOLS</span><strong>{(state.selectedRevision ?? state.modelDraft)?.pools.length ?? 0}</strong></div>
<div><span>CLAUSES</span><strong>{(state.selectedRevision ?? state.modelDraft)?.clauses.length ?? 0}</strong></div>
</div>
{#if state.selectedRevision ?? state.modelDraft}
{@const model = (state.selectedRevision ?? state.modelDraft)!}
<div class="proposal-graph-summary">
<section><header><span>SCENARIO INPUTS</span><b>{model.scenarioInputs.length}</b></header>
{#each model.scenarioInputs as input}<p>INPUT · {input.label} · {formatMechanicToken(input.kind)} · {formatMechanicToken(input.readiness)}{input.min !== null || input.max !== null ? ` · RANGE ${shown(input.min)}–${shown(input.max)}` : ''}{input.defaultValue !== null ? ` · DEFAULT ${shown(input.defaultValue)}` : ''}</p>{:else}<p>NO SCENARIO INPUTS</p>{/each}
</section>
<section><header><span>STATE POOLS</span><b>{model.pools.length}</b></header>
{#each model.pools as pool}<p>POOL · {pool.key} · {pool.label} · KIND {formatMechanicToken(pool.stateKind)} · {formatMechanicToken(pool.scope)} · INITIAL {shown(pool.initial)} · RANGE {shown(pool.min)}–{shown(pool.max)}{pool.bestCaseValue !== null ? ` · BEST ${shown(pool.bestCaseValue)}` : ''}{pool.expiresAfterSeconds !== null ? ` · EXPIRES ${shown(pool.expiresAfterSeconds)}s` : ''}</p>{:else}<p>NO STATE POOLS</p>{/each}
</section>
<section><header><span>CLAUSE GRAPH</span><b>{model.clauses.length}</b></header>
{#each model.clauses as clause, clauseIndex}
<article class="proposal-clause">
<p><b>CLAUSE {clauseIndex + 1} · {clause.label}</b> · EVENT {formatMechanicToken(clause.event)}{clause.recipeKey ? ` · RECIPE ${formatMechanicToken(clause.recipeKey)}` : ''} · REDUCER {formatMechanicToken((clause.reducer as any)?.kind)}{clause.cooldownSeconds !== null ? ` · COOLDOWN ${shown(clause.cooldownSeconds)}s` : ''}</p>
<small>CONDITION · {formatMechanicValue(clause.condition)} · MUTATIONS {clause.mutations.length}</small>
{#each clause.mutations as mutation, mutationIndex}<small>MUTATION {mutationIndex + 1} · {formatMechanicValue(mutation)}</small>{/each}
{#each clause.outcomes as outcome, outcomeIndex}
<div class="proposal-outcome"><small>OUTCOME {outcomeIndex + 1} · {outcome.label} · {formatMechanicToken(outcome.kind)} · {formatMechanicToken(outcome.scope)} · {formatMechanicToken(outcome.phase)} · BASIS {formatMechanicToken(outcome.basis)}{outcome.attributeId ? ` · ATTRIBUTE ${outcome.attributeId}` : ''}{outcome.statePool ? ` · POOL ${outcome.statePool}` : ''}{outcome.resource ? ` · RESOURCE ${formatMechanicToken(outcome.resource)}` : ''}{outcome.perStack !== null ? ` · PER STACK ${outcome.perStack ? 'YES' : 'NO'}` : ''}</small>{#each outcome.variants as variant}<small>VARIANT {variant.key} · VALUE {formatMechanicValue(variant.value)}{variant.expression !== null ? ` · EXPRESSION ${formatMechanicValue(variant.expression)}` : ''}</small>{/each}</div>
{/each}
</article>
{:else}<p>NO CLAUSES</p>{/each}
</section>
</div>
{:else}<p class="proposal-structured-empty">NO IMMUTABLE MODEL REVISION IS SELECTED.</p>{/if}
</section>
{/snippet}
{#snippet projectionBlock(state: MechanicPreviewState)}
<section class="proposal-mechanic-section proposal-projection-preview">
<header><span>PUBLIC PROJECTION</span><b>{formatMechanicToken(state.projection.status)}</b></header>
{#if state.projection.variants.length}
<p class="proposal-projection-meta">PROJECTION SOURCE · {state.selectedRevision ? `REVISION ${shown(state.selectedRevision.revisionNumber)}` : 'AUTHORED FALLBACK'}</p>
{#each state.projection.variants as variant}<p class="proposal-projection-variant"><b>{variant.key}</b><span>STATUS {formatMechanicToken(variant.status)} · DISPLAY SOURCE {formatMechanicToken(variant.displaySource)}</span><strong>{variant.text || 'NO PUBLIC TEXT'}</strong></p>{/each}
{:else}<p class="proposal-structured-empty">NO PUBLIC PROJECTION AVAILABLE.</p>{/if}
</section>
{/snippet}
{#snippet mechanicBlocks(state: MechanicPreviewState, heading: string)}
{@render authoredSourceBlock(state.authoredSource)}
{@render mechanicStateBlock(state, heading)}
{@render projectionBlock(state)}
{/snippet}
{#snippet attachmentBlock(attachments: AttachmentPreview[], heading: string)}
<section class="proposal-mechanic-section attachment-preview">
<header><span>{heading}</span><b>{attachments.length} ATTACHMENTS</b></header>
{#each attachments as attachment, attachmentIndex}
<article>
<header><span>ATTACHMENT {attachmentIndex + 1} · {attachment.attachmentId}</span><b>RANK {shown(attachment.rank)} · ORDER {shown(attachment.effectOrder)}</b></header>
<p>NODE ID · {shown(attachment.nodeId)} · COMBAT EFFECT ID · {shown(attachment.effectId)}</p>
{@render mechanicBlocks(attachment.mechanic, 'ATTACHMENT MECHANIC')}
</article>
{:else}<p class="proposal-structured-empty">NO ATTACHMENTS</p>{/each}
</section>
{/snippet}
<section class="proposal-change-preview" aria-label="Proposed data comparison">
<div class="proposal-preview-head">
<div><span>CHANGE PREVIEW</span><small>LIVE REGISTRY COMPARED WITH CONTRIBUTOR SUBMISSION</small></div>
@@ -256,7 +329,7 @@
{#each [['type','TYPE'],['category','CATEGORY'],['calculation_type','CALCULATION'],['unit','UNIT']] as field}
<div class="preview-field"><span>{field[1]}</span><strong>{shown(attributeValue('current', field[0]))}</strong></div>
{/each}
{#each [['gear','GEAR'],['weapon','WEAPON'],['skill_chip','SKILL CHIP']] as source}
{#each [['gear','GEAR'],['weapon','WEAPON']] as source}
<div class="preview-field range"><span>{source[1]} RANGE · OPTIONAL</span><div><strong>{shown(attributeValue('current', `${source[0]}_min`))}</strong><i>TO</i><strong>{shown(attributeValue('current', `${source[0]}_max`))}</strong></div></div>
{/each}
<div class="preview-field"><span>OPTIONAL CAP</span><strong>{shown(attributeValue('current', 'cap_value'))}</strong></div>
@@ -273,40 +346,58 @@
{#each [['type','TYPE'],['category','CATEGORY'],['calculation_type','CALCULATION'],['unit','UNIT']] as field}
<div class="preview-field"><span>{field[1]}</span><strong>{shown(attributeValue('proposed', field[0]))}</strong></div>
{/each}
{#each [['gear','GEAR'],['weapon','WEAPON'],['skill_chip','SKILL CHIP']] as source}
{#each [['gear','GEAR'],['weapon','WEAPON']] as source}
<div class="preview-field range"><span>{source[1]} RANGE · OPTIONAL</span><div><strong>{shown(attributeValue('proposed', `${source[0]}_min`))}</strong><i>TO</i><strong>{shown(attributeValue('proposed', `${source[0]}_max`))}</strong></div></div>
{/each}
<div class="preview-field"><span>OPTIONAL CAP</span><strong>{shown(attributeValue('proposed', 'cap_value'))}</strong></div>
</div>
</article>
</div>
{:else if module === 'skillchips' && action === 'save'}
<div class="proposal-compare-grid">
{:else if phase2OwnerPreview}
<div class="proposal-compare-grid phase2-owner-preview">
<article class="proposal-editor-preview before">
<header><span>CURRENT LIVE · SKILL CHIP</span><b>{skillChipRecord ? 'BEFORE' : 'NO LIVE RECORD'}</b></header>
{#if skillChipRecord}
<div class="proposal-field-summary">
{#each skillChipFields as field}<div><span>{field.label}</span><strong>{shown(field.current)}</strong></div>{/each}
<div><span>ROLL POOLS</span><strong>{(currentSnapshot?.primaryOptions as any[])?.length ?? 0} PRIMARY · {(currentSnapshot?.secondaryOptions as any[])?.length ?? 0} SECONDARY</strong></div>
</div>
<div class="proposal-structured-list">
<header><span>3-PIECE CONDITIONAL MECHANIC</span><b>TYPED RULE</b></header>
{#each conditionalRuleLines(currentSnapshot?.conditional) as line}<p>{line}</p>{:else}<p>NO TYPED RULE CONFIGURED</p>{/each}
</div>
{:else}
<div class="proposal-empty-state"><b>NEW SKILL CHIP</b><span>No live registry record will be replaced.</span></div>
{/if}
<header><span>CURRENT LIVE · {phase2OwnerPreview.family.toUpperCase()}</span><b>{phase2HasCurrentTarget ? 'BEFORE' : 'NO LIVE RECORD'}</b></header>
{#if phase2HasCurrentTarget}
<div class="proposal-field-summary owner-identity-fields">{#each ownerIdentityFields('current', phase2OwnerPreview.family) as field}<div><span>{field.label}</span><strong>{shown(field.value)}</strong></div>{/each}</div>
<div class="proposal-relationships"><header><span>RELATIONSHIPS</span><b>LIVE</b></header>{#each ownerRelationshipRows('current', phase2OwnerPreview.family) as line}<p>{line}</p>{/each}</div>
{#if ['talent','protocol-talent','skill-chip-talent'].includes(phase2OwnerPreview.family)}
{@render mechanicBlocks(phase2OwnerPreview.currentMechanic, 'MECHANIC MODEL')}
{:else}
{#if phase2OwnerPreview.family === 'specialization-node'}
<div class="proposal-rank-preview">
{#each currentNodeRanks as rank}<section><header><span>RANK {rank.rank}</span><b>{currentNodeEffects.filter((effect) => Number(effect.rank) === Number(rank.rank)).length} STATIC EFFECTS</b></header><p>{shown(rank.description)}</p>{#each currentNodeEffects.filter((effect) => Number(effect.rank) === Number(rank.rank)) as effect}<small>{effectSummary(effect)}</small>{/each}<small>{currentNodeCosts.filter((cost) => Number(cost.rank) === Number(rank.rank)).map((cost) => `${cost.resource_id} × ${cost.quantity}`).join(' · ') || 'CREDITS ONLY'}</small></section>{:else}<p class="proposal-structured-empty">NO RANK DEFINITIONS</p>{/each}
</div>
{/if}
{@render attachmentBlock(phase2OwnerPreview.currentAttachments, phase2OwnerPreview.family === 'combat-effect' ? 'REUSABLE EFFECT FAN-OUT' : 'NODE ATTACHMENTS')}
{/if}
{:else}<div class="proposal-empty-state"><b>NO LIVE {phase2OwnerPreview.family.toUpperCase()} RECORD</b><span>The proposed owner state will be created on approval.</span></div>{/if}
</article>
<article class="proposal-editor-preview after">
<header><span>PROPOSED · SKILL CHIP</span><b>AFTER</b></header>
<div class="proposal-field-summary">
{#each skillChipFields as field}<div><span>{field.label}</span><strong>{shown(field.proposed)}</strong></div>{/each}
<div><span>ROLL POOLS</span><strong>{Array.isArray(proposed.primaryStatIds) ? proposed.primaryStatIds.length : Number(Boolean(proposed.primaryStatIds))} PRIMARY · {Array.isArray(proposed.secondaryStatIds) ? proposed.secondaryStatIds.length : Number(Boolean(proposed.secondaryStatIds))} SECONDARY</strong></div>
</div>
<div class="proposal-structured-list">
<header><span>3-PIECE CONDITIONAL MECHANIC</span><b>TYPED RULE</b></header>
{#each conditionalRuleLines(jsonValue(proposed.conditionalRule)) as line}<p>{line}</p>{:else}<p>NO TYPED RULE CONFIGURED</p>{/each}
</div>
<header><span>PROPOSED · {phase2OwnerPreview.family.toUpperCase()}</span><b>AFTER</b></header>
<div class="proposal-field-summary owner-identity-fields">{#each ownerIdentityFields('proposed', phase2OwnerPreview.family) as field}<div><span>{field.label}</span><strong>{shown(field.value)}</strong></div>{/each}</div>
<div class="proposal-relationships"><header><span>RELATIONSHIPS</span><b>PROPOSED</b></header>{#each ownerRelationshipRows('proposed', phase2OwnerPreview.family) as line}<p>{line}</p>{/each}</div>
{#if phase2OwnerPreview.selectionIntent}<p class="proposal-selection-intent">MODEL REQUEST · {formatMechanicToken(phase2OwnerPreview.selectionIntent)}</p>{/if}
{#if ['talent','protocol-talent','skill-chip-talent'].includes(phase2OwnerPreview.family)}
{@render mechanicBlocks(phase2OwnerPreview.proposedMechanic, 'MECHANIC MODEL AFTER APPROVAL')}
{:else}
{#if phase2OwnerPreview.family === 'specialization-node'}
<div class="proposal-rank-preview">
{#each proposedRanks as rank}<section><header><span>RANK {rank.rank}</span><b>{rank.effects?.length ?? 0} STATIC EFFECTS</b></header><p>{shown(rank.description)}</p>{#each rank.effects ?? [] as effect}<small>{effectSummary(effect)}</small>{/each}<small>{Object.entries(rank.costs ?? {}).filter(([, quantity]) => Number(quantity) > 0).map(([resource, quantity]) => `${resource} × ${quantity}`).join(' · ') || 'CREDITS ONLY'}</small></section>{:else}<p class="proposal-structured-empty">NO RANK DEFINITIONS</p>{/each}
</div>
{/if}
{@render attachmentBlock(phase2OwnerPreview.proposedAttachments, phase2OwnerPreview.family === 'combat-effect' ? 'REUSABLE EFFECT FAN-OUT AFTER APPROVAL' : 'NODE ATTACHMENTS AFTER APPROVAL')}
{/if}
</article>
</div>
{:else if module === 'skillchips' && ['savePrimaryProfile','saveSecondaryPool'].includes(action)}
<div class="proposal-compare-grid">
<article class="proposal-editor-preview before">
<header><span>CURRENT LIVE · SHARED {action === 'savePrimaryProfile' ? 'PRIMARY PROFILE' : 'SECONDARY POOL'}</span><b>BEFORE</b></header>
<div class="proposal-structured-list">{#each currentSharedSkillChipProfiles as profile}<p>{profile.statId} · {Object.entries(profile.values ?? {}).map(([quality, range]: [string, any]) => `${quality.toUpperCase()} ${shown(range.min)}–${shown(range.max)}`).join(' · ')}</p>{:else}<p>SHARED PROFILE NOT CONFIGURED</p>{/each}</div>
</article>
<article class="proposal-editor-preview after">
<header><span>PROPOSED · SHARED {action === 'savePrimaryProfile' ? 'PRIMARY PROFILE' : 'SECONDARY POOL'}</span><b>AFTER</b></header>
<div class="proposal-structured-list">{#each proposedSharedSkillChipProfiles as profile}<p>{profile.statId} · {Object.entries(profile.values ?? {}).map(([quality, range]: [string, any]) => `${quality.toUpperCase()} ${shown(range.min)}–${shown(range.max)}`).join(' · ')}</p>{/each}</div>
</article>
</div>
{:else if module === 'calculations'}
@@ -321,7 +412,7 @@
{#if action === 'saveRevision'}<div class="proposal-rank-preview">{#each proposedCalculationStages as stage}<section><header><span>{stage.label}</span><b>{stage.operator?.replaceAll('_',' ').toUpperCase()}</b></header><p>ORDER {stage.displayOrder} · {stage.lockedPosition?.toUpperCase() ?? 'REORDERABLE'}</p></section>{/each}</div>{/if}
</article>
</div>
{:else if module === 'specializations' && ['saveProgression','saveSpecialization','saveFocus','saveAbility','saveCombatEffect','saveFocusAbilities','saveNode'].includes(action)}
{:else if module === 'specializations' && ['saveProgression','saveSpecialization','saveFocus','saveAbility','saveFocusAbilities'].includes(action)}
<div class="proposal-compare-grid specialization-proposal-preview">
<article class="proposal-editor-preview before">
<header><span>CURRENT LIVE · {specializationKind}</span><b>{specializationRecord ? 'BEFORE' : 'NO LIVE RECORD'}</b></header>
@@ -340,12 +431,6 @@
<header><span>INTRINSIC ATTACK PROFILE</span><b>{currentAbilityAttack ? 'CONFIGURED' : 'NONE'}</b></header>
{#each attackProfileLines(currentAbilityAttack) as line}<p>{line}</p>{:else}<p>NO INTRINSIC ATTACK PROFILE</p>{/each}
</div>
{:else if action === 'saveNode'}
<div class="proposal-rank-preview">
{#each currentNodeRanks as rank}
<section><header><span>RANK {rank.rank}</span><b>{currentNodeEffects.filter((effect) => Number(effect.rank) === Number(rank.rank)).length} EFFECTS</b></header><p>{rank.description}</p><small>{currentNodeCosts.filter((cost) => Number(cost.rank) === Number(rank.rank)).map((cost) => `${cost.resource_id} × ${cost.quantity}`).join(' · ') || 'CREDITS ONLY'}</small></section>
{/each}
</div>
{:else if action === 'saveProgression'}
<div class="proposal-rank-preview">
{#each currentRankProfiles as profile}
@@ -373,18 +458,6 @@
<header><span>INTRINSIC ATTACK PROFILE</span><b>{attackProfileLines(proposedAbilityAttack).length ? 'CONFIGURED' : 'NONE'}</b></header>
{#each attackProfileLines(proposedAbilityAttack) as line}<p>{line}</p>{:else}<p>NO INTRINSIC ATTACK PROFILE</p>{/each}
</div>
{:else if action === 'saveNode'}
<div class="proposal-rank-preview">
{#each proposedRanks as rank}
<section>
<header><span>RANK {rank.rank}</span><b>{rank.effects?.length ?? 0} STATIC · {rank.combatEffects?.length ?? 0} CONDITIONAL</b></header>
<p>{rank.description || 'NO RANK DESCRIPTION'}</p>
{#each rank.effects ?? [] as effect}<small>{effectSummary(effect)}</small>{/each}
{#each rank.combatEffects ?? [] as effect}<small>{conditionalRuleSummary(effect)}</small>{/each}
<small>{Object.entries(rank.costs ?? {}).filter(([, quantity]) => Number(quantity) > 0).map(([resource, quantity]) => `${resource} × ${quantity}`).join(' · ') || 'CREDITS ONLY'}</small>
</section>
{/each}
</div>
{:else if action === 'saveProgression'}
<div class="proposal-rank-preview">
{#each [1,2,3] as rank}
@@ -399,19 +472,94 @@
<header><span>PROPOSED {label(module)} RECORD</span><b>AFTER</b></header>
<div class="proposal-field-summary">
{#each entries as entry}
{@const safeValue = entry.kind === 'text' ? safePreviewValue(entry.name, entry.value) : ''}
{#if !isPreviewFieldHidden(entry.name)}
<div>
<span>{label(entry.name)}</span>
{#if entry.kind === 'file'}
<strong>{entry.fileName || 'UPLOADED FILE'}</strong><small>{entry.type || 'FILE EVIDENCE'}</small>
{:else if jsonValue(entry.value)}
<strong>{Array.isArray(jsonValue(entry.value)) ? `${jsonValue(entry.value).length} CONFIGURED ITEMS` : 'CONFIGURED DATA'}</strong>
<small>{entry.value}</small>
{:else if jsonValue(safeValue)}
<strong>{Array.isArray(jsonValue(safeValue)) ? `${jsonValue(safeValue).length} CONFIGURED ITEMS` : 'CONFIGURED DATA'}</strong>
<small>{safeValue}</small>
{:else}
<strong>{shown(entry.value)}</strong>
<strong>{shown(safeValue)}</strong>
{/if}
</div>
{/if}
{/each}
</div>
</div>
{/if}
</section>
<style>
.phase2-owner-preview { align-items: start; }
.owner-identity-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.proposal-relationships,
.proposal-mechanic-section { display: grid; gap: 5px; padding: 0 8px 8px; }
.proposal-relationships > header,
.proposal-mechanic-section > header,
.proposal-source-variants article > header,
.attachment-preview article > header { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 29px; padding: 6px 8px; border: 1px solid var(--shd-border); background: #111415; }
.proposal-relationships > header span,
.proposal-relationships > header b,
.proposal-mechanic-section > header span,
.proposal-mechanic-section > header b,
.proposal-source-variants article > header span,
.proposal-source-variants article > header b,
.attachment-preview article > header span,
.attachment-preview article > header b { font-size: var(--shd-type-telemetry); letter-spacing: .65px; }
.proposal-relationships > header b,
.proposal-mechanic-section > header b,
.proposal-source-variants article > header b,
.attachment-preview article > header b { color: var(--shd-orange); }
.proposal-relationships p,
.proposal-selection-intent,
.proposal-mechanic-section > p,
.proposal-mechanic-section > article > p,
.proposal-source-variants article > small,
.attachment-preview article > p { margin: 0; padding: 6px 8px; border: 1px solid var(--shd-border); color: var(--shd-text-muted); font-size: var(--shd-type-telemetry); overflow-wrap: anywhere; }
.proposal-source-template { display: grid; gap: 4px; padding: 8px; border: 1px solid var(--shd-border); background: #0d0f10; }
.proposal-source-identity { display: grid; gap: 5px; grid-template-columns: repeat(3, minmax(0, 1fr)); }
.proposal-source-identity > div { display: grid; gap: 4px; min-width: 0; padding: 7px 8px; border: 1px solid var(--shd-border); background: #0d0f10; }
.proposal-source-identity span { color: var(--shd-text-muted); font-size: var(--shd-type-telemetry); letter-spacing: .7px; }
.proposal-source-identity p { margin: 0; color: var(--shd-text); font-size: var(--shd-type-supporting); line-height: 1.4; overflow-wrap: anywhere; white-space: pre-wrap; }
.proposal-source-template span,
.proposal-model-summary span,
.proposal-projection-meta,
.proposal-projection-variant span { color: var(--shd-text-muted); font-size: var(--shd-type-telemetry); letter-spacing: .7px; }
.proposal-source-template p,
.proposal-materialized-text { margin: 0; color: var(--shd-text); font-size: var(--shd-type-body); line-height: 1.4; overflow-wrap: anywhere; white-space: pre-wrap; }
.proposal-source-variants,
.proposal-graph-summary { display: grid; gap: 5px; }
.proposal-source-variants article { display: grid; gap: 4px; padding: 5px; border: 1px solid var(--shd-border); background: #0d1011; }
.proposal-source-variants article > small { background: #111415; }
.proposal-model-summary { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 5px; }
.proposal-model-summary > div { display: grid; gap: 4px; min-width: 0; padding: 7px; border: 1px solid var(--shd-border); background: #0d0f10; }
.proposal-model-summary strong { color: var(--shd-text); font-size: var(--shd-type-telemetry); overflow-wrap: anywhere; }
.proposal-graph-summary > section { display: grid; gap: 4px; }
.proposal-graph-summary > section > header { display: flex; justify-content: space-between; gap: 8px; padding: 6px 8px; border: 1px solid var(--shd-border); background: #111415; }
.proposal-graph-summary > section > header span,
.proposal-graph-summary > section > header b { font-size: var(--shd-type-telemetry); letter-spacing: .65px; }
.proposal-graph-summary > section > header b { color: var(--shd-orange); }
.proposal-graph-summary > section > p { margin: 0; padding: 6px 8px; border: 1px solid var(--shd-border); color: var(--shd-text-muted); font-size: var(--shd-type-telemetry); overflow-wrap: anywhere; }
.proposal-clause { display: grid; gap: 3px; padding: 5px; border: 1px solid var(--shd-border); background: #0d1011; }
.proposal-clause > p,
.proposal-clause > small,
.proposal-outcome > small { margin: 0; padding: 5px 6px; border: 1px solid var(--shd-border); color: var(--shd-text-muted); font-size: var(--shd-type-telemetry); overflow-wrap: anywhere; }
.proposal-clause > p { color: var(--shd-text); }
.proposal-outcome { display: grid; gap: 3px; margin-left: 8px; }
.proposal-projection-preview { gap: 5px; }
.proposal-projection-meta { margin: 0; padding: 0 2px; }
.proposal-projection-variant { display: grid; grid-template-columns: 42px 1fr; gap: 5px; margin: 0; padding: 6px; border: 1px solid var(--shd-border); background: #0d0f10; }
.proposal-projection-variant > b { color: var(--shd-orange); font-size: var(--shd-type-telemetry); }
.proposal-projection-variant > strong { grid-column: 1 / -1; color: var(--shd-text); font-size: var(--shd-type-supporting); line-height: 1.4; overflow-wrap: anywhere; }
.attachment-preview { padding-top: 0; }
.attachment-preview > article { display: grid; gap: 5px; padding: 5px; border: 1px solid var(--shd-border); background: #0d1011; }
.attachment-preview > article .proposal-mechanic-section { padding-right: 0; padding-left: 0; }
.proposal-structured-empty { margin: 0; padding: 8px; border: 1px dashed var(--shd-border); color: var(--shd-text-muted); font-size: var(--shd-type-telemetry); text-align: center; }
@media (max-width: 800px) {
.owner-identity-fields, .proposal-model-summary, .proposal-source-identity { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.proposal-model-summary > div:last-child { grid-column: span 2; }
}
</style>
@@ -0,0 +1,128 @@
<script lang="ts">
import PendingForm from '$lib/components/PendingForm.svelte';
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
import {
SKILL_CHIP_QUALITIES,
SKILL_CHIP_SECONDARY_CAPACITY,
SKILL_CHIP_SECONDARY_QUALITIES,
type SkillChipProfile
} from '$lib/skill-chips/profiles';
let {
primaryProfiles,
primaryProfileBase,
secondaryPool,
stats,
primaryError,
secondaryError,
primarySaved = false,
secondarySaved = false,
resultToken = ''
}: {
primaryProfiles: SkillChipProfile[];
primaryProfileBase: SkillChipProfile[];
secondaryPool: SkillChipProfile[];
stats: Array<{ id: string; name: string; unit: string }>;
primaryError?: string;
secondaryError?: string;
primarySaved?: boolean;
secondarySaved?: boolean;
resultToken?: string;
} = $props();
const qualityClass = (quality: string) => `quality-${quality.toLowerCase().replace('-', '')}`;
const blankValues = (qualities: readonly string[]) => Object.fromEntries(
qualities.map((quality) => [quality, { min: '', max: '' }])
);
const hydrated = (profile: SkillChipProfile, qualities: readonly string[]) => ({
statId: profile.statId,
values: Object.fromEntries(qualities.map((quality) => [quality, {
min: profile.values[quality]?.min ?? '',
max: profile.values[quality]?.max ?? ''
}]))
});
const blankSecondary = () => ({ statId: '', values: blankValues(SKILL_CHIP_SECONDARY_QUALITIES) });
let primaryEditing = $state(false);
let secondaryEditing = $state(false);
let primaryDraft = $state<any[]>([]);
let secondaryDraft = $state<any[]>([]);
let handledPrimaryToken = $state('');
let handledSecondaryToken = $state('');
function openPrimary() {
primaryDraft = primaryProfiles.map((profile) => hydrated(profile, SKILL_CHIP_QUALITIES));
secondaryEditing = false;
primaryEditing = true;
}
function openSecondary() {
const loaded: any[] = secondaryPool.map((profile) =>
hydrated(profile, SKILL_CHIP_SECONDARY_QUALITIES)
);
while (loaded.length < 2) loaded.push(blankSecondary());
secondaryDraft = loaded;
primaryEditing = false;
secondaryEditing = true;
}
$effect(() => {
if (primarySaved && resultToken && resultToken !== handledPrimaryToken) {
handledPrimaryToken = resultToken;
primaryEditing = false;
}
if (secondarySaved && resultToken && resultToken !== handledSecondaryToken) {
handledSecondaryToken = resultToken;
secondaryEditing = false;
}
});
</script>
<div class="skill-chip-shared-config">
<section class="mod-secondary-pool-registry">
<div class="selection-head"><div><span>SHARED PRIMARY PROFILE</span><small>FOUR ABILITY-SCOPED STATS · ONE RANGE PER QUALITY</small></div><button type="button" class="row-action" onclick={openPrimary}>EDIT PRIMARY RANGES →</button></div>
<div class="mod-secondary-capacity">
{#each SKILL_CHIP_QUALITIES as quality}<span><b class={qualityClass(quality)}>{quality.toUpperCase()}</b><strong>{primaryProfiles.filter((profile) => profile.values[quality]).length}</strong><small>OF 4 CONFIGURED</small></span>{/each}
<span class="pool-total"><b>CALCULATION</b><strong>—</strong><small>NOT YET DEFINED</small></span>
</div>
</section>
<section class="mod-secondary-pool-registry">
<div class="selection-head"><div><span>SHARED SECONDARY POOL</span><small>ONE CANONICAL ATTRIBUTE POOL FOR EVERY SKILL CHIP</small></div><button type="button" class="row-action" onclick={openSecondary}>EDIT SHARED POOL →</button></div>
<div class="mod-secondary-capacity">
{#each SKILL_CHIP_QUALITIES as quality}<span><b class={qualityClass(quality)}>{quality.toUpperCase()}</b><strong>{SKILL_CHIP_SECONDARY_CAPACITY[quality]}</strong><small>SECONDARY {SKILL_CHIP_SECONDARY_CAPACITY[quality] === 1 ? 'ROLL' : 'ROLLS'}</small></span>{/each}
<span class="pool-total"><b>ATTRIBUTE POOL</b><strong>{secondaryPool.length}</strong><small>SHARED OPTIONS</small></span>
</div>
</section>
</div>
{#if primaryEditing}
<div class="dashboard-editor skill-chip-profile-editor">
<div class="editor-title"><div><p class="eyebrow">SHARED CONFIGURATION</p><h3>SKILL CHIP PRIMARY RANGES</h3></div><button aria-label="Close primary profile editor" onclick={() => primaryEditing = false}>×</button></div>
<PendingForm action="?/savePrimaryProfile">
<input type="hidden" name="id" value="skill-chip-primary-profile"/><input type="hidden" name="name" value="Shared Skill Chip Primary Profile"/><input type="hidden" name="baseProfiles" value={JSON.stringify(primaryProfileBase)}/><input type="hidden" name="profiles" value={JSON.stringify(primaryDraft)}/>
<p class="console-note">Every Skill Chip rolls one of these four ability-scoped primary stats. Configure the direct minimum and maximum for each quality. Current combat calculations do not apply these values.</p>
<div class="skill-chip-profile-options fixed">
{#each primaryDraft as profile}
<div class="skill-chip-profile-option"><div><strong>{stats.find((item) => item.id === profile.statId)?.name ?? 'UNKNOWN ATTRIBUTE'}</strong><small>{stats.find((item) => item.id === profile.statId)?.unit || 'FLAT'} · ABILITY SCOPED</small></div>{#each SKILL_CHIP_QUALITIES as quality}<label><b class={qualityClass(quality)}>{quality.toUpperCase()}</b><span><input type="number" min="0" step="any" bind:value={profile.values[quality].min} aria-label={`${quality} ${stats.find((item) => item.id === profile.statId)?.name ?? 'attribute'} minimum`} required/><i>–</i><input type="number" min="0" step="any" bind:value={profile.values[quality].max} aria-label={`${quality} ${stats.find((item) => item.id === profile.statId)?.name ?? 'attribute'} maximum`} required/></span></label>{/each}</div>
{/each}
</div>
{#if primaryError}<p class="error">{primaryError}</p>{/if}
<div class="form-actions"><button type="button" class="secondary" onclick={() => primaryEditing = false}>CANCEL</button><button class="primary">SAVE PRIMARY PROFILE →</button></div>
</PendingForm>
</div>
{/if}
{#if secondaryEditing}
<div class="dashboard-editor skill-chip-profile-editor">
<div class="editor-title"><div><p class="eyebrow">SHARED CONFIGURATION</p><h3>SKILL CHIP SECONDARY POOL</h3></div><button aria-label="Close secondary pool editor" onclick={() => secondaryEditing = false}>×</button></div>
<PendingForm action="?/saveSecondaryPool">
<input type="hidden" name="id" value="skill-chip-secondary-pool"/><input type="hidden" name="name" value="Shared Skill Chip Secondary Pool"/><input type="hidden" name="baseProfiles" value={JSON.stringify(secondaryPool)}/><input type="hidden" name="profiles" value={JSON.stringify(secondaryDraft)}/>
<p class="console-note">Standard has no secondary roll. Specialized selects one option; Superior and High-end select two distinct options. Skill Chips have no item level, so each quality uses one direct range.</p>
<div class="selection-head pool-editor-head"><div><span>AVAILABLE ATTRIBUTES</span><small>SPECIALIZED · SUPERIOR · HIGH-END RANGES</small></div><button type="button" class="row-action" onclick={() => secondaryDraft = [...secondaryDraft, blankSecondary()]} disabled={secondaryDraft.length >= 50}>ADD ATTRIBUTE +</button></div>
<div class="skill-chip-profile-options">
{#each secondaryDraft as profile, index}
<div class="skill-chip-profile-option selectable"><strong>{String(index + 1).padStart(2, '0')}</strong><SearchableAssignment items={stats} mode="single" compact subjectLabel="POSSIBLE ATTRIBUTE" searchPlaceholder="Search attributes…" excludedIds={secondaryDraft.filter((_, itemIndex) => itemIndex !== index).map((item) => item.statId)} bind:value={profile.statId}/>{#each SKILL_CHIP_SECONDARY_QUALITIES as quality}<label><b class={qualityClass(quality)}>{quality.toUpperCase()}</b><span><input type="number" min="0" step="any" bind:value={profile.values[quality].min} aria-label={`${quality} ${stats.find((item) => item.id === profile.statId)?.name ?? `option ${index + 1}`} minimum`} required/><i>–</i><input type="number" min="0" step="any" bind:value={profile.values[quality].max} aria-label={`${quality} ${stats.find((item) => item.id === profile.statId)?.name ?? `option ${index + 1}`} maximum`} required/></span></label>{/each}<button type="button" aria-label={`Remove secondary option ${index + 1}`} onclick={() => secondaryDraft = secondaryDraft.filter((_, itemIndex) => itemIndex !== index)} disabled={secondaryDraft.length <= 2}>×</button></div>
{/each}
</div>
{#if secondaryError}<p class="error">{secondaryError}</p>{/if}
<div class="form-actions"><button type="button" class="secondary" onclick={() => secondaryEditing = false}>CANCEL</button><button class="primary">SAVE SHARED POOL →</button></div>
</PendingForm>
</div>
{/if}
@@ -0,0 +1,29 @@
<script lang="ts">
let {
slot,
iconUrl,
decorative = true,
size = 24
}: { slot: string; iconUrl?: string | null; decorative?: boolean; size?: number } = $props();
</script>
{#if iconUrl}
<img
class="weapon-mod-slot-icon"
src={iconUrl}
alt={decorative ? '' : `${slot} Weapon Mod slot`}
loading="lazy"
width={size}
height={size}
style={`--weapon-mod-slot-icon-size: ${size}px`}
/>
{/if}
<style>
img {
width: var(--weapon-mod-slot-icon-size);
height: var(--weapon-mod-slot-icon-size);
flex: 0 0 var(--weapon-mod-slot-icon-size);
object-fit: contain;
}
</style>
@@ -0,0 +1,212 @@
<script lang="ts">
import IconUploadField from '$lib/components/IconUploadField.svelte';
import PendingForm from '$lib/components/PendingForm.svelte';
import WeaponModSlotIcon from '$lib/components/WeaponModSlotIcon.svelte';
type SlotIcon = {
id: string;
name: string;
iconUrl: string;
updatedAt: string;
};
let {
slots,
error = '',
saved = false,
resultToken = ''
}: {
slots: SlotIcon[];
error?: string;
saved?: boolean;
resultToken?: string;
} = $props();
let editing = $state<SlotIcon | null>(null);
let handledResultToken = $state('');
$effect(() => {
if (saved && resultToken && resultToken !== handledResultToken) {
handledResultToken = resultToken;
editing = null;
}
});
</script>
<section class="slot-icon-registry" aria-labelledby="slot-icon-registry-title">
<div class="selection-head">
<div>
<span id="slot-icon-registry-title">SHARED MOD SLOT ICONS</span>
<small>ONE CANONICAL ICON FOR EACH WEAPON MOD SLOT</small>
</div>
<b>{slots.every((slot) => slot.iconUrl) ? 'READY' : 'CONFIG REQUIRED'}</b>
</div>
<div class="slot-icon-grid">
{#each slots as slot}
<button
type="button"
class:configured={Boolean(slot.iconUrl)}
aria-pressed={editing?.id === slot.id}
onclick={() => editing = slot}
>
<span class="slot-icon-preview">
{#if slot.iconUrl}
<WeaponModSlotIcon slot={slot.name} iconUrl={slot.iconUrl}/>
{:else}
<i aria-hidden="true">?</i>
{/if}
</span>
<span><strong>{slot.name}</strong><small>{slot.iconUrl ? 'ICON REGISTERED' : 'ICON REQUIRED'}</small></span>
<b>EDIT</b>
</button>
{/each}
</div>
</section>
{#if editing}
<section class="dashboard-editor slot-icon-editor" aria-labelledby="slot-icon-editor-title">
<div class="editor-title">
<div><p class="eyebrow">SHARED MOD SLOT ARTWORK</p><h3 id="slot-icon-editor-title">{editing.name.toUpperCase()}</h3></div>
<button aria-label="Close slot icon editor" onclick={() => editing = null}>×</button>
</div>
<PendingForm action="?/saveSlotIcon" enctype="multipart/form-data">
<input type="hidden" name="id" value={editing.id}/>
<input type="hidden" name="name" value={`${editing.name} Slot`}/>
<input type="hidden" name="slotType" value={editing.name}/>
<input type="hidden" name="baseUpdatedAt" value={editing.updatedAt}/>
<div class="slot-icon-editor-body">
<div class="slot-icon-editor-identity">
<span class="slot-icon-preview large">
{#if editing.iconUrl}
<WeaponModSlotIcon slot={editing.name} iconUrl={editing.iconUrl} size={56}/>
{:else}
<i aria-hidden="true">?</i>
{/if}
</span>
<div><small>CANONICAL SLOT</small><strong>{editing.name}</strong><p>Weapon cards use this artwork anywhere the slot is available.</p></div>
</div>
<IconUploadField currentUrl={editing.iconUrl} label={`${editing.name.toUpperCase()} SLOT ICON`} resetToken={`${editing.id}:${editing.updatedAt}`}/>
</div>
{#if error}<p class="error" role="alert">{error}</p>{/if}
<div class="form-actions"><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary">SAVE SLOT ICON →</button></div>
</PendingForm>
</section>
{/if}
<style>
.slot-icon-registry {
margin: 10px 0;
padding: 10px;
border: 1px solid var(--shd-border);
border-left: 2px solid var(--shd-info);
background: #0d1011;
}
.slot-icon-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 7px;
margin-top: 8px;
}
.slot-icon-grid > button {
min-height: 62px;
display: grid;
grid-template-columns: 38px minmax(0, 1fr) auto;
align-items: center;
gap: 9px;
padding: 8px;
border: 1px solid var(--shd-border);
background: #101415;
color: var(--shd-text);
text-align: left;
cursor: pointer;
}
.slot-icon-grid > button:hover,
.slot-icon-grid > button:focus-visible,
.slot-icon-grid > button[aria-pressed='true'] {
border-color: var(--shd-orange);
background: #191712;
}
.slot-icon-grid > button:focus-visible {
outline: 2px solid var(--shd-orange);
outline-offset: 2px;
}
.slot-icon-grid > button > span:nth-child(2) {
min-width: 0;
display: grid;
gap: 3px;
}
.slot-icon-grid strong,
.slot-icon-grid small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.slot-icon-grid strong {
font-size: var(--shd-type-label);
letter-spacing: .04em;
}
.slot-icon-grid small,
.slot-icon-grid > button > b {
color: var(--shd-text-muted);
font-size: var(--shd-type-micro);
letter-spacing: .07em;
}
.slot-icon-grid > button.configured small,
.slot-icon-grid > button.configured > b {
color: var(--shd-info);
}
.slot-icon-preview {
width: 38px;
height: 38px;
display: grid;
place-items: center;
border: 1px solid #313839;
background: #090b0c;
}
.slot-icon-preview i {
color: #586164;
font-size: 20px;
font-style: normal;
font-weight: 900;
}
.slot-icon-editor {
border-top-color: var(--shd-info);
}
.slot-icon-editor-body {
display: grid;
grid-template-columns: minmax(260px, .7fr) minmax(360px, 1.3fr);
gap: 12px;
}
.slot-icon-editor-identity {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border: 1px solid var(--shd-border);
background: #0d1011;
}
.slot-icon-preview.large {
width: 76px;
height: 76px;
flex: 0 0 76px;
}
.slot-icon-editor-identity > div {
display: grid;
gap: 5px;
}
.slot-icon-editor-identity small,
.slot-icon-editor-identity p {
color: var(--shd-text-muted);
font-size: var(--shd-type-telemetry);
}
.slot-icon-editor-identity p {
margin: 0;
}
@media (max-width: 900px) {
.slot-icon-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.slot-icon-editor-body { grid-template-columns: 1fr; }
}
@media (max-width: 480px) {
.slot-icon-grid { grid-template-columns: 1fr; }
}
</style>
+31 -31
View File
@@ -37,20 +37,20 @@
&& Math.floor(Number(active.split(':')[1]) / 3) === Number(cell.id.split(':')[1]));
const issueLabel = (issue: string) => {
if (/not configured|required/i.test(issue)) return 'ACTION REQUIRED';
if (/incomplete/i.test(issue)) return 'DATA INCOMPLETE';
return 'CHECK CONFIGURATION';
if (/not configured|required|select|choose/i.test(issue)) return 'REQUIRED';
if (/incomplete/i.test(issue)) return 'INCOMPLETE';
return 'CHECK';
};
</script>
<section class="loadout-matrix" aria-label="Loadout matrix">
<header>
<span>LOADOUT MATRIX</span>
<b class:ready={configured}>{configured ? 'COMBAT READY' : 'CONFIGURATION INCOMPLETE'}</b>
<span>LOADOUT</span>
<b class:ready={configured}>{configured ? 'COMPLETE' : 'INCOMPLETE'}</b>
</header>
<div class="matrix-section doctrine-section">
<small class="section-label">DOCTRINE · ROOT CHOICE</small>
<small class="section-label">SPECIALIZATION FOCUS</small>
<button
type="button"
class="doctrine-cell"
@@ -60,27 +60,15 @@
onclick={() => onselect(doctrine.id)}
>
<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>
</button>
<div class="supply-line"><span>SUPPLIES 3 ABILITIES</span></div>
<div class="supply-line"><span>ABILITIES</span></div>
<div class="ability-cells">
{#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)}>
<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>
</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>
<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}
@@ -88,12 +76,25 @@
</div>
<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">
{#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)}>
<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>
</button>
{/each}
@@ -101,10 +102,10 @@
</div>
<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)}>
<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>
</button>
</div>
@@ -112,21 +113,20 @@
<style>
.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)}
.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}
.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}
: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.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}
.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}
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){.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}}
@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:520px){.ability-cells{grid-template-columns:1fr}.equipment-cells{grid-template-columns:1fr 1fr}.gear-cells{grid-template-columns:1fr}}
</style>
@@ -37,10 +37,9 @@
<section class:page={layout === 'page'} class="loadout-known-stats" aria-labelledby={headingId}>
<div class="summary-head">
<div>
<p class="eyebrow"><span></span> KNOWN EQUIPMENT TOTALS</p>
<p class="eyebrow"><span></span> KNOWN STATS</p>
<h2 id={headingId}>Agent stats</h2>
</div>
<b>{summary.stats.length}</b>
</div>
<div class="summary-core">
{#each ['Firepower', 'Toughness', 'Engineering'] as name}
@@ -53,49 +52,57 @@
</div>
<div class="summary-scroll">
<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}
<div class="summary-stat">
<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>
</div>
{:else}
<p>Equip and configure items to calculate their known Attributes.</p>
<p>Equip items to see known Attributes.</p>
{/each}
</div>
<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}
<div class="summary-bonus">
<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>
</div>
{:else}
<p>Matching Brand Sets and Skill Chips activate bonuses here.</p>
<p>No active bonuses.</p>
{/each}
</div>
<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}
<div class="summary-talent">
<span>
<b>{equipped.custom?.name ?? talent(equipped.id)?.name ?? 'Talent'}</b>
<small>{equipped.source} · CONDITIONAL EFFECT NOT INCLUDED</small>
<em>{equipped.description}</em>
<details open={layout === 'page'}>
<summary><b>{equipped.custom?.name ?? talent(equipped.id)?.name ?? 'Talent'}</b><small>{equipped.source}{equipped.tierLabel ? ` · ${equipped.tierLabel}` : ''}</small></summary>
<p>{equipped.description}</p>
{#if equipped.id && talentCooldown(equipped.id)}<i class="talent-cooldown">{talentCooldown(equipped.id)}</i>{/if}
</span>
</details>
</div>
{:else}
<p>Selected equipment Talents appear here.</p>
<p>No Talents equipped.</p>
{/each}
</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>
<style>
.loadout-known-stats{background:var(--shd-surface)}
.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}
.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))}
@@ -10,20 +10,20 @@
} = $props();
const capabilities = [
{ label: 'WEAPON OUTPUT', detail: 'Hit values, burst DPS, sustained DPS, and reload-aware firing cycles' },
{ label: 'SKILL SIMULATION', detail: 'Ability cadence, target counts, critical hits, and multi-shot behavior' },
{ label: 'ENCOUNTER MODEL', detail: 'Duration, accuracy, movement, target flow, rotations, and downtime' },
{ label: 'CONDITIONAL EFFECTS', detail: 'Talent triggers, stack buildup, cooldowns, uptime, and resource changes' }
{ label: 'WEAPON OUTPUT', detail: 'Hits and firing cycles' },
{ label: 'SKILL SIMULATION', detail: 'Timing and target caps' },
{ label: 'ENCOUNTER MODEL', detail: 'Duration and rotations' },
{ label: 'CONDITIONAL EFFECTS', detail: 'Triggers and uptime' }
];
</script>
<section class:compact={variant === 'compact'} class="planned-combat-model" aria-labelledby={headingId}>
<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>
<b>COMING SOON</b>
<div><span>COMBAT MODEL</span>{#if headingLevel === 'h2'}<h2 id={headingId}>DPS calculations</h2>{:else}<h3 id={headingId}>DPS calculations</h3>{/if}</div>
<b>PLANNED</b>
</header>
{#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}
<div class="planned-capabilities" aria-label="Planned combat-model capabilities">
{#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>
@@ -0,0 +1,60 @@
<script lang="ts">
import {
databaseMechanicGroupDisplaySourceLabel,
databaseMechanicGroupStatus,
databaseMechanicStatusLabel,
type DatabaseMechanicGroup
} from '$lib/database/items';
import DatabaseMechanicStatusBadge from './DatabaseMechanicStatusBadge.svelte';
let {
group,
detail = false,
showIdentity = false
}: {
group: DatabaseMechanicGroup;
detail?: boolean;
showIdentity?: boolean;
} = $props();
const status = $derived(databaseMechanicGroupStatus(group));
const displaySource = $derived(databaseMechanicGroupDisplaySourceLabel(group));
</script>
<section
class="database-mechanic-group"
class:detail
class:specialization-effect={showIdentity}
data-status={status}
aria-label={`${group.label}: ${databaseMechanicStatusLabel(status)}`}
>
{#if showIdentity}
<header class="database-mechanic-group-identity">
<div>
<small>{group.label}</small>
{#if group.context}<strong>{group.context}</strong>{/if}
</div>
<DatabaseMechanicStatusBadge {status}/>
</header>
{:else if group.context}
<p class="database-mechanic-group-context">{group.context}</p>
{/if}
<div class="database-mechanic-variants">
{#each group.variants as variant (variant.label)}
<section class="database-mechanic-variant" data-status={variant.status}>
<div class="database-mechanic-variant-label"><span>{variant.label}</span></div>
<div class="database-mechanic-behavior">
<small>{variant.status === 'modeled' ? 'MODELED BEHAVIOR' : 'AUTHORED DESCRIPTION'}</small>
<p aria-label={variant.behaviorText} title={detail ? undefined : variant.behaviorText}>{variant.behaviorText}</p>
</div>
</section>
{/each}
</div>
{#if detail}
<dl class="database-mechanic-display-source">
<div><dt>DISPLAY SOURCE</dt><dd>{displaySource}</dd></div>
</dl>
{/if}
</section>
@@ -0,0 +1,12 @@
<script lang="ts">
import {
databaseMechanicStatusLabel,
type DatabaseMechanicStatus
} from '$lib/database/items';
let { status }: { status: DatabaseMechanicStatus } = $props();
</script>
<strong class="database-mechanic-status" class:not-modeled={status === 'not_modeled'}>
{databaseMechanicStatusLabel(status)}
</strong>
@@ -0,0 +1,355 @@
<script lang="ts">
import type {
MechanicAuthoredSlot,
MechanicAuthoredVariant,
} from '$lib/calculations/mechanic-authored-description';
export type AuthoredVariantLabel = { key: string; label: string };
let {
template = $bindable(),
variants = $bindable(),
variantLabels,
idPrefix = 'authored-description',
disabled = false,
}: {
template: string;
variants: MechanicAuthoredVariant[];
variantLabels: AuthoredVariantLabel[];
idPrefix?: string;
disabled?: boolean;
} = $props();
const tokenPattern = /\{([^{}]+)\}/g;
const allowedNamedTokens = new Set(['duration', 'max_stacks']);
const tokens = $derived.by(() => {
const ordered: string[] = [];
for (const match of template.matchAll(tokenPattern)) {
if (!ordered.includes(match[1])) ordered.push(match[1]);
}
return ordered;
});
const invalidTokens = $derived(
tokens.filter(
(token) =>
!allowedNamedTokens.has(token) && !/^[1-9]\d*$/.test(token),
),
);
function materialized(
sourceTemplate: string,
slots: MechanicAuthoredSlot[],
) {
const values = new Map(slots.map((slot) => [slot.key, slot.value]));
return sourceTemplate.replace(tokenPattern, (placeholder, token: string) =>
values.get(token) || placeholder,
);
}
function reconcileVariants(
sourceTemplate: string,
sourceVariants: MechanicAuthoredVariant[],
) {
return variantLabels.map((requiredVariant) => {
const existing = sourceVariants.find(
(variant) => variant.key === requiredVariant.key,
);
const existingSlots = new Map(
(existing?.slots ?? []).map((slot) => [slot.key, slot]),
);
const slots = tokens.map((token, order) => ({
...(existingSlots.get(token) ?? { key: token, value: '', order }),
key: token,
order,
}));
return {
key: requiredVariant.key,
materialized: materialized(sourceTemplate, slots),
slots,
};
});
}
function updateTemplate(value: string) {
template = value;
variants = reconcileVariants(value, variants);
}
function updateSlot(variantKey: string, token: string, value: string) {
variants = variants.map((variant) => {
if (variant.key !== variantKey) return variant;
const slots = tokens.map((currentToken, order) => {
const existing = variant.slots.find(
(slot) => slot.key === currentToken,
);
return {
...(existing ?? { key: currentToken, value: '', order }),
key: currentToken,
value: currentToken === token ? value : (existing?.value ?? ''),
order,
};
});
return {
...variant,
slots,
materialized: materialized(template, slots),
};
});
}
function slotValue(variantKey: string, token: string) {
return (
variants
.find((variant) => variant.key === variantKey)
?.slots.find((slot) => slot.key === token)?.value ?? ''
);
}
function preview(variantKey: string) {
return (
variants.find((variant) => variant.key === variantKey)?.materialized ??
template
);
}
</script>
<section class="authored-editor" aria-labelledby={`${idPrefix}-title`}>
<header>
<div>
<p class="eyebrow">PUBLIC FALLBACK SOURCE</p>
<h4 id={`${idPrefix}-title`}>AUTHORED DESCRIPTION</h4>
<p>Define exact public prose independently from combat modeling.</p>
</div>
<span class="variant-count">
{variantLabels.length} {variantLabels.length === 1 ? 'VARIANT' : 'VARIANTS'}
</span>
</header>
<label class="field template-field">
SHARED DESCRIPTION TEMPLATE
<textarea
id={`${idPrefix}-template`}
value={template}
oninput={(event) => updateTemplate(event.currentTarget.value)}
placeholder={'After {1} hits, gain {2}% Weapon Damage for {duration} seconds.'}
{disabled}
required
></textarea>
<small>
Use numbered tokens such as <code>{'{1}'}</code>, plus
<code>{'{duration}'}</code> and <code>{'{max_stacks}'}</code>. Keep fixed
units and Attribute wording in the template. Enter only the numbers that
change by variant below; the combat model assigns Attribute semantics.
</small>
</label>
{#if invalidTokens.length}
<p class="token-error" role="alert">
Unsupported {invalidTokens.length === 1 ? 'token' : 'tokens'}:
{invalidTokens.map((token) => `{${token}}`).join(', ')}
</p>
{/if}
{#if tokens.length}
<div class="matrix-scroll">
<table>
<thead>
<tr>
<th scope="col">AUTHORED VALUE</th>
{#each variantLabels as variant}
<th scope="col">{variant.label}</th>
{/each}
</tr>
</thead>
<tbody>
{#each tokens as token}
<tr>
<th scope="row"><code>{`{${token}}`}</code></th>
{#each variantLabels as variant}
<td>
<label class="sr-only" for={`${idPrefix}-${variant.key}-${token}`}>
{variant.label} value for {`{${token}}`}
</label>
<input
id={`${idPrefix}-${variant.key}-${token}`}
type="number"
step="any"
inputmode="decimal"
value={slotValue(variant.key, token)}
oninput={(event) =>
updateSlot(variant.key, token, event.currentTarget.value)}
{disabled}
required
/>
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<p class="empty-tokens">
Add a supported token to the shared template to define variant values.
</p>
{/if}
<div class="previews">
{#each variantLabels as variant}
<article>
<span>{variant.label} EXACT PREVIEW</span>
<p>{preview(variant.key) || 'Description preview'}</p>
</article>
{/each}
</div>
</section>
<style>
.authored-editor {
border: 1px solid var(--border, #333b44);
background: color-mix(in srgb, var(--surface, #12171d) 94%, transparent);
}
header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.1rem;
border-bottom: 1px solid var(--border, #333b44);
}
header h4,
header p {
margin: 0;
}
header h4 {
margin-top: 0.2rem;
}
header > div > p:last-child {
margin-top: 0.35rem;
color: var(--muted, #97a3af);
}
.variant-count {
flex: 0 0 auto;
padding: 0.4rem 0.5rem;
border: 1px solid var(--border, #46515c);
color: var(--muted, #a4aeb8);
font-family: var(--font-mono, monospace);
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.08em;
}
.template-field {
margin: 1rem 1.1rem 0;
}
.template-field code,
th code {
color: var(--info, #6cb9ed);
}
.matrix-scroll {
margin: 1rem 1.1rem;
overflow-x: auto;
}
table {
width: 100%;
min-width: 34rem;
border-collapse: collapse;
}
th,
td {
border: 1px solid var(--border, #333b44);
padding: 0.55rem;
text-align: left;
}
thead th {
background: var(--surface-dark, #0d1217);
color: var(--muted, #a4aeb8);
font-family: var(--font-mono, monospace);
font-size: 0.68rem;
letter-spacing: 0.08em;
}
tbody th {
width: 10rem;
background: color-mix(in srgb, var(--surface, #12171d) 80%, black);
}
td input {
width: 100%;
min-width: 8rem;
}
.previews {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
gap: 0.75rem;
padding: 0 1.1rem 1.1rem;
}
.previews article {
padding: 0.8rem 0.9rem;
border-left: 3px solid var(--info, #6cb9ed);
background: var(--surface-dark, #0d1217);
}
.previews span {
color: var(--info, #6cb9ed);
font-family: var(--font-mono, monospace);
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.08em;
}
.previews p {
margin: 0.4rem 0 0;
white-space: pre-wrap;
}
.token-error,
.empty-tokens {
margin: 1rem 1.1rem;
padding: 0.7rem 0.8rem;
border: 1px solid var(--border, #46515c);
color: var(--muted, #a4aeb8);
font-family: var(--font-mono, monospace);
font-size: 0.75rem;
}
.token-error {
border-color: var(--danger, #b7524c);
color: var(--danger-light, #ff8b84);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (max-width: 40rem) {
header {
align-items: stretch;
flex-direction: column;
}
.variant-count {
align-self: flex-start;
}
}
</style>
@@ -9,18 +9,24 @@
blankConditionalMechanicDraft,
currentHealthPercentBandPredicate,
eventFieldComparisonPredicate,
eventFieldNumericExpression,
mechanicTargetingForMode,
mechanicTargetingMode,
parseConditionalMechanicDraft,
readCurrentHealthPercentBand,
readEventFieldComparison,
readEventFieldNumericExpression,
registeredEventFields,
readStatePoolThreshold,
statePoolThresholdPredicate,
type ConditionalMechanicClauseDraft,
type ConditionalNumericExpression,
type ConditionalMechanicDraftV1,
type ConditionalMechanicOutcomeDraft,
type ConditionalMechanicPoolDraft,
type EditablePredicateScalar,
type EventFieldComparison,
type MechanicTargetingMode,
type PredicateComparisonOperator,
type StatePoolThresholdComparison
} from '$lib/calculations/mechanic-editor';
@@ -28,6 +34,7 @@
formatMechanicProgram,
MECHANIC_EVENT_KINDS,
MECHANIC_EVENT_REGISTRY,
PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_FIELD,
type ClauseMutation,
type MechanicOutcomeScope,
type NumericExpression,
@@ -182,7 +189,9 @@
}
function eventPredicateOperatorOptions(clause: ConditionalMechanicClauseDraft) {
return selectedEventPredicateField(clause)?.type === 'number'
const field = selectedEventPredicateField(clause);
if (field?.path === 'values.aiming') return predicateOperatorOptions.filter((option) => option.value === 'eq');
return field?.type === 'number'
? predicateOperatorOptions
: predicateOperatorOptions.filter((option) => option.value === 'eq' || option.value === 'ne');
}
@@ -190,15 +199,21 @@
function setClauseEvent(clauseIndex: number, event: string) {
const nextEvent = event as ConditionalMechanicClauseDraft['event'];
const clause = rule.clauses[clauseIndex];
const outcomes = nextEvent === 'weapon_hit'
? clause.outcomes
: clause.outcomes.map((outcome) => outcome.targeting?.kind === 'event_target_area'
? { ...outcome, targeting: undefined }
: outcome);
const fields = registeredEventFields(nextEvent);
const comparison = readEventFieldComparison(clause.if);
if (!comparison || !fields.length) {
updateClause(clauseIndex, { event: nextEvent });
updateClause(clauseIndex, { event: nextEvent, outcomes });
return;
}
const field = fields[0];
updateClause(clauseIndex, {
event: nextEvent,
outcomes,
if: eventFieldComparisonPredicate({
path: field.path,
operator: 'eq',
@@ -397,12 +412,109 @@
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>) {
const outcomes = [...rule.clauses[clauseIndex].outcomes];
outcomes[outcomeIndex] = { ...outcomes[outcomeIndex], ...patch };
updateClause(clauseIndex, { outcomes });
}
function targetingOptions(clause: ConditionalMechanicClauseDraft, outcome: ConditionalMechanicOutcomeDraft) {
if (outcome.scope === 'allies' || outcome.scope === 'self_and_allies') {
return [
{ value: 'none', label: 'NONE' },
{ value: 'allies_within_radius', label: 'ALLIES IN RADIUS' }
];
}
if (outcome.scope === 'target') {
return [
{ value: 'none', label: 'NONE' },
{ value: 'enemies_within_radius', label: 'ENEMIES IN RADIUS · LEGACY' },
...(outcome.kind === 'direct_damage' && clause.event === 'weapon_hit'
? [{ value: 'event_target_explosion', label: 'HIT-TARGET EXPLOSION' }]
: [])
];
}
return [{ value: 'none', label: 'NONE' }];
}
function setOutcomeScope(clauseIndex: number, outcomeIndex: number, scope: MechanicOutcomeScope) {
const outcome = rule.clauses[clauseIndex].outcomes[outcomeIndex];
const targeting = outcome.targeting;
const compatible = !targeting
|| (targeting.kind === 'event_target_area' && scope === 'target' && outcome.kind === 'direct_damage')
|| (targeting.kind === 'within_radius' && targeting.population === 'enemies' && scope === 'target')
|| (targeting.kind === 'within_radius' && targeting.population === 'allies' && (scope === 'allies' || scope === 'self_and_allies'));
updateOutcome(clauseIndex, outcomeIndex, { scope, targeting: compatible ? targeting : undefined });
}
function setOutcomeKind(clauseIndex: number, outcomeIndex: number, kind: ConditionalMechanicOutcomeDraft['kind']) {
const outcome = rule.clauses[clauseIndex].outcomes[outcomeIndex];
updateOutcome(clauseIndex, outcomeIndex, {
kind,
resource: kind === 'restoration' || kind === 'resource_cost' ? outcome.resource ?? 'health' : undefined,
attributeId: kind === 'attribute_modifier' ? outcome.attributeId ?? '' : undefined,
delivery: kind === 'restoration' ? outcome.delivery : undefined,
durationSeconds: kind === 'restoration' ? outcome.durationSeconds : undefined,
targeting: outcome.targeting?.kind === 'event_target_area' && kind !== 'direct_damage'
? undefined
: outcome.targeting
});
}
function setOutcomeTargeting(clauseIndex: number, outcomeIndex: number, mode: MechanicTargetingMode) {
const outcome = rule.clauses[clauseIndex].outcomes[outcomeIndex];
updateOutcome(clauseIndex, outcomeIndex, {
targeting: mechanicTargetingForMode(mode, outcome.targeting),
phase: mode === 'event_target_explosion' && outcome.phase !== 'on_event' && outcome.phase !== 'after_event'
? 'after_event'
: outcome.phase
});
}
function phaseOptions(outcome: ConditionalMechanicOutcomeDraft) {
return [
{ value: 'on_event', label: 'ON EVENT' },
{ value: 'after_event', label: 'AFTER EVENT' },
{ value: 'while_active', label: 'WHILE ACTIVE', disabled: outcome.targeting?.kind === 'event_target_area' },
{ value: 'on_enter', label: 'ON ENTER', disabled: outcome.targeting?.kind === 'event_target_area' },
{ value: 'on_exit', label: 'ON EXIT', disabled: outcome.targeting?.kind === 'event_target_area' }
];
}
function updateVariant(clauseIndex: number, outcomeIndex: number, variant: string, raw: string) {
const outcome = rule.clauses[clauseIndex].outcomes[outcomeIndex];
const value = Number(raw);
@@ -443,6 +555,10 @@
const eventValueOptions = [
{ value: 'values.enemyCount', label: 'ENEMY 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.magazinePercent', label: 'MAGAZINE %' },
{ value: 'values.distanceMeters', label: 'DISTANCE' }
@@ -460,10 +576,10 @@
};
}
function protocolUpgradeExpression(value: number): NumericExpression {
function protocolUpgradeExpression(): ConditionalNumericExpression {
return {
op: 'multiply',
left: value,
left: { ref: { source: 'variant_value' } },
right: {
op: 'add',
left: 1,
@@ -504,17 +620,17 @@
typeof expression === 'object'
&& 'op' in expression
&& expression.op === 'multiply'
&& typeof expression.left === 'number'
&& 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 === 'scenario') return 'scenario';
if (expression.right.ref.source === 'reducer') return 'reducer';
if (expression.right.ref.source === 'attribute') return 'attribute_value';
}
if (
'op' in expression.right
typeof expression.left === 'number'
&& 'op' in expression.right
&& expression.right.op === 'divide'
&& typeof expression.right.left === 'object'
&& 'ref' in expression.right.left
@@ -522,7 +638,13 @@
&& expression.right.right === 100
) return 'attribute';
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'
&& typeof expression.right.right === 'object'
&& 'op' in expression.right.right
@@ -583,7 +705,7 @@
model: OutcomeValueModel,
value: number,
reference = ''
): NumericExpression | undefined {
): ConditionalNumericExpression | undefined {
if (model === 'event') return referenceExpression('event', reference || 'values.enemyCount', value);
if (model === 'attribute') return reference
? attributePercentExpression(value, reference)
@@ -597,7 +719,7 @@
if (model === '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;
}
@@ -624,7 +746,7 @@
updateOutcome(clauseIndex, outcomeIndex, { variants });
}
function replaceExpressionBase(expression: NumericExpression, value: number): NumericExpression {
function replaceExpressionBase(expression: ConditionalNumericExpression, value: number): ConditionalNumericExpression {
const model = outcomeValueModel({
key: 'temporary',
label: 'Temporary',
@@ -669,7 +791,7 @@
if (model === '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;
}
@@ -684,7 +806,51 @@
});
}
function updateMutation(clauseIndex: number, mutationIndex: number, kind: 'add' | 'set' | 'consume' | 'clear', pool?: string, value?: number) {
function addAbilityDurationExtension(clauseIndex: number) {
updateClause(clauseIndex, {
mutations: [
...rule.clauses[clauseIndex].mutations,
{ kind: 'extend_duration', target: 'assigned_ability', seconds: 0.3 }
]
});
}
function addCooldownOnMax(clauseIndex: number) {
const stackPool = rule.pools.find((pool) => pool.max && pool.max > 0)?.key;
const cooldownPool = rule.pools.find((pool) => pool.scope === 'agent' && pool.expiresAfterSeconds)?.key;
if (!stackPool || !cooldownPool || stackPool === cooldownPool) return;
updateClause(clauseIndex, {
mutations: [
...rule.clauses[clauseIndex].mutations,
{ kind: 'start_cooldown_on_max', stackPool, cooldownPool }
]
});
}
function updateAbilityDurationExtension(clauseIndex: number, mutationIndex: number, raw: string) {
const mutations = [...rule.clauses[clauseIndex].mutations];
const seconds = Number(raw);
mutations[mutationIndex] = {
kind: 'extend_duration',
target: 'assigned_ability',
seconds: Number.isFinite(seconds) ? seconds : 0
};
updateClause(clauseIndex, { mutations });
}
function updateCooldownOnMax(
clauseIndex: number,
mutationIndex: number,
patch: { stackPool?: string; cooldownPool?: string }
) {
const mutations = [...rule.clauses[clauseIndex].mutations];
const current = mutations[mutationIndex];
if (current.kind !== 'start_cooldown_on_max') return;
mutations[mutationIndex] = { ...current, ...patch };
updateClause(clauseIndex, { mutations });
}
function updateMutation(clauseIndex: number, mutationIndex: number, kind: 'add' | 'set' | 'consume' | 'derive' | 'clear', pool?: string, value?: number) {
const mutations = [...rule.clauses[clauseIndex].mutations];
const current = mutations[mutationIndex];
const nextPool = pool ?? ('pool' in current ? current.pool : rule.pools[0]?.key ?? '');
@@ -694,7 +860,9 @@
: undefined;
mutations[mutationIndex] = kind === 'clear'
? { 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 });
}
@@ -981,6 +1149,20 @@
{:else if conditionModel(clause) === 'advanced'}
<p class="advanced-condition-note">This saved condition remains active and is preserved unchanged. Use the generated mechanic preview to inspect it.</p>
{/if}
{#if allowAssignedAbility}
<div class="ability-proc-controls">
<SegmentedChoice
legend="ASSIGNED ABILITY"
name={`${idPrefix}-assigned-ability-${clauseIndex}`}
options={[{value:'false',label:'ANY STATE'},{value:'true',label:'MUST BE ACTIVE'}]}
value={String(clause.requiresAssignedAbilityActive ?? false)}
onchange={(values) => updateClause(clauseIndex,{requiresAssignedAbilityActive:values[0] === 'true' ? true : undefined})}
/>
<label class="field">PROC CHANCE · %<input type="number" min="0" max="100" step="any" value={clause.procChancePercent ?? ''} oninput={(event) => updateClause(clauseIndex,{procChancePercent:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/><small>Rolls after conditions and cooldown checks, before mutations.</small></label>
</div>
{:else}
<label class="field proc-chance-field">PROC CHANCE · %<input type="number" min="0" max="100" step="any" value={clause.procChancePercent ?? ''} oninput={(event) => updateClause(clauseIndex,{procChancePercent:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/><small>Rolls after conditions and cooldown checks, before mutations.</small></label>
{/if}
</div>
<div class="reducer-controls">
<SegmentedChoice
@@ -1002,43 +1184,82 @@
{: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>
{: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}
</div>
<div class="mutation-panel">
<div class="outcome-heading"><div><span>STATE CHANGES</span><strong>{clause.mutations.length}</strong></div><button type="button" class="secondary compact-action" onclick={() => addMutation(clauseIndex)} disabled={!rule.pools.length}>ADD STATE CHANGE +</button></div>
<div class="outcome-heading"><div><span>STATE CHANGES</span><strong>{clause.mutations.length}</strong></div><div class="mutation-actions"><button type="button" class="secondary compact-action" onclick={() => addMutation(clauseIndex)} disabled={!rule.pools.length}>ADD STATE CHANGE +</button>{#if allowAssignedAbility}<button type="button" class="secondary compact-action" onclick={() => addAbilityDurationExtension(clauseIndex)}>EXTEND ABILITY +</button>{/if}<button type="button" class="secondary compact-action" onclick={() => addCooldownOnMax(clauseIndex)} disabled={rule.pools.filter((pool) => pool.max && pool.max > 0).length < 2}>ADD MAX COOLDOWN +</button></div></div>
{#each clause.mutations as mutation, mutationIndex}
<div class="mutation-row">
<SegmentedChoice
legend="OPERATION"
name={`${idPrefix}-mutation-kind-${clauseIndex}-${mutationIndex}`}
options={[{value:'add',label:'ADD'},{value:'set',label:'SET'},{value:'consume',label:'CONSUME'},{value:'clear',label:'CLEAR'}]}
value={mutation.kind === 'none' || mutation.kind === 'derive' || mutation.kind === 'transfer' ? 'add' : mutation.kind}
onchange={(values) => updateMutation(clauseIndex,mutationIndex,values[0] as 'add'|'set'|'consume'|'clear')}
/>
<SegmentedChoice
legend="STATE POOL"
name={`${idPrefix}-mutation-pool-${clauseIndex}-${mutationIndex}`}
options={rule.pools.map((pool) => ({value:pool.key,label:pool.label.toUpperCase()}))}
value={'pool' in mutation ? mutation.pool : rule.pools[0]?.key ?? ''}
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])}
/>
{#if mutation.kind !== 'clear'}
<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>
{/if}
{#if 'pool' in mutation && rule.pools.find((pool) => pool.key === mutation.pool)?.scope === 'target'}
{#if mutation.kind === 'extend_duration'}
<div class="mutation-row ability-mutation-row">
<div class="branch-readout"><span>OPERATION</span><strong>EXTEND ASSIGNED ABILITY</strong></div>
<label class="field">SECONDS<input type="number" min="0" step="any" value={typeof mutation.seconds === 'number' ? mutation.seconds : 0} oninput={(event) => updateAbilityDurationExtension(clauseIndex,mutationIndex,event.currentTarget.value)}/><small>Applies only while the assigned ability remains active.</small></label>
<button type="button" class="remove" onclick={() => updateClause(clauseIndex,{mutations:clause.mutations.filter((_,index) => index !== mutationIndex)})}>REMOVE</button>
</div>
{:else if mutation.kind === 'start_cooldown_on_max'}
<div class="mutation-row cooldown-mutation-row">
<div class="branch-readout"><span>OPERATION</span><strong>START COOLDOWN ON MAX</strong></div>
<SegmentedChoice legend="STACK POOL" name={`${idPrefix}-cooldown-stack-${clauseIndex}-${mutationIndex}`} options={rule.pools.filter((pool) => pool.max && pool.max > 0).map((pool) => ({value:pool.key,label:pool.label.toUpperCase()}))} value={mutation.stackPool} onchange={(values) => updateCooldownOnMax(clauseIndex,mutationIndex,{stackPool:values[0]})}/>
<SegmentedChoice legend="COOLDOWN POOL" name={`${idPrefix}-cooldown-pool-${clauseIndex}-${mutationIndex}`} options={rule.pools.filter((pool) => pool.scope === 'agent' && pool.expiresAfterSeconds && pool.key !== mutation.stackPool).map((pool) => ({value:pool.key,label:pool.label.toUpperCase()}))} value={mutation.cooldownPool} onchange={(values) => updateCooldownOnMax(clauseIndex,mutationIndex,{cooldownPool:values[0]})}/>
<button type="button" class="remove" onclick={() => updateClause(clauseIndex,{mutations:clause.mutations.filter((_,index) => index !== mutationIndex)})}>REMOVE</button>
</div>
{:else}
<div class="mutation-row">
<SegmentedChoice
legend="STATE OWNER"
name={`${idPrefix}-mutation-entity-${clauseIndex}-${mutationIndex}`}
options={[{value:'event_target',label:'EVENT TARGET'},{value:'event_actor',label:'EVENT ACTOR'}]}
value={'entity' in mutation && mutation.entity?.kind === 'event_actor' ? 'event_actor' : 'event_target'}
onchange={(values) => updateMutationEntity(clauseIndex,mutationIndex,values[0] as 'event_target'|'event_actor')}
legend="OPERATION"
name={`${idPrefix}-mutation-kind-${clauseIndex}-${mutationIndex}`}
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 === 'transfer' ? 'add' : mutation.kind}
onchange={(values) => updateMutation(clauseIndex,mutationIndex,values[0] as 'add'|'set'|'consume'|'derive'|'clear')}
/>
{/if}
<button type="button" class="remove" onclick={() => updateClause(clauseIndex,{mutations:clause.mutations.filter((_,index) => index !== mutationIndex)})}>REMOVE</button>
</div>
<SegmentedChoice
legend="STATE POOL"
name={`${idPrefix}-mutation-pool-${clauseIndex}-${mutationIndex}`}
options={rule.pools.map((pool) => ({value:pool.key,label:pool.label.toUpperCase()}))}
value={'pool' in mutation ? mutation.pool : rule.pools[0]?.key ?? ''}
columns={Math.min(4,Math.max(1,rule.pools.length))}
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' && 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>
{:else if mutation.kind === 'derive'}
<div class="branch-readout"><span>VALUE</span><strong>DERIVED SCALAR</strong></div>
{/if}
{#if 'pool' in mutation && rule.pools.find((pool) => pool.key === mutation.pool)?.scope === 'target'}
<SegmentedChoice
legend="STATE OWNER"
name={`${idPrefix}-mutation-entity-${clauseIndex}-${mutationIndex}`}
options={[{value:'event_target',label:'EVENT TARGET'},{value:'event_actor',label:'EVENT ACTOR'}]}
value={'entity' in mutation && mutation.entity?.kind === 'event_actor' ? 'event_actor' : 'event_target'}
onchange={(values) => updateMutationEntity(clauseIndex,mutationIndex,values[0] as 'event_target'|'event_actor')}
/>
{/if}
<button type="button" class="remove" onclick={() => updateClause(clauseIndex,{mutations:clause.mutations.filter((_,index) => index !== mutationIndex)})}>REMOVE</button>
</div>
{/if}
{:else}
<p class="empty-state">This branch emits outcomes without changing shared state.</p>
{/each}
@@ -1049,25 +1270,24 @@
<div class="outcome-card">
<div class="outcome-grid">
<label class="field">LABEL<input value={outcome.label} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{label:event.currentTarget.value,key:slug(event.currentTarget.value)})}/></label>
<SegmentedChoice legend="KIND" name={`${idPrefix}-kind-${clauseIndex}-${outcomeIndex}`} options={outcomeKinds} value={outcome.kind} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{kind:values[0] as ConditionalMechanicOutcomeDraft['kind'],resource:values[0] === 'restoration' || values[0] === 'resource_cost' ? outcome.resource ?? 'health' : undefined,attributeId:values[0] === 'attribute_modifier' ? outcome.attributeId ?? '' : undefined,delivery:values[0] === 'restoration' ? outcome.delivery : undefined,durationSeconds:values[0] === 'restoration' ? outcome.durationSeconds : undefined})}/>
<SegmentedChoice legend="KIND" name={`${idPrefix}-kind-${clauseIndex}-${outcomeIndex}`} options={outcomeKinds} value={outcome.kind} onchange={(values) => setOutcomeKind(clauseIndex,outcomeIndex,values[0] as ConditionalMechanicOutcomeDraft['kind'])}/>
<label class="field">DESCRIPTION SLOT<input type="number" min="1" step="1" value={outcome.descriptionSlot ?? ''} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{descriptionSlot:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/></label>
</div>
<SegmentedChoice legend="TARGET SCOPE" name={`${idPrefix}-scope-${clauseIndex}-${outcomeIndex}`} options={scopeOptions} value={outcome.scope} columns={scopeOptions.length > 4 ? 4 : scopeOptions.length} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{scope:values[0] as MechanicOutcomeScope})}/>
<SegmentedChoice legend="TARGET SCOPE" name={`${idPrefix}-scope-${clauseIndex}-${outcomeIndex}`} options={scopeOptions} value={outcome.scope} columns={scopeOptions.length > 4 ? 4 : scopeOptions.length} onchange={(values) => setOutcomeScope(clauseIndex,outcomeIndex,values[0] as MechanicOutcomeScope)}/>
{#if outcome.scope === 'allies' || outcome.scope === 'self_and_allies' || outcome.scope === 'target'}
<div class="delivery-controls">
<SegmentedChoice
legend="AREA TARGETING"
name={`${idPrefix}-targeting-${clauseIndex}-${outcomeIndex}`}
options={[{value:'none',label:'NONE'},{value:'allies',label:'ALLIES IN RADIUS'},{value:'enemies',label:'ENEMIES IN RADIUS'}]}
value={outcome.targeting?.population ?? 'none'}
onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{
targeting: values[0] === 'none'
? undefined
: {kind:'within_radius',population:values[0] as 'allies'|'enemies',radiusMeters:outcome.targeting?.radiusMeters ?? 20}
})}
options={targetingOptions(clause,outcome)}
value={mechanicTargetingMode(outcome.targeting)}
onchange={(values) => setOutcomeTargeting(clauseIndex,outcomeIndex,values[0] as MechanicTargetingMode)}
/>
{#if outcome.targeting}
<label class="field">RADIUS (METERS)<input type="number" min="0.01" step="any" value={outcome.targeting.radiusMeters} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{targeting:{...outcome.targeting!,radiusMeters:Number(event.currentTarget.value)}})}/></label>
{#if outcome.targeting.kind === 'event_target_area'}
<p class="area-targeting-note"><strong>EVENT TARGET CENTER</strong><span>Damages each valid enemy at or inside the radius once. Runtime events must supply the authoritative target sample.</span></p>
{/if}
{/if}
</div>
{/if}
@@ -1154,8 +1374,9 @@
<label class="field">{column.label} VALUE<input type="number" step="any" value={outcome.variants[column.variantKey]?.value ?? 0} oninput={(event) => updateVariant(clauseIndex,outcomeIndex,column.variantKey,event.currentTarget.value)}/></label>
{/each}
</div>
<label class="field probability-field">OUTCOME CHANCE · %<input type="number" min="0" max="100" step="any" value={outcome.probabilityPercent ?? ''} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{probabilityPercent:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/><small>Each eligible outcome rolls independently. Maximum Potential resolves each nonzero chance as successful.</small></label>
<div class="outcome-footer">
<SegmentedChoice legend="PHASE" name={`${idPrefix}-phase-${clauseIndex}-${outcomeIndex}`} options={[{value:'on_event',label:'ON EVENT'},{value:'after_event',label:'AFTER EVENT'},{value:'while_active',label:'WHILE ACTIVE'},{value:'on_enter',label:'ON ENTER'},{value:'on_exit',label:'ON EXIT'}]} value={outcome.phase} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{phase:values[0] as OutcomePhase})}/>
<SegmentedChoice legend="PHASE" name={`${idPrefix}-phase-${clauseIndex}-${outcomeIndex}`} options={phaseOptions(outcome)} value={outcome.phase} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{phase:values[0] as OutcomePhase})}/>
<button type="button" class="remove" onclick={() => updateClause(clauseIndex,{outcomes:clause.outcomes.filter((_,index) => index !== outcomeIndex)})}>REMOVE OUTCOME</button>
</div>
</div>
@@ -1198,6 +1419,8 @@
.branch>header>div{display:flex;align-items:end;gap:9px}.branch>header>div>span{align-self:center;color:var(--shd-orange);font:900 var(--shd-type-telemetry)/1 var(--shd-font-mono)}.branch>header .field{min-width:min(360px,55vw);margin:0}
.branch-grid{display:grid;grid-template-columns:repeat(2,minmax(240px,1fr)) minmax(160px,.45fr);align-items:end;gap:var(--shd-gap-sm);padding:var(--shd-gap-sm)}
.condition-controls{display:grid;grid-template-columns:minmax(260px,.7fr) minmax(320px,1.3fr);align-items:end;gap:var(--shd-gap-sm);padding:0 var(--shd-gap-sm) var(--shd-gap-sm)}
.ability-proc-controls{display:grid;grid-template-columns:minmax(240px,.7fr) minmax(180px,.4fr);align-items:end;gap:var(--shd-gap-sm)}
.ability-proc-controls .field small,.proc-chance-field small,.probability-field small,.ability-mutation-row .field small{display:block;margin-top:4px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);line-height:1.3}
.health-boundaries{display:grid;grid-template-columns:repeat(2,minmax(150px,1fr));gap:var(--shd-gap-sm)}
.health-boundaries .field small{display:block;margin-top:4px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);line-height:1.3}
.predicate-fields{display:grid;grid-template-columns:minmax(170px,1fr) minmax(190px,.8fr);align-items:end;gap:var(--shd-gap-sm)}
@@ -1207,12 +1430,15 @@
.branch-readout{display:grid;align-content:center;gap:3px;min-height:44px;padding:7px 9px;border:1px solid var(--shd-border);background:var(--shd-surface-raised)}.branch-readout span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.branch-readout strong{font-size:var(--shd-type-supporting)}
.reducer-controls{display:grid;grid-template-columns:minmax(0,1fr) repeat(2,minmax(130px,.25fr));align-items:end;gap:var(--shd-gap-sm);padding:0 var(--shd-gap-sm) var(--shd-gap-sm)}
.mutation-panel{margin:0 var(--shd-gap-sm) var(--shd-gap-sm);border:1px solid var(--shd-border-soft)}
.mutation-actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:6px}
.mutation-row{display:grid;grid-template-columns:minmax(260px,1fr) minmax(220px,1fr) minmax(110px,.35fr) minmax(240px,1fr) auto;align-items:end;gap:8px;padding:8px;border-bottom:1px solid var(--shd-border-soft)}
.ability-mutation-row{grid-template-columns:minmax(240px,1fr) minmax(180px,.5fr) auto}.cooldown-mutation-row{grid-template-columns:minmax(210px,.8fr) minmax(220px,1fr) minmax(220px,1fr) auto}.probability-field{max-width:260px}
.outcome-card{display:grid;gap:8px;margin:var(--shd-gap-sm);padding:9px;border:1px solid var(--shd-border);border-left:2px solid var(--shd-info);background:var(--shd-surface-raised)}
.outcome-grid{display:grid;grid-template-columns:minmax(200px,1fr) minmax(360px,1.4fr) minmax(120px,.3fr);align-items:end;gap:8px}
.variant-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:8px}
.state-scaling{display:grid;grid-template-columns:minmax(0,1fr) minmax(260px,.75fr) repeat(2,minmax(110px,.3fr));align-items:end;gap:8px}
.delivery-controls{display:grid;grid-template-columns:minmax(260px,1fr) minmax(150px,.35fr);align-items:end;gap:8px}.delivery-controls>.field{margin:0}
.area-targeting-note{grid-column:1/-1;display:grid;gap:2px;margin:0;padding:7px 9px;border-left:2px solid var(--shd-info);background:var(--shd-model-surface);color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}.area-targeting-note strong{color:var(--shd-info);font-size:var(--shd-type-telemetry);letter-spacing:.7px}
.outcome-footer{display:flex;align-items:end;justify-content:space-between;gap:8px}
.remove,.danger-quiet{min-height:38px;padding:0 10px;border:1px solid var(--shd-danger);background:transparent;color:var(--shd-danger);font-size:var(--shd-type-telemetry);font-weight:900;cursor:pointer}
.remove:hover,.remove:focus-visible,.danger-quiet:hover,.danger-quiet:focus-visible{background:var(--shd-danger);color:var(--shd-surface)}
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest';
import {
databaseMechanicGroupDisplaySourceLabel,
databaseMechanicGroupStatus,
databaseMechanicStatusLabel,
type DatabaseMechanicGroup,
} from './items';
const group = (
variants: DatabaseMechanicGroup['variants'],
): DatabaseMechanicGroup => ({
id: 'owner-1',
ownerType: 'talent',
label: 'Weapon Talent',
variants,
});
describe('database mechanic presentation', () => {
it('uses one modeled status and display source for a fully modeled owner', () => {
const value = group([
{
label: 'Tier I',
behaviorText: 'Gain 10% Weapon Damage.',
status: 'modeled',
displaySource: 'model',
},
{
label: 'Tier II',
behaviorText: 'Gain 20% Weapon Damage.',
status: 'modeled',
displaySource: 'model',
},
]);
expect(databaseMechanicGroupStatus(value)).toBe('modeled');
expect(databaseMechanicStatusLabel(databaseMechanicGroupStatus(value))).toBe('MODELED');
expect(databaseMechanicGroupDisplaySourceLabel(value)).toBe('MODELED BEHAVIOR');
});
it('falls back to the neutral public status when an owner is incomplete', () => {
const value = group([
{
label: 'Tier I',
behaviorText: 'Gain 10% Weapon Damage.',
status: 'modeled',
displaySource: 'model',
},
{
label: 'Tier II',
behaviorText: 'Gain 20% Weapon Damage.',
status: 'not_modeled',
displaySource: 'authored',
},
]);
expect(databaseMechanicGroupStatus(value)).toBe('not_modeled');
expect(databaseMechanicStatusLabel(databaseMechanicGroupStatus(value))).toBe('NOT MODELED');
expect(databaseMechanicGroupDisplaySourceLabel(value)).toBe('AUTHORED DESCRIPTION');
});
});
+63
View File
@@ -2,7 +2,59 @@ import type { GameIconName } from '$lib/game-icons';
import type { GameDataTargetType } from '$lib/game-data-targets';
export type DatabaseBadge = { label: string; tone?: string };
/** Public, serializable mechanic presentation for one source owner. */
export type DatabaseMechanicOwnerType =
| 'talent'
| 'os_protocol_talent'
| 'skill_chip_talent'
| 'specialization_tree_rank';
export type DatabaseMechanicStatus = 'modeled' | 'not_modeled';
export type DatabaseMechanicDisplaySource = 'model' | 'authored';
export type DatabaseMechanicVariant = {
label: string;
behaviorText: string;
status: DatabaseMechanicStatus;
displaySource: DatabaseMechanicDisplaySource;
};
export type DatabaseMechanicGroup = {
/** Stable owner identity for keyed rendering and deep-linkable detail. */
id: string;
ownerType: DatabaseMechanicOwnerType;
label: string;
context?: string;
variants: DatabaseMechanicVariant[];
};
export const databaseMechanicStatusLabel = (status: DatabaseMechanicStatus) =>
status === 'modeled' ? 'MODELED' : 'NOT MODELED';
/** A complete public owner projection has one status across its variants. */
export const databaseMechanicGroupStatus = (
group: DatabaseMechanicGroup,
): DatabaseMechanicStatus =>
group.variants.length > 0 &&
group.variants.every((variant) => variant.status === 'modeled')
? 'modeled'
: 'not_modeled';
export const databaseMechanicGroupDisplaySourceLabel = (
group: DatabaseMechanicGroup,
) =>
databaseMechanicGroupStatus(group) === 'modeled'
? 'MODELED BEHAVIOR'
: 'AUTHORED DESCRIPTION';
export const isDatabaseMechanicStatus = (
value: unknown,
): value is DatabaseMechanicStatus =>
value === 'modeled' || value === 'not_modeled';
export type DatabaseMeter = { label: string; value: number };
export type DatabaseWeaponModSlot = {
slot: string;
unlockedAtTierOne: boolean;
iconUrl?: string;
};
export type DatabaseFact = {
label: string;
value: string;
@@ -26,8 +78,10 @@ export type DatabaseItem = {
renderUrl?: string;
gameIcon?: GameIconName;
headerBadge?: string;
headerBadgeTone?: string;
headerBadges?: string[];
headerMetaIcon?: GameIconName;
topBorderTone?: string;
badges?: DatabaseBadge[];
facts: DatabaseFact[];
factLayout?: 'grid' | 'stack';
@@ -35,9 +89,18 @@ export type DatabaseItem = {
lines?: DatabaseFact[];
afterLines?: DatabaseFact[];
iconGroups?: DatabaseIconGroup[];
weaponModSlots?: DatabaseWeaponModSlot[];
mechanicGroups?: DatabaseMechanicGroup[];
description?: string;
secondaryDescription?: string;
descriptionStyle?: 'prose' | 'data';
protocolPreview?: {
primaryStat: string;
primaryUnit: string;
primaryBaseValue: number;
talentDescription?: string;
talentEffects: Array<{ baseValue: number; unit: string }>;
};
};
export type DatabaseRelatedItem = {
+115
View File
@@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest';
import {
MOD_SECONDARY_CAPACITY,
normalizeModSecondaryPool,
} from './secondary-pool';
const endpoints = (
level1Min: number | string,
level1Max: number | string,
level20Min: number | string,
level20Max: number | string,
) => ({ level1Min, level1Max, level20Min, level20Max });
const range = (statId: string) => ({
statId,
values: {
Specialized: endpoints('1.5', '2.5', '3.5', '4.5'),
Superior: endpoints(2, 3, 4, 5),
'High-end': endpoints(3, 4, 5, 6),
},
});
describe('shared Mod secondary pool', () => {
it('defines secondary capacity for every Mod quality', () => {
expect(MOD_SECONDARY_CAPACITY).toEqual({
Standard: 0,
Specialized: 1,
Superior: 2,
'High-end': 2,
});
});
it('normalizes Attribute IDs and valid Level 1/20 endpoints', () => {
const result = normalizeModSecondaryPool([
range(' critical-hit-chance '),
range('critical-hit-damage'),
]);
expect(result.error).toBeNull();
expect(result.pool).toEqual([
{
statId: 'critical-hit-chance',
values: {
Specialized: endpoints(1.5, 2.5, 3.5, 4.5),
Superior: endpoints(2, 3, 4, 5),
'High-end': endpoints(3, 4, 5, 6),
},
},
{
statId: 'critical-hit-damage',
values: {
Specialized: endpoints(1.5, 2.5, 3.5, 4.5),
Superior: endpoints(2, 3, 4, 5),
'High-end': endpoints(3, 4, 5, 6),
},
},
]);
});
it('rejects duplicate Attributes after ID normalization', () => {
const result = normalizeModSecondaryPool([
range('critical-hit-chance'),
range(' critical-hit-chance '),
]);
expect(result).toEqual({
pool: [],
error: 'Choose a unique Attribute for every shared pool option.',
});
});
it.each([
[
'missing quality ranges',
{
...range('critical-hit-chance'),
values: {
Specialized: endpoints(1, 2, 3, 4),
Superior: endpoints(2, 3, 4, 5),
},
},
],
[
'reversed ranges',
{
...range('critical-hit-chance'),
values: {
...range('critical-hit-chance').values,
Specialized: endpoints(2, 1, 3, 4),
},
},
],
[
'Level 20 regression',
{
...range('critical-hit-chance'),
values: {
...range('critical-hit-chance').values,
Specialized: endpoints(2, 3, 1, 2),
},
},
],
])('rejects %s', (_label, invalidRange) => {
const result = normalizeModSecondaryPool([
invalidRange,
range('critical-hit-damage'),
]);
expect(result).toEqual({
pool: [],
error:
'Configure valid Level 1 and Level 20 ranges for Specialized, Superior, and High-end quality.',
});
});
});
+102
View File
@@ -0,0 +1,102 @@
export const MOD_QUALITIES = [
'Standard',
'Specialized',
'Superior',
'High-end',
] as const;
export const MOD_SECONDARY_QUALITIES = [
'Specialized',
'Superior',
'High-end',
] as const;
export const MOD_SECONDARY_CAPACITY = {
Standard: 0,
Specialized: 1,
Superior: 2,
'High-end': 2,
} as const satisfies Record<(typeof MOD_QUALITIES)[number], number>;
export type ModSecondaryQuality = (typeof MOD_SECONDARY_QUALITIES)[number];
export type ModSecondaryRange = {
statId: string;
values: Record<
ModSecondaryQuality,
{
level1Min: number;
level1Max: number;
level20Min: number;
level20Max: number;
}
>;
};
export function normalizeModSecondaryPool(input: unknown): {
pool: ModSecondaryRange[];
error: string | null;
} {
if (!Array.isArray(input) || input.length < 2 || input.length > 50) {
return {
pool: [],
error: 'Configure between two and 50 shared secondary Attributes.',
};
}
const pool = input.map((candidate) => {
const range = candidate as Partial<ModSecondaryRange>;
return {
statId: typeof range.statId === 'string' ? range.statId.trim() : '',
values: Object.fromEntries(
MOD_SECONDARY_QUALITIES.map((quality) => {
const values = range.values?.[quality] as
| Partial<ModSecondaryRange['values'][ModSecondaryQuality]>
| undefined;
return [
quality,
{
level1Min: Number(values?.level1Min),
level1Max: Number(values?.level1Max),
level20Min: Number(values?.level20Min),
level20Max: Number(values?.level20Max),
},
];
}),
) as ModSecondaryRange['values'],
};
});
if (
pool.some((range) => !range.statId) ||
new Set(pool.map((range) => range.statId)).size !== pool.length
) {
return {
pool: [],
error: 'Choose a unique Attribute for every shared pool option.',
};
}
if (
pool.some((range) =>
MOD_SECONDARY_QUALITIES.some((quality) => {
const values = range.values[quality];
return (
Object.values(values).some((value) => !Number.isFinite(value)) ||
values.level1Max < values.level1Min ||
values.level20Max < values.level20Min ||
values.level20Min < values.level1Min ||
values.level20Max < values.level1Max
);
}),
)
) {
return {
pool: [],
error:
'Configure valid Level 1 and Level 20 ranges for Specialized, Superior, and High-end quality.',
};
}
return { pool, error: null };
}
@@ -94,7 +94,7 @@ describe('AI mechanic authoring prompt', () => {
}),
);
expect(AI_MECHANIC_PROMPT_VERSION).toBe(7);
expect(AI_MECHANIC_PROMPT_VERSION).toBe(11);
expect(input.canonicalCatalog.grammar.outcome.optional).toEqual(
expect.arrayContaining(['delivery', 'durationSeconds']),
);
@@ -136,7 +136,7 @@ describe('AI mechanic authoring prompt', () => {
]),
);
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain(
'Prompt contract version: 7',
'Prompt contract version: 11',
);
const advertised = structuredClone(
@@ -185,7 +185,7 @@ describe('AI mechanic authoring prompt', () => {
expect(parsed.ok).toBe(true);
});
it('advertises the canonical Extra Health change event contract in version 7', () => {
it('advertises the canonical Extra Health change event contract in version 11', () => {
const input = JSON.parse(
buildAiMechanicAuthoringInput({
request,
@@ -196,9 +196,9 @@ describe('AI mechanic authoring prompt', () => {
}),
);
expect(AI_MECHANIC_PROMPT_VERSION).toBe(7);
expect(AI_MECHANIC_PROMPT_VERSION).toBe(11);
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain(
'Prompt contract version: 7',
'Prompt contract version: 11',
);
expect(input.canonicalCatalog.events).toContainEqual({
key: 'extra_health_changed',
@@ -222,6 +222,144 @@ describe('AI mechanic authoring prompt', () => {
});
});
it('advertises assigned-ability activity for kill and assist predicates', () => {
const input = JSON.parse(
buildAiMechanicAuthoringInput({
request,
attributes: [],
events: [
MECHANIC_EVENT_REGISTRY.enemy_killed,
MECHANIC_EVENT_REGISTRY.assist,
],
actions: [],
recipeKeys: [],
}),
);
for (const event of input.canonicalCatalog.events) {
expect(event.fields).toEqual(
expect.arrayContaining([
{
path: 'values.assignedAbilityActive',
label: 'Assigned ability active',
type: 'boolean',
},
]),
);
}
expect(input.examplesAndRules).toEqual(
expect.arrayContaining([
expect.stringContaining('values.assignedAbilityActive'),
]),
);
});
it('advertises and round-trips aimed event-target area damage', () => {
const protocolRequest: AiMechanicAuthoringRequest = {
...request,
context: {
sourceKind: 'os_protocol_talent',
sourceName: 'Untitled mechanic',
sourceDescription:
'Critical Hits performed during aiming create an explosion with a radius of 5 meters around the target that deals damage equal to 2000% Firepower to them.',
},
valueColumns: [{ key: 'base', variantKey: 'base', label: 'BASE' }],
allowProtocolUpgradeScaling: true,
};
const input = JSON.parse(
buildAiMechanicAuthoringInput({
request: protocolRequest,
attributes: [
{
id: 'firepower-id',
name: 'Firepower',
description: null,
unit: '',
type: null,
category: null,
calculationType: null,
},
],
events: [MECHANIC_EVENT_REGISTRY.weapon_hit],
actions: [],
recipeKeys: [],
}),
);
expect(input.canonicalCatalog.events[0].fields).toContainEqual({
path: 'values.aiming',
label: 'Aiming down sights',
type: 'boolean',
});
expect(
input.canonicalCatalog.grammar.outcome.targeting.eventTargetArea,
).toMatchObject({
kind: 'event_target_area',
center: 'event_target',
validTargets: 'enemies',
});
expect(input.examplesAndRules).toEqual(
expect.arrayContaining([
expect.stringContaining('values.aiming'),
expect.stringContaining('event_target_area'),
]),
);
const advertised = structuredClone(
input.canonicalCatalog.grammar.aimedCriticalAreaDamageShape,
);
const outcome = advertised.clauses[0].outcomes[0];
const base = JSON.parse(
JSON.stringify(
outcome.variants['replace-with-each-required-variant-key'],
).replaceAll(
'replace-with-an-exact-catalog-attribute-id',
'firepower-id',
),
);
outcome.variants = { base };
const parsed = parseConditionalMechanicDraft(advertised, {
variantKeys: ['base'],
allowedAttributeIds: new Set(['firepower-id']),
});
expect(parsed.ok).toBe(true);
});
it('advertises reproducible outcome and clause proc probabilities in version 11', () => {
const input = JSON.parse(
buildAiMechanicAuthoringInput({
request,
attributes: [],
events: [MECHANIC_EVENT_REGISTRY.skill_hit],
actions: [],
recipeKeys: [],
}),
);
expect(AI_MECHANIC_PROMPT_VERSION).toBe(11);
expect(input.canonicalCatalog.grammar.clause.optional).toEqual(
expect.arrayContaining([
'procChancePercent',
'requiresAssignedAbilityActive',
]),
);
expect(input.canonicalCatalog.grammar.mutationKinds).toEqual(
expect.arrayContaining(['extend_duration', 'start_cooldown_on_max']),
);
expect(
input.canonicalCatalog.grammar.probabilityOutcomeShape,
).toMatchObject({
probabilityPercent: 29,
resource: 'ammo',
});
expect(input.examplesAndRules).toEqual(
expect.arrayContaining([
expect.stringContaining('procChancePercent'),
expect.stringContaining('reproducible seeded random stream'),
]),
);
});
it('advertises authoritative current Health percentage with strict band semantics', () => {
const input = JSON.parse(
buildAiMechanicAuthoringInput({
@@ -351,4 +489,62 @@ describe('AI mechanic authoring prompt', () => {
});
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'),
]),
);
});
});
+117 -2
View File
@@ -3,7 +3,7 @@ import { CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES } from '$lib/calculations/mec
import { MECHANIC_COMBAT_REFERENCE_REGISTRY } from '$lib/calculations/mechanic';
import type { AiMechanicAttribute } from './mechanic-authoring-context';
export const AI_MECHANIC_PROMPT_VERSION = 7;
export const AI_MECHANIC_PROMPT_VERSION = 11;
const mechanicsGrammar = {
version: 1,
@@ -40,7 +40,14 @@ const mechanicsGrammar = {
},
clause: {
required: ['key', 'event', 'if', 'reducer', 'mutations', 'outcomes'],
optional: ['label', 'recipeKey', 'cooldownSeconds', 'weight'],
optional: [
'label',
'recipeKey',
'cooldownSeconds',
'weight',
'procChancePercent',
'requiresAssignedAbilityActive',
],
},
predicateKinds: ['true', 'exists', 'compare', 'all', 'any', 'not'],
compareOperators: ['eq', 'ne', 'gt', 'gte', 'lt', 'lte'],
@@ -63,6 +70,8 @@ const mechanicsGrammar = {
'derive',
'clear',
'transfer',
'extend_duration',
'start_cooldown_on_max',
],
entitySelectors: ['agent', 'event_actor', 'event_target', 'event_field'],
valueReferences: [
@@ -70,9 +79,11 @@ const mechanicsGrammar = {
'scenario',
'combat',
'equipment',
'assigned_ability',
'attribute',
'reducer',
'pool',
'variant_value',
],
combatReferences: MECHANIC_COMBAT_REFERENCE_REGISTRY,
numericExpressionOperators: [
@@ -85,7 +96,9 @@ const mechanicsGrammar = {
'clamp',
'floor',
],
draftOutcomeExpressionReferences: ['variant_value'],
referenceShapes: [
{ ref: { source: 'variant_value' } },
{ ref: { source: 'event', path: 'values.distanceMeters' } },
{ ref: { source: 'scenario', key: 'input-key' } },
{ ref: { source: 'combat', path: 'currentHealth', timing: 'current' } },
@@ -97,6 +110,7 @@ const mechanicsGrammar = {
},
},
{ ref: { source: 'equipment', path: 'protocolTalentUpgrades' } },
{ ref: { source: 'assigned_ability', path: 'remainingDurationSeconds' } },
{
ref: {
source: 'attribute',
@@ -200,6 +214,16 @@ const mechanicsGrammar = {
value: 'all',
clearSource: true,
},
{
kind: 'extend_duration',
target: 'assigned_ability',
seconds: 0.3,
},
{
kind: 'start_cooldown_on_max',
stackPool: 'extension-stacks',
cooldownPool: 'extension-cooldown',
},
],
outcome: CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES,
continuousRestorationOutcomeShape: {
@@ -232,6 +256,83 @@ const mechanicsGrammar = {
},
},
},
probabilityOutcomeShape: {
key: 'recover-ammo',
label: 'Recover Ammo',
phase: 'after_event',
kind: 'restoration',
scope: 'self',
basis: 'flat',
resource: 'ammo',
probabilityPercent: 29,
variants: {
'replace-with-each-required-variant-key': { value: 1 },
},
},
aimedCriticalAreaDamageShape: {
version: 1,
simulationReadiness: 'ready',
scenarioInputs: [],
pools: [],
clauses: [
{
key: 'aimed-critical-explosion',
label: 'Aimed critical hit explosion',
event: 'weapon_hit',
if: {
kind: 'all',
predicates: [
{ kind: 'exists', value: { source: 'event', path: 'targetId' } },
{
kind: 'compare',
left: { source: 'event', path: 'values.critical' },
operator: 'eq',
right: true,
},
{
kind: 'compare',
left: { source: 'event', path: 'values.aiming' },
operator: 'eq',
right: true,
},
],
},
reducer: { kind: 'none' },
mutations: [],
outcomes: [
{
key: 'critical-explosion-damage',
label: 'Critical explosion damage',
phase: 'after_event',
kind: 'direct_damage',
scope: 'target',
basis: 'flat',
targeting: {
kind: 'event_target_area',
center: 'event_target',
validTargets: 'enemies',
radiusMeters: 5,
},
variants: {
'replace-with-each-required-variant-key': {
value: 2000,
expression: {
op: 'multiply',
left: 20,
right: {
ref: {
source: 'attribute',
attributeId: 'replace-with-an-exact-catalog-attribute-id',
},
},
},
},
},
},
],
},
],
},
preciseTraumaShape: {
version: 1,
simulationReadiness: 'ready',
@@ -400,6 +501,7 @@ Security and scope:
- 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.
- 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.
Response rules:
@@ -409,6 +511,7 @@ Response rules:
- 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.
- 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 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.
@@ -453,9 +556,21 @@ 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.',
'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.',
'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.',
'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.',
'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.',
'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, requiresAssignedAbilityActive gates a clause on the runtime assigned-ability state. skill_deployed and signature_ability_deployed may supply values.assignedAbilityDurationSeconds to begin that state. At an exact expiry timestamp, the ability expires before a later clause can extend it.',
'procChancePercent is a clause-level independent percentage roll after its predicate and cooldown checks but before the reducer, mutations, and outcomes. Use it when a successful proc changes state. probabilityPercent is an outcome-level independent percentage roll for one outcome only.',
'Custom simulation uses a reproducible seeded random stream. Maximum Potential resolves every nonzero chance as successful. Never use clause weight as a proc chance.',
'extend_duration may target only assigned_ability and does nothing while the ability is inactive. start_cooldown_on_max starts a configured expiring Agent cooldown pool only when a mutation in the same clause transitions its capped stack pool to its maximum.',
'If cadence is not stated, ask rather than assuming a one-second interval.',
'If wording contains separate actions such as restoration on a periodic snapshot and a health cost on every shot, represent them as separate clauses.',
'A pool with expiresAfterSeconds refreshes the shared expiry for every current stack whenever any mutation writes that pool. Use it for mechanics where gaining a stack refreshes the duration of the full stack pool.',
@@ -9,21 +9,35 @@ describe('AI mechanic authoring rate limit', () => {
beforeEach(() => resetAiMechanicRateLimitForTests());
it('allows only one in-flight request per account', () => {
const release = reserveAiMechanicGeneration('agent-1', 1_000);
expect(() => reserveAiMechanicGeneration('agent-1', 1_001)).toThrow(
const release = reserveAiMechanicGeneration('agent-1', false, 1_000);
expect(() => reserveAiMechanicGeneration('agent-1', false, 1_001)).toThrow(
AiMechanicRateLimitError,
);
release();
expect(() => reserveAiMechanicGeneration('agent-1', 1_002)).not.toThrow();
expect(() =>
reserveAiMechanicGeneration('agent-1', false, 1_002),
).not.toThrow();
});
it('enforces the hourly account limit', () => {
for (let request = 0; request < 10; request += 1) {
const release = reserveAiMechanicGeneration('agent-1', 10_000 + request);
const release = reserveAiMechanicGeneration(
'agent-1',
false,
10_000 + request,
);
release();
}
expect(() => reserveAiMechanicGeneration('agent-1', 10_100)).toThrow(
expect(() => reserveAiMechanicGeneration('agent-1', false, 10_100)).toThrow(
'This account has reached the hourly mechanic authoring limit.',
);
});
it('does not limit administrators', () => {
const releases = Array.from({ length: 101 }, () =>
reserveAiMechanicGeneration('admin-1', true, 10_000),
);
for (const release of releases) release();
});
});

Some files were not shown because too many files have changed in this diff Show More