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
214 changed files with 33251 additions and 20597 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
+260
View File
@@ -926,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,
+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,
+79 -37
View File
@@ -896,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
@@ -1843,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
@@ -2390,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
@@ -2404,6 +2448,15 @@ applied to the stored value. Restoration outcomes also expose an `INSTANT` or
field and describes the configured value as the total delivered across that
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
@@ -2421,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,
@@ -2478,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.
@@ -2502,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
@@ -2595,33 +2660,10 @@ anchor scrolling and sticky-state transitions.
#### Builder calculation modes
The current Builder and shared Build pages do not expose calculation modes or
combat assumptions. The Builder summary rail additionally contains one local
experimental catalog baseline panel; shared Build pages do not render it. Keep
the panel in the summary rail only, with two independent cards labeled `PRIMARY`
and `SECONDARY`.
Use the exact eyebrow `WEAPON OUTPUT / CATALOG BASELINE` and show the
`EXPERIMENTAL` state. Each card displays the catalog weapon ID, selected damage,
catalog RPM, magazine, reload, rated body-hit throughput, and magazine-cycle
body-hit throughput. Include a visible formula and exclusions block with these
exact formulas:
- `selected damage × (RPM ÷ 60)` for rated body-hit throughput;
- `(magazine × selected damage) ÷ ((magazine ÷ (RPM ÷ 60)) + reload seconds)`
for magazine-cycle body-hit throughput.
The panel is evidence-only. It excludes inferred or substituted values, combat
profiles, effects, Talents, Attribute totals, calculation profiles, accuracy,
critical hits, headshots, enemy mitigation, target count, encounter uptime,
rotations, and simulation. It must not present these values as actual, expected,
or encounter DPS. When a required source value is unavailable or invalid, show
`NOT AVAILABLE` and list the missing source fields under the metric; never
display zero as a placeholder.
The panel does not alter the planned calculation modes or full combat panels.
Keep conditional Talent outcomes, weapon and Skill DPS, encounter assumptions,
combat assumptions. They show deterministic equipped-item totals and reserve
conditional Talent outcomes, weapon and Skill DPS, encounter assumptions,
activation profiles, rotations, and simulation behind explicit `COMING SOON`
panels. Calculation modes remain planned and use these visible labels:
panels.
Loadout schema version 6 retains `maximum`, `custom`, conditional state,
encounter-assumption, and per-ability activation fields. Version 4 is obsolete
-41
View File
@@ -1,41 +0,0 @@
# Weapon baseline prototype
## Scope
`calculateWeaponBaseline` is a pure, client-safe Builder helper. It records
weapon ID, name, and label, then reports three evidence-only metrics:
- `selectedDamage`: the supplied positive finite damage value.
- `ratedBodyHitThroughput`: `selectedDamage × rpm / 60`.
- `magazineCycleBodyHitThroughput`:
`(selectedDamage × magazine) / (magazine / (rpm / 60) + reloadSeconds)`.
Unknown, absent, non-positive, non-finite, or invalid source values make only
the metrics that depend on them unavailable. The helper preserves decimal inputs
and does not substitute values.
These are not actual, expected, or encounter DPS. They contain no hit
probability, critical or headshot outcomes, target state, or encounter timeline.
The rated metric is nominal continuous body-hit throughput; the magazine-cycle
metric includes the supplied reload interval.
## Excluded inputs
This helper does not read combat profiles, effects, talents, Attribute totals,
calculation profiles, encounter assumptions, or any other build inputs. It does
not call the simulation or combat engines.
`selectedDamage` is already the equipped weapon damage used at this boundary. It
is consumed exactly once; no additional level scaling or damage scaling is
applied.
## Missing data for a DPS model
A valid DPS model still needs the authoritative damage calculation and its
modifiers, hit and critical/headshot assumptions, firing and reload behavior,
target and encounter assumptions, and the timing and contribution of talents,
effects, Attributes, skills, and weapon swaps. Those data are outside this
baseline.
This is Builder-only, local prototype work. It does not define the player DPS
simulation contract or change the existing calculation architecture.
+18
View File
@@ -79,6 +79,24 @@ meters:
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
+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.
+49 -23
View File
@@ -25,16 +25,12 @@ The team builds the project in vertical slices.
Server code checks authorization for each mutation. UI visibility has no effect
on server authorization.
Builder and shared Build pages expose deterministic totals from the equipped
items, selected rolls, fixed bonuses, inherited profiles, augments, and active
set bonuses. Builder additionally shows an experimental catalog baseline for
each selected weapon: body-hit throughput from selected weapon damage and
catalog RPM, magazine, and reload values only. It is not expected or encounter
DPS, does not infer or substitute missing values, reports blocked metrics with
their source fields, and does not change shared Build behavior. Conditional
Talent outcomes, full weapon and Skill DPS, encounter assumptions, activation
profiles, rotations, and simulation remain planned. Explicit `COMING SOON`
panels identify those player-facing capabilities.
Builder and shared Build pages currently expose only deterministic totals from
the equipped items, selected rolls, fixed bonuses, inherited profiles, augments,
and active set bonuses. Conditional Talent outcomes, weapon and Skill DPS,
encounter assumptions, activation profiles, rotations, and simulation remain
planned. Explicit `COMING SOON` panels identify those player-facing
capabilities.
Weapon and gear range calculations must preserve decimal endpoints and follow
the tested progression rules in
@@ -642,12 +638,24 @@ 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. `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
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.
@@ -711,11 +719,20 @@ The program graph uses three child tables:
rolls separately. Restoration outcomes may deliver their total immediately or
continuously over an explicit duration.
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.
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.
@@ -804,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
@@ -900,8 +924,10 @@ 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 6 stores one focus, selected tree ranks, nine Skill
Chips, independent T1/T2 choices for each equipped gear and weapon Talent, a
+5
View File
@@ -0,0 +1,5 @@
{
"worktree": {
"setup": "npm install"
}
}
+51 -10
View File
@@ -2216,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'",
@@ -2235,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),
@@ -2244,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)',
@@ -2282,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),
@@ -2291,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)',
);
@@ -2329,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),
@@ -2338,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)',
);
@@ -2376,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),
@@ -2385,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)',
);
@@ -2427,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),
@@ -2436,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
@@ -2466,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)',
);
+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',
+250
View File
@@ -2818,6 +2818,24 @@ a.secondary:active {
font-size: var(--shd-type-telemetry);
line-height: 1.35;
}
.talent-icon-identity .game-data-render-upload .image-drop-input {
grid-template-columns: 1fr;
gap: 6px;
text-align: center;
}
.talent-icon-identity .game-data-render-upload .image-upload-preview {
width: 100%;
}
.talent-icon-identity .game-data-render-upload .image-upload-actions {
display: grid;
grid-template-columns: 1fr 1fr;
width: 100%;
}
.talent-icon-identity .game-data-render-upload .image-upload-actions button {
min-width: 0;
padding: 7px 3px;
font-size: var(--shd-type-telemetry);
}
.dashboard-row > .registry-render-identity {
display: flex;
align-items: center;
@@ -19386,3 +19404,235 @@ a.secondary:active {
grid-template-columns: 1fr;
}
}
/* Public mechanic behavior status: Variant A integrated card hierarchy. */
.database-item-name-line {
min-width: 0;
display: flex;
align-items: flex-start;
gap: 8px;
}
.database-item-name-line > h2 {
min-width: 0;
}
.database-mechanic-status {
flex: none;
display: inline-flex;
align-items: center;
min-height: 22px;
padding: 4px 6px;
border: 1px solid var(--shd-model-border);
background: var(--shd-model-surface);
color: var(--shd-info);
font-size: 10px;
font-weight: 900;
letter-spacing: 0.7px;
line-height: 1;
white-space: nowrap;
}
.database-mechanic-status.not-modeled {
border-color: var(--shd-border-strong);
background: var(--shd-surface-active);
color: var(--shd-text-muted);
}
.database-mechanic-groups {
display: grid;
gap: var(--shd-gap-sm);
margin: 10px 12px 0;
}
.database-mechanic-group {
min-width: 0;
display: grid;
gap: 6px;
}
.database-mechanic-group-context {
margin: 0;
color: var(--shd-text-muted);
font-size: var(--shd-type-telemetry);
font-weight: 800;
letter-spacing: 0.5px;
}
.database-mechanic-group-identity {
min-height: 48px;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
padding: 9px 10px;
border: 1px solid var(--shd-border);
background: var(--shd-surface-raised);
}
.database-mechanic-group-identity > div {
min-width: 0;
display: grid;
gap: 3px;
}
.database-mechanic-group-identity small,
.database-mechanic-behavior > small,
.database-mechanic-variant-label > span,
.database-mechanic-display-source dt {
color: var(--shd-text-dim);
font-size: 10px;
font-weight: 900;
letter-spacing: 0.8px;
line-height: 1.1;
}
.database-mechanic-group-identity strong {
overflow-wrap: anywhere;
color: var(--shd-text);
font-size: var(--shd-type-supporting);
line-height: 1.25;
}
.database-mechanic-variants {
display: grid;
gap: 6px;
}
.database-mechanic-variant {
min-width: 0;
display: grid;
grid-template-columns: 64px minmax(0, 1fr);
border: 1px solid var(--shd-model-border);
background: var(--shd-model-surface);
}
.database-mechanic-variant[data-status='not_modeled'] {
border-color: var(--shd-border);
background: var(--shd-surface-raised);
}
.database-mechanic-variant-label {
align-self: stretch;
display: grid;
align-content: start;
padding: 10px 8px;
border-right: 1px solid var(--shd-model-border);
}
.database-mechanic-variant[data-status='not_modeled'] .database-mechanic-variant-label {
border-color: var(--shd-border);
}
.database-mechanic-variant-label > span {
color: var(--shd-info);
}
.database-mechanic-variant[data-status='not_modeled'] .database-mechanic-variant-label > span {
color: var(--shd-text-muted);
}
.database-mechanic-behavior {
min-width: 0;
padding: 10px 12px;
border-left: 2px solid var(--shd-info);
}
.database-mechanic-variant[data-status='not_modeled'] .database-mechanic-behavior {
border-left-color: var(--shd-text-dim);
}
.database-mechanic-behavior > small {
display: block;
color: var(--shd-info);
}
.database-mechanic-variant[data-status='not_modeled'] .database-mechanic-behavior > small {
color: var(--shd-text-muted);
}
.database-mechanic-behavior > p {
margin: 6px 0 0;
color: var(--shd-text);
font: var(--shd-type-supporting)/1.5 var(--shd-font-editorial);
}
.database-mechanic-group:not(.detail) .database-mechanic-behavior > p {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
.database-mechanic-display-source {
margin: 0;
border: 1px solid var(--shd-border);
background: var(--shd-surface);
}
.database-mechanic-display-source > div {
display: grid;
gap: 4px;
padding: 9px 10px;
}
.database-mechanic-display-source dd {
margin: 0;
color: var(--shd-text-muted);
font-size: var(--shd-type-telemetry);
font-weight: 800;
letter-spacing: 0.5px;
}
.database-specialization-effect-groups {
padding-top: 9px;
border-top: 1px solid var(--shd-border);
}
.database-preview-dialog:has(.database-mechanic-groups) {
width: min(760px, 100%);
}
.database-global-results > div > a {
grid-template-columns: 110px minmax(0, 0.8fr) minmax(120px, 1.35fr) auto auto;
grid-template-areas: 'category identity behavior status navigation';
}
.database-global-results > div > a > small {
grid-area: category;
}
.database-global-results > div > a > .database-global-result-identity {
grid-area: identity;
}
.database-global-results .database-global-result-behavior {
display: block;
grid-area: behavior;
}
.database-global-results .database-mechanic-status {
grid-area: status;
justify-self: end;
}
.database-global-results .database-global-result-navigation {
grid-area: navigation;
color: var(--shd-orange);
}
@media (max-width: 750px) {
.database-item-name-line {
display: grid;
gap: 7px;
}
.database-item-name-line .database-mechanic-status {
width: max-content;
}
.database-mechanic-variant {
grid-template-columns: 1fr;
}
.database-mechanic-variant-label {
padding: 7px 10px;
border-right: 0;
border-bottom: 1px solid var(--shd-model-border);
}
.database-mechanic-variant[data-status='not_modeled'] .database-mechanic-variant-label {
border-color: var(--shd-border);
}
.database-mechanic-group-identity {
align-items: stretch;
flex-direction: column;
}
.database-mechanic-group-identity .database-mechanic-status {
width: max-content;
}
.database-global-results > div > a {
grid-template-columns: minmax(0, 1fr) auto;
grid-template-areas:
'category category'
'identity navigation'
'status navigation'
'behavior behavior';
}
.database-global-results .database-mechanic-status {
justify-self: start;
}
.database-global-results > div > a:not(:has(.database-mechanic-status)) {
grid-template-areas:
'category category'
'identity navigation'
'behavior behavior';
}
}
@media (prefers-reduced-motion: reduce) {
.database-mechanic-status,
.database-mechanic-variant {
transition: none;
}
}
+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,
+613 -71
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,82 +315,568 @@ describe('loadout mechanic timeline', () => {
).toEqual([1, 2]);
});
it('falls back to a documented Tier I mechanic when exotic 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,
isExotic: true,
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);
}
});
});
+255 -88
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) ??
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'
? conditionalProgram(equipped.rule, 'I')
: null)
);
? 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(
+83 -17
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,6 +36,24 @@ 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({
@@ -193,7 +212,6 @@ describe('summarizeLoadout', () => {
max_value: 45,
},
],
rule: null,
},
},
],
@@ -399,7 +417,6 @@ describe('summarizeLoadout', () => {
name: 'Recovery',
description_template: 'Restore {1}.',
effects: [{ stat_attribute_id: 'healing', base_value: 2.5 }],
rule: null,
},
},
],
@@ -536,19 +553,6 @@ describe('summarizeLoadout', () => {
exoticTalentTier: 1,
});
const rule = {
clauses: [
{
outcomes: [
{
kind: 'attribute_modifier',
attributeId: 'talent-stat',
value: 100,
},
],
},
],
};
const summary = summarizeLoadout(
draft,
catalog({
@@ -585,7 +589,6 @@ describe('summarizeLoadout', () => {
],
},
],
rule,
},
{
id: 'talent-two',
@@ -627,7 +630,7 @@ describe('summarizeLoadout', () => {
tier: 1,
tierLabel: 'TIER I',
description: 'Gain 10% for 2 Shots.',
rule,
runtime: null,
}),
expect.objectContaining({
id: 'talent-two',
@@ -645,6 +648,69 @@ describe('summarizeLoadout', () => {
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';
+20 -8
View File
@@ -4,6 +4,7 @@ import {
type LoadoutDraft,
type TalentTier,
} from '$lib/builder/loadout';
import type { MechanicRuntimeSelection } from '$lib/server/mechanic-runtime-selection';
export type LoadoutCatalog = {
attributes: any[];
@@ -38,7 +39,18 @@ export type LoadoutSummary = {
}>;
}>;
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;
@@ -164,7 +176,7 @@ 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,
@@ -260,9 +272,9 @@ 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,
?.runtime ?? null,
tier: selection.talentTier,
tierLabel: selection.talentTier === 2 ? 'TIER II' : 'TIER I',
isExotic: false,
@@ -336,9 +348,9 @@ export function summarizeLoadout(
equippedTalents.push({
key: `weapon:${index}:${equippedTalent.id}`,
id: equippedTalent.id,
rule:
runtime:
catalog.talents.find((entry) => entry.id === equippedTalent.id)
?.rule ?? null,
?.runtime ?? null,
tier: equippedTalent.tier,
tierLabel: equippedTalent.tier === 2 ? 'TIER II' : 'TIER I',
isExotic: equippedTalent.isExotic,
@@ -434,7 +446,7 @@ export function summarizeLoadout(
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.abilityId || null,
source: '3 Skill Chips',
@@ -486,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(
-146
View File
@@ -1,146 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
calculateWeaponBaseline,
type WeaponBaselineInput,
} from './weapon-baseline.js';
const input = (
overrides: Partial<WeaponBaselineInput> = {},
): WeaponBaselineInput => ({
weaponId: 'weapon-17',
weaponName: 'Evidence Rifle',
weaponLabel: 'PRIMARY',
selectedDamage: 12.75,
rpm: 137.5,
magazine: 31,
reloadSeconds: 2.35,
...overrides,
});
describe('calculateWeaponBaseline', () => {
it('preserves decimal inputs in the selected and rated metrics', () => {
const baseline = calculateWeaponBaseline(input());
expect(baseline.selectedDamage).toEqual({ value: 12.75, missing: [] });
expect(baseline.ratedBodyHitThroughput).toEqual({
value: 29.21875,
missing: [],
});
});
it('keeps rated throughput available when reload inputs are missing', () => {
const baseline = calculateWeaponBaseline(
input({ magazine: undefined, reloadSeconds: undefined }),
);
expect(baseline.ratedBodyHitThroughput).toEqual({
value: 29.21875,
missing: [],
});
expect(baseline.magazineCycleBodyHitThroughput).toEqual({
value: null,
missing: [
'magazine is unavailable for weapon weapon-17',
'reloadSeconds is unavailable for weapon weapon-17',
],
});
});
it('uses the complete magazine-cycle formula without rounding', () => {
const baseline = calculateWeaponBaseline(
input({
selectedDamage: 13.25,
rpm: 97.5,
magazine: 17.5,
reloadSeconds: 2.75,
}),
);
const expected = (13.25 * 17.5) / (17.5 / (97.5 / 60) + 2.75);
expect(baseline.magazineCycleBodyHitThroughput).toEqual({
value: expected,
missing: [],
});
});
it('uses selected damage exactly once at the no-double-scaling boundary', () => {
const baseline = calculateWeaponBaseline(
input({ selectedDamage: 12.5, rpm: 120, magazine: 10, reloadSeconds: 5 }),
);
expect(baseline.selectedDamage.value).toBe(12.5);
expect(baseline.ratedBodyHitThroughput.value).toBe(12.5 * (120 / 60));
expect(baseline.magazineCycleBodyHitThroughput.value).toBe(
(12.5 * 10) / (10 / (120 / 60) + 5),
);
expect(baseline.ratedBodyHitThroughput.value).not.toBe(
12.5 * 12.5 * (120 / 60),
);
expect(baseline.magazineCycleBodyHitThroughput.value).not.toBe(12.5 * 12.5);
});
it.each([
null,
undefined,
0,
-1,
Number.NaN,
Number.POSITIVE_INFINITY,
Number.NEGATIVE_INFINITY,
])('rejects invalid selected damage: %s', (selectedDamage) => {
const baseline = calculateWeaponBaseline(input({ selectedDamage }));
expect(baseline.selectedDamage.value).toBeNull();
expect(baseline.ratedBodyHitThroughput.value).toBeNull();
expect(baseline.magazineCycleBodyHitThroughput.value).toBeNull();
expect(baseline.selectedDamage.missing[0]).toContain('selectedDamage');
expect(baseline.selectedDamage.missing[0]).toContain('weapon-17');
for (const metric of [
baseline.selectedDamage,
baseline.ratedBodyHitThroughput,
baseline.magazineCycleBodyHitThroughput,
]) {
expect(metric.value).toBeNull();
expect(metric.value).not.toBeNaN();
expect(metric.value).not.toBe(Infinity);
expect(metric.value).not.toBe(-Infinity);
}
});
it.each([
['rpm', null],
['rpm', 0],
['rpm', Number.NaN],
['magazine', -1],
['reloadSeconds', Number.POSITIVE_INFINITY],
] as const)(
'makes only dependent metrics unavailable for invalid %s',
(field, value) => {
const baseline = calculateWeaponBaseline(input({ [field]: value }));
expect(baseline.selectedDamage.value).toBe(12.75);
if (field === 'rpm') {
expect(baseline.ratedBodyHitThroughput.value).toBeNull();
expect(baseline.magazineCycleBodyHitThroughput.value).toBeNull();
} else if (field === 'magazine' || field === 'reloadSeconds') {
expect(baseline.ratedBodyHitThroughput.value).toBe(29.21875);
expect(baseline.magazineCycleBodyHitThroughput.value).toBeNull();
}
const dependent =
field === 'rpm'
? [
baseline.ratedBodyHitThroughput,
baseline.magazineCycleBodyHitThroughput,
]
: [baseline.magazineCycleBodyHitThroughput];
for (const metric of dependent) {
expect(metric.value).not.toBeNaN();
expect(metric.value).not.toBe(Infinity);
expect(metric.value).not.toBe(-Infinity);
expect(metric.missing[0]).toContain(field);
expect(metric.missing[0]).toContain('weapon-17');
}
},
);
});
-112
View File
@@ -1,112 +0,0 @@
type NumericSource = number | null | undefined;
export type WeaponBaselineInput = {
weaponId: string;
weaponName: string;
weaponLabel: string;
selectedDamage?: NumericSource;
rpm?: NumericSource;
magazine?: NumericSource;
reloadSeconds?: NumericSource;
};
export type BaselineMetric = {
value: number | null;
missing: string[];
};
export type WeaponBaseline = {
weaponId: string;
weaponName: string;
weaponLabel: string;
selectedDamage: BaselineMetric;
ratedBodyHitThroughput: BaselineMetric;
magazineCycleBodyHitThroughput: BaselineMetric;
};
const isPositiveFinite = (value: NumericSource): value is number =>
typeof value === 'number' && Number.isFinite(value) && value > 0;
const missingField = (field: string, weaponId: string): string =>
`${field} is unavailable for weapon ${weaponId}`;
const unavailableMetric = (
fields: string[],
weaponId: string,
): BaselineMetric => ({
value: null,
missing: fields.map((field) => missingField(field, weaponId)),
});
const calculatedMetric = (
value: number,
metricField: string,
weaponId: string,
): BaselineMetric =>
Number.isFinite(value)
? { value, missing: [] }
: { value: null, missing: [missingField(metricField, weaponId)] };
export function calculateWeaponBaseline(
input: WeaponBaselineInput,
): WeaponBaseline {
const damageAvailable = isPositiveFinite(input.selectedDamage);
const rpmAvailable = isPositiveFinite(input.rpm);
const magazineAvailable = isPositiveFinite(input.magazine);
const reloadAvailable = isPositiveFinite(input.reloadSeconds);
const damage = damageAvailable ? (input.selectedDamage as number) : null;
const rpm = rpmAvailable ? (input.rpm as number) : null;
const magazine = magazineAvailable ? (input.magazine as number) : null;
const reloadSeconds = reloadAvailable
? (input.reloadSeconds as number)
: null;
const selectedDamage =
damage !== null
? calculatedMetric(damage, 'selectedDamage', input.weaponId)
: unavailableMetric(['selectedDamage'], input.weaponId);
const ratedBodyHitThroughput =
damage !== null && rpm !== null
? calculatedMetric(
(damage * rpm) / 60,
'ratedBodyHitThroughput',
input.weaponId,
)
: unavailableMetric(
[
...(!damageAvailable ? ['selectedDamage'] : []),
...(!rpmAvailable ? ['rpm'] : []),
],
input.weaponId,
);
const magazineCycleBodyHitThroughput =
damage !== null &&
rpm !== null &&
magazine !== null &&
reloadSeconds !== null
? calculatedMetric(
(damage * magazine) / (magazine / (rpm / 60) + reloadSeconds),
'magazineCycleBodyHitThroughput',
input.weaponId,
)
: unavailableMetric(
[
...(!damageAvailable ? ['selectedDamage'] : []),
...(!rpmAvailable ? ['rpm'] : []),
...(!magazineAvailable ? ['magazine'] : []),
...(!reloadAvailable ? ['reloadSeconds'] : []),
],
input.weaponId,
);
return {
weaponId: input.weaponId,
weaponName: input.weaponName,
weaponLabel: input.weaponLabel,
selectedDamage,
ratedBodyHitThroughput,
magazineCycleBodyHitThroughput,
};
}
@@ -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 } : {}),
};
}
@@ -2,11 +2,13 @@ import { describe, expect, it } from 'vitest';
import {
currentHealthPercentBandPredicate,
eventFieldComparisonPredicate,
eventFieldNumericExpression,
mechanicTargetingForMode,
mechanicTargetingMode,
parseConditionalMechanicDraft,
readCurrentHealthPercentBand,
readEventFieldComparison,
readEventFieldNumericExpression,
registeredEventFields,
readStatePoolThreshold,
statePoolThresholdPredicate,
@@ -202,6 +204,108 @@ describe('conditional mechanic authoring payload', () => {
});
});
it('round-trips the registered proximity count as a derived scalar and resolves each variant display value', () => {
const path = 'values.alliesWithin30MetersWithAgentGrantedBuff';
expect(registeredEventFields('proximity_snapshot')).toContainEqual({
path,
label: 'Allies within 30 meters with an Agent-granted buff',
type: 'number',
minimum: 0,
integer: true,
});
const expression = eventFieldNumericExpression(path);
expect(readEventFieldNumericExpression(expression)).toBe(path);
expect(
readEventFieldNumericExpression({
op: 'multiply',
left: expression,
right: 2,
}),
).toBeNull();
const value = draft();
value.clauses[0].event = 'proximity_snapshot';
value.clauses[0].recipeKey = undefined;
value.clauses[0].reducer = {
kind: 'derived_scalar',
value: expression,
};
value.clauses[0].mutations = [
{
kind: 'derive',
pool: 'effect',
value: { ref: { source: 'reducer' } },
},
];
const upgradeScale = {
op: 'add' as const,
left: 1,
right: {
op: 'divide' as const,
left: {
ref: {
source: 'equipment' as const,
path: 'protocolTalentUpgrades',
},
},
right: 5,
},
};
value.clauses[0].outcomes[0].variants = {
I: {
value: 7.25,
expression: {
op: 'multiply',
left: { ref: { source: 'variant_value' } },
right: upgradeScale,
},
},
II: {
value: 9.5,
expression: {
op: 'multiply',
left: { ref: { source: 'variant_value' } },
right: upgradeScale,
},
},
};
const result = parseConditionalMechanicDraft(value, {
variantKeys: ['I', 'II'],
allowedAttributeIds: new Set(['weapon-damage']),
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.draft.clauses[0]).toMatchObject({
event: 'proximity_snapshot',
reducer: { kind: 'derived_scalar', value: expression },
outcomes: [
{
variants: {
I: {
value: 7.25,
expression: {
left: { ref: { source: 'variant_value' } },
},
},
},
},
],
});
expect(result.value.programs.I.clauses[0].outcomes?.[0].value).toEqual({
op: 'multiply',
left: 7.25,
right: upgradeScale,
});
expect(result.value.programs.II.clauses[0].outcomes?.[0].value).toEqual({
op: 'multiply',
left: 9.5,
right: upgradeScale,
});
});
it('exposes assigned-ability activity for kill and assist event conditions', () => {
expect(registeredEventFields('enemy_killed')).toEqual(
expect.arrayContaining([
+105 -3
View File
@@ -39,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<
@@ -200,6 +219,21 @@ 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'
@@ -452,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,
@@ -511,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);
}
@@ -838,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.`,
@@ -954,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,
@@ -1002,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,
@@ -912,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,
});
});
});
+54 -6
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;
@@ -257,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;
@@ -673,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 &&
@@ -689,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.`,
@@ -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
+269
View File
@@ -19,6 +19,84 @@ const custom = (
inputs,
});
const closeAndPersonalProgram = (
resolvedVariantValue: number,
): MechanicProgram => ({
id: 'close-and-personal-engineering',
name: 'Close and Personal Engineering',
pools: [
{
key: 'eligible-allies',
label: 'Eligible Allies',
scope: 'agent',
initial: 0,
min: 0,
max: 20,
},
],
clauses: [
{
id: 'sample-eligible-allies',
when: 'proximity_snapshot',
reducer: {
kind: 'derived_scalar',
value: {
ref: {
source: 'event',
path: 'values.alliesWithin30MetersWithAgentGrantedBuff',
},
},
},
mutations: [
{
kind: 'derive',
pool: 'eligible-allies',
value: { ref: { source: 'reducer' } },
},
],
},
{
id: 'resolve-engineering',
when: 'tick',
reducer: { kind: 'none' },
mutations: [{ kind: 'none' }],
outcomes: [
{
id: 'engineering',
label: 'Engineering',
phase: 'on_event',
kind: 'attribute_modifier',
scope: 'self',
basis: 'flat',
attributeId: 'engineering',
value: {
op: 'multiply',
left: {
op: 'multiply',
left: { ref: { source: 'pool', pool: 'eligible-allies' } },
right: resolvedVariantValue,
},
right: {
op: 'add',
left: 1,
right: {
op: 'divide',
left: {
ref: {
source: 'equipment',
path: 'protocolTalentUpgrades',
},
},
right: 5,
},
},
},
},
],
},
],
});
describe('deterministic mechanic probabilities and assigned abilities', () => {
it('rolls outcome probability independently with a reproducible seed and preserves a refreshed window', () => {
const program: MechanicProgram = {
@@ -321,6 +399,23 @@ describe('structured mechanic runtime', () => {
});
});
it('advertises the optional non-negative integer proximity count without making snapshots strict', () => {
const definition = MECHANIC_EVENT_REGISTRY.proximity_snapshot;
const countField = definition.fields?.find(
({ path }) => path === 'values.alliesWithin30MetersWithAgentGrantedBuff',
);
expect(definition.strictFields).not.toBe(true);
expect(countField).toEqual({
path: 'values.alliesWithin30MetersWithAgentGrantedBuff',
label: 'Allies within 30 meters with an Agent-granted buff',
type: 'number',
minimum: 0,
integer: true,
});
expect(countField?.required).not.toBe(true);
});
it('advertises imported support and equipment-use trigger events', () => {
expect(MECHANIC_EVENT_REGISTRY.ally_downed).toMatchObject({
key: 'ally_downed',
@@ -1260,6 +1355,104 @@ describe('structured mechanic runtime', () => {
expect(maximum.finalState[0].value).toBe(4);
});
it('preserves an explicit zero clause weight', () => {
const result = evaluateMechanicProgram(
{
id: 'zero-weight',
name: 'Zero weight',
clauses: [
{
id: 'zero-weight-clause',
when: 'agent_hit',
reducer: { kind: 'none' },
weight: 0,
mutations: [],
},
],
},
[{ kind: 'agent_hit', at: 0 }],
custom(1),
);
expect(result.emissions).toHaveLength(1);
expect(result.emissions[0].weight).toBe(0);
expect(result.estimatedActivationCount).toBe(0);
});
it.each([0, 0.5])(
'keeps a weighted reload delay intact at weight %s',
(weight) => {
const result = evaluateMechanicProgram(
{
id: 'weighted-reload',
name: 'Weighted reload',
clauses: [
{
id: 'weighted-reload-clause',
when: 'weapon_hit',
reducer: { kind: 'none' },
weight,
mutations: [],
outcomes: [
{
id: 'reload',
label: 'Reload',
phase: 'on_event',
kind: 'weapon_reload',
scope: 'equipped_weapon',
value: 5,
basis: 'flat',
},
],
},
],
},
[{ kind: 'weapon_hit', at: 1 }],
custom(10),
);
expect(result.outcomes).toHaveLength(1);
expect(result.outcomes[0]).toMatchObject({
at: 6,
weight,
resolvedValue: 5,
appliedValue: 5,
});
},
);
it('preserves a zero weight for maximum-mode passive outcomes', () => {
const result = evaluateMechanicProgram(
{
id: 'zero-weight-maximum',
name: 'Zero weight maximum',
clauses: [
{
id: 'zero-weight-maximum-clause',
label: 'Maximum passive',
when: 'weapon_hit',
reducer: { kind: 'none' },
weight: 0,
mutations: [],
outcomes: [
{
id: 'marker',
label: 'Passive marker',
phase: 'while_active',
kind: 'marker',
scope: 'self',
value: 1,
basis: 'flat',
},
],
},
],
},
[],
{ mode: 'maximum', encounterSeconds: 10 },
);
expect(result.outcomes).toHaveLength(1);
expect(result.outcomes[0]).toMatchObject({ weight: 0, appliedValue: 0 });
});
it('derives proximity stacks directly from a builder-controlled snapshot', () => {
const program: MechanicProgram = {
id: 'nearby-allies',
@@ -1306,6 +1499,7 @@ describe('structured mechanic runtime', () => {
],
custom(),
);
expect(result.status).toBe('OK');
expect(result.finalState[0].value).toBe(2);
const maximum = evaluateMechanicProgram(program, [], {
mode: 'maximum',
@@ -1314,6 +1508,81 @@ describe('structured mechanic runtime', () => {
expect(maximum.finalState[0].value).toBe(3);
});
it.each([
{ count: 0, upgrades: 0, multiplier: 0 },
{ count: 3, upgrades: 0, multiplier: 3 },
{ count: 3, upgrades: 5, multiplier: 6 },
])(
'resolves Close-and-Personal-style Engineering for count $count and $upgrades OS upgrades',
({ count, upgrades, multiplier }) => {
const resolvedVariantValue = 7.25;
const result = evaluateMechanicProgram(
closeAndPersonalProgram(resolvedVariantValue),
[
{
kind: 'proximity_snapshot',
at: 0,
values: {
alliesWithin30MetersWithAgentGrantedBuff: count,
},
},
{ kind: 'tick', at: 1 },
],
custom(),
undefined,
{ protocolTalentUpgrades: upgrades },
);
expect(result.status).toBe('OK');
expect(result.finalState).toContainEqual(
expect.objectContaining({ pool: 'eligible-allies', value: count }),
);
expect(
result.outcomes.find(({ outcomeId }) => outcomeId === 'engineering'),
).toMatchObject({
value: resolvedVariantValue * multiplier,
resolvedValue: resolvedVariantValue * multiplier,
});
},
);
it.each([
{
count: '2',
error:
'Allies within 30 meters with an Agent-granted buff must be a number.',
},
{
count: -1,
error:
'Allies within 30 meters with an Agent-granted buff must be 0 or greater.',
},
{
count: 1.5,
error:
'Allies within 30 meters with an Agent-granted buff must be an integer.',
},
])('rejects invalid proximity ally count $count', ({ count, error }) => {
const result = evaluateMechanicProgram(
closeAndPersonalProgram(7.25),
[
{
kind: 'proximity_snapshot',
at: 0,
values: {
alliesWithin30MetersWithAgentGrantedBuff: count,
},
},
],
custom(),
undefined,
{ protocolTalentUpgrades: 0 },
);
expect(result.status).toBe('INVALID');
expect(result.errors).toContain(`Event 1 (Proximity snapshot) ${error}`);
});
it('shares one movement charge pool across generation and consumption clauses', () => {
const program: MechanicProgram = {
id: 'mobile-charge',
+37 -6
View File
@@ -28,6 +28,13 @@ export const MECHANIC_EVENT_KINDS = [
'tick',
] as const;
export const PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_VALUE_KEY =
'alliesWithin30MetersWithAgentGrantedBuff' as const;
export const PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_FIELD =
`values.${PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_VALUE_KEY}` as const;
export const PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_INPUT =
'allies-within-30m-with-agent-granted-buff' as const;
export type MechanicEventKind = (typeof MECHANIC_EVENT_KINDS)[number];
export type MechanicEventDefinition = {
key: MechanicEventKind;
@@ -39,6 +46,7 @@ export type MechanicEventDefinition = {
type: 'boolean' | 'number' | 'string';
required?: boolean;
minimum?: number;
integer?: boolean;
}[];
strictFields?: boolean;
};
@@ -308,6 +316,15 @@ export const MECHANIC_EVENT_REGISTRY: Readonly<
label: 'Proximity snapshot',
description:
'A sampled count or state for nearby allies, enemies, and Agent-owned buffs.',
fields: [
{
path: PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_FIELD,
label: 'Allies within 30 meters with an Agent-granted buff',
type: 'number',
minimum: 0,
integer: true,
},
],
},
firing_started: {
key: 'firing_started',
@@ -1616,8 +1633,10 @@ function emitOutcomes(
}
const targetIds = outcomeTargetIds(clause, outcome, context);
if (!targetIds) continue;
const emittedAt =
outcome.kind === 'weapon_reload' ? at + Math.max(0, appliedValue) : at;
if (outcome.kind === 'weapon_reload') {
appliedValue = Math.max(0, resolvedValue);
}
const emittedAt = outcome.kind === 'weapon_reload' ? at + appliedValue : at;
for (const targetId of targetIds) {
emissions.push({
clauseId: clause.id,
@@ -2049,6 +2068,13 @@ function validateMechanicEvents(events: MechanicEvent[]) {
) {
errors.push(`${fieldPath} must be ${field.minimum} or greater.`);
}
if (
field.type === 'number' &&
field.integer &&
!Number.isInteger(value)
) {
errors.push(`${fieldPath} must be an integer.`);
}
}
const hasExtraHealth = getPath(event, 'values.hasExtraHealth');
const currentExtraHealth = getPath(event, 'values.currentExtraHealth');
@@ -3091,6 +3117,10 @@ export function evaluateMechanicProgram(
.filter((pool): pool is string => Boolean(pool))
.map((pool) => [pool, stateValue(pool, undefined, context)]),
);
const weight =
clause.weight !== undefined
? clamp(numericValue(clause.weight, context), 0, 1)
: 1;
appendScheduledOutcomes(
outcomes,
emitOutcomes(
@@ -3102,7 +3132,7 @@ export function evaluateMechanicProgram(
},
'after',
0,
1,
weight,
before,
context,
continuousDeliveries,
@@ -3224,9 +3254,10 @@ export function evaluateMechanicProgram(
for (const reduction of reduced) {
if (reduction.at + EPSILON < (cooldowns.get(stateKey) ?? 0)) continue;
const context = { ...baseContext, reducerValue: reduction.value };
const weight = clause.weight
? clamp(numericValue(clause.weight, context), 0, 1)
: 1;
const weight =
clause.weight !== undefined
? clamp(numericValue(clause.weight, context), 0, 1)
: 1;
const relevantPools = new Set(
(clause.outcomes ?? [])
.map((outcome) => outcome.statePool)
+38 -6
View File
@@ -7,15 +7,20 @@
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);
@@ -29,6 +34,14 @@
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'} 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'}>
@@ -39,7 +52,12 @@
</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">
@@ -53,7 +71,7 @@
{#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}
{#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>
@@ -86,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 if !item.protocolPreview}
{: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}
+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}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+292 -156
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) => {
@@ -43,16 +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 currentSharedSkillChipProfiles = $derived((Array.isArray(currentSnapshot) ? currentSnapshot : []) as any[]);
const proposedSharedSkillChipProfiles = $derived((jsonValue(proposed.profiles) ?? []) as any[]);
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 specializationFields = $derived(
action === 'saveProgression'
? [
@@ -116,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();
@@ -156,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>
@@ -281,6 +353,42 @@
</div>
</article>
</div>
{:else if phase2OwnerPreview}
<div class="proposal-compare-grid phase2-owner-preview">
<article class="proposal-editor-preview before">
<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 · {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">
@@ -292,35 +400,6 @@
<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 === 'skillchips' && action === 'save'}
<div class="proposal-compare-grid">
<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>3-PIECE TALENT</span><strong>{currentSnapshot?.talent ? 'ASSIGNED' : 'NOT ASSIGNED'}</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}
</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>3-PIECE TALENT</span><strong>{proposed.hasTalent === '1' ? 'ASSIGNED' : 'NOT ASSIGNED'}</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>
</article>
</div>
{:else if module === 'calculations'}
<div class="proposal-compare-grid calculation-proposal-preview">
<article class="proposal-editor-preview before">
@@ -333,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>
@@ -352,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}
@@ -385,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}
@@ -411,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>
@@ -1,97 +0,0 @@
<script lang="ts">
import type { WeaponBaseline } from '$lib/builder/weapon-baseline';
type WeaponBaselineEntry = {
slot: 'PRIMARY' | 'SECONDARY';
id: string;
name: string;
damage: number | null;
rpm: number | null;
magazine: number | null;
reloadSpeed: number | null;
baseline: WeaponBaseline;
};
let { entries, headingId }: { entries: WeaponBaselineEntry[]; headingId: string } = $props();
const numberValue = (value: number | null, suffix = '') =>
value === null || !Number.isFinite(value) || value <= 0 ? 'NOT AVAILABLE' : `${value.toLocaleString(undefined, { maximumFractionDigits: 2 })}${suffix}`;
const metricValue = (metric: { value: number | null; missing: string[] }) => numberValue(metric.value, ' /s');
</script>
<section class="weapon-baseline" aria-labelledby={headingId}>
<header class="baseline-head">
<div>
<p class="eyebrow"><span></span> WEAPON OUTPUT / CATALOG BASELINE</p>
<h2 id={headingId}>Weapon output</h2>
</div>
<strong>EXPERIMENTAL</strong>
</header>
<p class="baseline-intro">Strict evidence-only throughput from the selected catalog weapon and selected damage.</p>
<div class="baseline-results" aria-live="polite" aria-atomic="false">
{#each entries as entry}
<article class="weapon-result" aria-labelledby={`${headingId}-${entry.slot.toLowerCase()}`}>
<header class="weapon-result-head">
<div>
<span>{entry.slot}</span>
<h3 id={`${headingId}-${entry.slot.toLowerCase()}`}>{entry.name}</h3>
</div>
<code>{entry.id || 'NO CATALOG ID'}</code>
</header>
<div class="baseline-inputs" aria-label={`${entry.slot} baseline inputs`}>
<span><small>SELECTED DAMAGE</small><b>{numberValue(entry.baseline.selectedDamage.value)}</b></span>
<span><small>RPM</small><b>{numberValue(entry.rpm)}</b></span>
<span><small>MAGAZINE</small><b>{numberValue(entry.magazine, ' SHOTS')}</b></span>
<span><small>RELOAD</small><b>{numberValue(entry.reloadSpeed, ' SEC')}</b></span>
</div>
<div class="baseline-metrics">
{#each [
{ label: 'RATED BODY-HIT THROUGHPUT', metric: entry.baseline.ratedBodyHitThroughput },
{ label: 'MAGAZINE-CYCLE BODY-HIT THROUGHPUT', metric: entry.baseline.magazineCycleBodyHitThroughput }
] as output}
<div class:unavailable={output.metric.value === null}>
<span>{output.label}<small>PER SECOND</small></span>
<strong>{metricValue(output.metric)}</strong>
{#if output.metric.value === null}
<p class="missing-label">MISSING FIELDS</p>
<ul>{#each output.metric.missing as field}<li>{field}</li>{/each}</ul>
{/if}
</div>
{/each}
</div>
</article>
{/each}
</div>
<div class="baseline-contract">
<p><b>FORMULAS</b> Selected damage × (RPM ÷ 60)</p>
<p>Magazine × selected damage ÷ ((Magazine ÷ (RPM ÷ 60)) + reload seconds)</p>
<p>This does not calculate accuracy, critical hits, headshots, Talents, enemy mitigation, rotations, target count, or encounter uptime.</p>
</div>
<p class="baseline-source">Source provenance: each result shows its catalog weapon ID above.</p>
</section>
<style>
.weapon-baseline{display:grid;gap:var(--shd-gap-md);padding:var(--shd-gap-md);border:1px solid var(--shd-model-border);border-top:2px solid var(--shd-orange);background:var(--shd-model-surface);min-width:0}
.baseline-head,.weapon-result-head{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--shd-gap-md)}
.baseline-head>div,.weapon-result-head>div{display:grid;gap:3px;min-width:0}
.eyebrow,.baseline-head p{margin:0;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1px}
.eyebrow span{display:inline-block;width:6px;height:6px;margin-right:4px;background:var(--shd-orange)}
h2,h3{margin:0;text-transform:uppercase;letter-spacing:.7px;color:var(--shd-text)}
h2{font-size:var(--shd-type-body)}h3{font-size:var(--shd-type-supporting)}
.baseline-head>strong{padding:5px 7px;border:1px solid var(--shd-orange);color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.8px;white-space:nowrap}
.baseline-intro,.baseline-contract p,.baseline-source{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.45}
.baseline-results{display:grid;gap:var(--shd-gap-sm)}
.weapon-result{display:grid;gap:var(--shd-gap-sm);padding:var(--shd-gap-sm);border:1px solid var(--shd-border);background:var(--shd-surface)}
.weapon-result-head span,.baseline-inputs small,.baseline-metrics span,.missing-label{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}
.weapon-result-head code{max-width:45%;overflow:hidden;color:var(--shd-text-dim);font:var(--shd-type-telemetry) var(--shd-font-mono);text-overflow:ellipsis;white-space:nowrap}
.baseline-inputs{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));border-block:1px solid var(--shd-border-soft)}
.baseline-inputs span{display:grid;gap:3px;padding:7px;border-left:1px solid var(--shd-border-soft);min-width:0}.baseline-inputs span:first-child{border-left:0}
.baseline-inputs b{font-size:var(--shd-type-supporting);font-weight:700;overflow-wrap:anywhere}
.baseline-metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--shd-gap-sm)}
.baseline-metrics>div{display:grid;gap:5px;padding:9px;border-left:2px solid var(--shd-success);background:var(--shd-bg);min-width:0}.baseline-metrics>div.unavailable{border-left-color:var(--shd-danger)}
.baseline-metrics span{display:grid;gap:2px}.baseline-metrics span small{color:var(--shd-text-dim);font-size:10px}.baseline-metrics strong{font-size:18px;color:var(--shd-success)}.baseline-metrics .unavailable strong{color:var(--shd-danger)}
.missing-label{margin:2px 0 0;color:var(--shd-danger)}.baseline-metrics ul{margin:0;padding-left:17px;color:var(--shd-danger);font:var(--shd-type-telemetry)/1.4 var(--shd-font-mono)}
.baseline-contract{display:grid;gap:5px;padding-top:var(--shd-gap-sm);border-top:1px solid var(--shd-border)}.baseline-contract b{color:var(--shd-text);letter-spacing:.7px}.baseline-source{font-size:var(--shd-type-telemetry)}
@media(max-width:620px){.baseline-head{display:grid}.baseline-inputs{grid-template-columns:repeat(2,minmax(0,1fr))}.baseline-inputs span:nth-child(odd){border-left:0}.baseline-inputs span:nth-child(n+3){border-top:1px solid var(--shd-border-soft)}.baseline-metrics{grid-template-columns:1fr}.weapon-result-head code{max-width:50%}}
@media(prefers-reduced-motion:reduce){.weapon-baseline *{scroll-behavior:auto;transition:none!important;animation:none!important}}
</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,15 +9,18 @@
blankConditionalMechanicDraft,
currentHealthPercentBandPredicate,
eventFieldComparisonPredicate,
eventFieldNumericExpression,
mechanicTargetingForMode,
mechanicTargetingMode,
parseConditionalMechanicDraft,
readCurrentHealthPercentBand,
readEventFieldComparison,
readEventFieldNumericExpression,
registeredEventFields,
readStatePoolThreshold,
statePoolThresholdPredicate,
type ConditionalMechanicClauseDraft,
type ConditionalNumericExpression,
type ConditionalMechanicDraftV1,
type ConditionalMechanicOutcomeDraft,
type ConditionalMechanicPoolDraft,
@@ -31,6 +34,7 @@
formatMechanicProgram,
MECHANIC_EVENT_KINDS,
MECHANIC_EVENT_REGISTRY,
PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_FIELD,
type ClauseMutation,
type MechanicOutcomeScope,
type NumericExpression,
@@ -408,6 +412,40 @@
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 };
@@ -517,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' }
@@ -534,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,
@@ -578,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
@@ -596,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
@@ -657,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)
@@ -671,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;
}
@@ -698,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',
@@ -743,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;
}
@@ -802,7 +850,7 @@
updateClause(clauseIndex, { mutations });
}
function updateMutation(clauseIndex: number, mutationIndex: number, kind: 'add' | 'set' | 'consume' | 'clear', pool?: string, value?: number) {
function updateMutation(clauseIndex: number, mutationIndex: number, kind: 'add' | 'set' | 'consume' | 'derive' | 'clear', pool?: string, value?: number) {
const mutations = [...rule.clauses[clauseIndex].mutations];
const current = mutations[mutationIndex];
const nextPool = pool ?? ('pool' in current ? current.pool : rule.pools[0]?.key ?? '');
@@ -812,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 });
}
@@ -1134,7 +1184,29 @@
{: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>
@@ -1159,9 +1231,9 @@
<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')}
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')}
/>
<SegmentedChoice
legend="STATE POOL"
@@ -1169,10 +1241,12 @@
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])}
onchange={(values) => updateMutation(clauseIndex,mutationIndex,mutation.kind === 'clear' ? 'clear' : mutation.kind === 'derive' ? 'derive' : mutation.kind === 'set' || mutation.kind === 'consume' ? mutation.kind : 'add',values[0])}
/>
{#if mutation.kind !== 'clear'}
{#if mutation.kind !== 'clear' && mutation.kind !== 'derive'}
<label class="field">VALUE<input type="number" step="any" value={'value' in mutation && typeof mutation.value === 'number' ? mutation.value : 1} oninput={(event) => updateMutation(clauseIndex,mutationIndex,mutation.kind === 'set' || mutation.kind === 'consume' ? mutation.kind : 'add',undefined,Number(event.currentTarget.value))}/></label>
{: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
+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');
});
});
+48
View File
@@ -2,6 +2,53 @@ 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;
@@ -43,6 +90,7 @@ export type DatabaseItem = {
afterLines?: DatabaseFact[];
iconGroups?: DatabaseIconGroup[];
weaponModSlots?: DatabaseWeaponModSlot[];
mechanicGroups?: DatabaseMechanicGroup[];
description?: string;
secondaryDescription?: string;
descriptionStyle?: 'prose' | 'data';
@@ -94,7 +94,7 @@ describe('AI mechanic authoring prompt', () => {
}),
);
expect(AI_MECHANIC_PROMPT_VERSION).toBe(10);
expect(AI_MECHANIC_PROMPT_VERSION).toBe(11);
expect(input.canonicalCatalog.grammar.outcome.optional).toEqual(
expect.arrayContaining(['delivery', 'durationSeconds']),
);
@@ -136,7 +136,7 @@ describe('AI mechanic authoring prompt', () => {
]),
);
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain(
'Prompt contract version: 10',
'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 10', () => {
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(10);
expect(AI_MECHANIC_PROMPT_VERSION).toBe(11);
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain(
'Prompt contract version: 10',
'Prompt contract version: 11',
);
expect(input.canonicalCatalog.events).toContainEqual({
key: 'extra_health_changed',
@@ -325,7 +325,7 @@ describe('AI mechanic authoring prompt', () => {
expect(parsed.ok).toBe(true);
});
it('advertises reproducible outcome and clause proc probabilities in version 10', () => {
it('advertises reproducible outcome and clause proc probabilities in version 11', () => {
const input = JSON.parse(
buildAiMechanicAuthoringInput({
request,
@@ -336,7 +336,7 @@ describe('AI mechanic authoring prompt', () => {
}),
);
expect(AI_MECHANIC_PROMPT_VERSION).toBe(10);
expect(AI_MECHANIC_PROMPT_VERSION).toBe(11);
expect(input.canonicalCatalog.grammar.clause.optional).toEqual(
expect.arrayContaining([
'procChancePercent',
@@ -489,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'),
]),
);
});
});
+11 -1
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 = 10;
export const AI_MECHANIC_PROMPT_VERSION = 11;
const mechanicsGrammar = {
version: 1,
@@ -83,6 +83,7 @@ const mechanicsGrammar = {
'attribute',
'reducer',
'pool',
'variant_value',
],
combatReferences: MECHANIC_COMBAT_REFERENCE_REGISTRY,
numericExpressionOperators: [
@@ -95,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' } },
@@ -498,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:
@@ -507,6 +511,7 @@ Response rules:
- All keys must be stable lower-case identifiers using letters, digits, dashes, or underscores and beginning with a letter.
- 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.
@@ -551,9 +556,14 @@ export function buildAiMechanicAuthoringInput(input: {
'A target-scoped pool owns an independent value and expiry timer for each entity. Bind hit-created target state with entity:{kind:"event_target"}; do not collapse different enemies into one Agent-scoped pool.',
'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.',
@@ -0,0 +1,127 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Database } from '@tursodatabase/database';
import type { MechanicRuntimeSelection } from './mechanic-runtime-selection';
const mocks = vi.hoisted(() => ({
loadSelections: vi.fn(),
}));
vi.mock('./mechanic-runtime-selection', async (importOriginal) => ({
...(await importOriginal<typeof import('./mechanic-runtime-selection')>()),
loadSelectedMechanicRuntimesForOwnerReferences: mocks.loadSelections,
}));
import { loadBuildMechanicRuntimes } from './build-mechanic-runtime';
import { mechanicRuntimeOwnerReferenceKey } from './mechanic-runtime-selection';
const runtime = (sourceId: 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: [
{
key: 'base',
label: 'BASE',
program: { id: `mechanic-runtime:${sourceId}:base`, name: sourceId, clauses: [] },
},
],
});
function emptyDatabase() {
return {} as Database;
}
describe('Build mechanic runtime hydration', () => {
beforeEach(() => {
mocks.loadSelections.mockReset();
});
it('maps every owner family through the selected-runtime reader using attachment ownership', async () => {
const database = emptyDatabase();
const selections = new Map<string, MechanicRuntimeSelection | null>([
[
mechanicRuntimeOwnerReferenceKey({
type: 'talent',
id: 'talent-1',
}),
runtime('talent-source'),
],
[
mechanicRuntimeOwnerReferenceKey({
type: 'os_protocol_talent',
id: 'protocol-talent-1',
}),
runtime('protocol-source'),
],
[
mechanicRuntimeOwnerReferenceKey({
type: 'skill_chip_talent',
id: 'chip-talent-1',
}),
runtime('chip-source'),
],
[
mechanicRuntimeOwnerReferenceKey({
type: 'specialization_tree_rank',
id: 'attachment-1',
}),
runtime('attachment-source'),
],
]);
mocks.loadSelections.mockResolvedValueOnce(selections);
const hydrated = await loadBuildMechanicRuntimes(database, {
talentIds: ['talent-1', 'not-authored'],
protocolTalentIds: ['protocol-talent-1'],
skillChipTalentIds: ['chip-talent-1'],
specializationAttachmentIds: ['attachment-1'],
});
expect(mocks.loadSelections).toHaveBeenCalledWith(database, [
{ type: 'talent', id: 'talent-1' },
{ type: 'talent', id: 'not-authored' },
{ type: 'os_protocol_talent', id: 'protocol-talent-1' },
{ type: 'skill_chip_talent', id: 'chip-talent-1' },
{ type: 'specialization_tree_rank', id: 'attachment-1' },
]);
expect(hydrated).toMatchObject({
talents: {
'talent-1': { sourceId: 'talent-source' },
'not-authored': null,
},
protocolTalents: {
'protocol-talent-1': { sourceId: 'protocol-source' },
},
skillChipTalents: { 'chip-talent-1': { sourceId: 'chip-source' } },
specializationAttachments: {
'attachment-1': { sourceId: 'attachment-source' },
},
});
expect(
hydrated.specializationAttachments['combat-effect-reusable'],
).toBeUndefined();
expect(JSON.parse(JSON.stringify(hydrated))).toEqual(hydrated);
});
it('returns null capabilities when no selected source can be loaded', async () => {
const database = emptyDatabase();
const hydrated = await loadBuildMechanicRuntimes(database, {
talentIds: ['talent-1'],
protocolTalentIds: ['protocol-talent-1'],
skillChipTalentIds: ['chip-talent-1'],
specializationAttachmentIds: ['attachment-1'],
});
expect(hydrated).toEqual({
talents: { 'talent-1': null },
protocolTalents: { 'protocol-talent-1': null },
skillChipTalents: { 'chip-talent-1': null },
specializationAttachments: { 'attachment-1': null },
});
expect(mocks.loadSelections).toHaveBeenCalledOnce();
});
});
+114
View File
@@ -0,0 +1,114 @@
import type { Database } from '@tursodatabase/database';
import {
loadSelectedMechanicRuntimesForOwnerReferences,
mechanicRuntimeOwnerReferenceKey,
type MechanicRuntimeSelection,
} from './mechanic-runtime-selection';
export type BuildMechanicRuntimeOwnerIds = {
talentIds: readonly string[];
protocolTalentIds: readonly string[];
skillChipTalentIds: readonly string[];
specializationAttachmentIds: readonly string[];
};
export type BuildMechanicRuntimes = {
talents: Record<string, MechanicRuntimeSelection | null>;
protocolTalents: Record<string, MechanicRuntimeSelection | null>;
skillChipTalents: Record<string, MechanicRuntimeSelection | null>;
specializationAttachments: Record<string, MechanicRuntimeSelection | null>;
};
function uniqueNonEmptyIds(ids: readonly string[]) {
return [...new Set(ids.filter((id) => id.trim().length > 0))];
}
function emptyRuntimeMap(ids: readonly string[]) {
return Object.fromEntries(ids.map((id) => [id, null])) as Record<
string,
MechanicRuntimeSelection | null
>;
}
/**
* Hydrates the only executable capability exposed to Build surfaces. One
* bounded bulk read resolves source metadata, selected revisions, graph rows,
* and active attributes. Every owner still receives an independent null when
* its source cannot be validated.
*/
export async function loadBuildMechanicRuntimes(
database: Database,
input: BuildMechanicRuntimeOwnerIds,
): Promise<BuildMechanicRuntimes> {
const talentIds = uniqueNonEmptyIds(input.talentIds);
const protocolTalentIds = uniqueNonEmptyIds(input.protocolTalentIds);
const skillChipTalentIds = uniqueNonEmptyIds(input.skillChipTalentIds);
const specializationAttachmentIds = uniqueNonEmptyIds(
input.specializationAttachmentIds,
);
const runtimes: BuildMechanicRuntimes = {
talents: emptyRuntimeMap(talentIds),
protocolTalents: emptyRuntimeMap(protocolTalentIds),
skillChipTalents: emptyRuntimeMap(skillChipTalentIds),
specializationAttachments: emptyRuntimeMap(specializationAttachmentIds),
};
const owners = [
...talentIds.map((id) => ({ type: 'talent' as const, id })),
...protocolTalentIds.map((id) => ({
type: 'os_protocol_talent' as const,
id,
})),
...skillChipTalentIds.map((id) => ({
type: 'skill_chip_talent' as const,
id,
})),
...specializationAttachmentIds.map((id) => ({
type: 'specialization_tree_rank' as const,
id,
})),
];
if (!owners.length) return runtimes;
try {
const selections = await loadSelectedMechanicRuntimesForOwnerReferences(
database,
owners,
);
for (const id of talentIds) {
runtimes.talents[id] =
selections.get(mechanicRuntimeOwnerReferenceKey({ type: 'talent', id })) ??
null;
}
for (const id of protocolTalentIds) {
runtimes.protocolTalents[id] =
selections.get(
mechanicRuntimeOwnerReferenceKey({
type: 'os_protocol_talent',
id,
}),
) ?? null;
}
for (const id of skillChipTalentIds) {
runtimes.skillChipTalents[id] =
selections.get(
mechanicRuntimeOwnerReferenceKey({
type: 'skill_chip_talent',
id,
}),
) ?? null;
}
for (const id of specializationAttachmentIds) {
runtimes.specializationAttachments[id] =
selections.get(
mechanicRuntimeOwnerReferenceKey({
type: 'specialization_tree_rank',
id,
}),
) ?? null;
}
} catch {
// An unavailable metadata read has no executable fallback at this boundary.
}
return runtimes;
}
File diff suppressed because it is too large Load Diff
+726 -72
View File
@@ -5,6 +5,37 @@ import type { AuthUser } from '$lib/server/auth/session';
import { recordIsacAudit } from '$lib/server/audit';
import { getContributorMode } from '$lib/server/contributor-mode';
import { notifyOversightReview } from '$lib/server/oversight-notifications';
import type { Database } from '@tursodatabase/database';
import { GameDataIconValidationError } from '$lib/server/game-data-icons';
import { ManagedImageValidationError } from '$lib/server/managed-images';
import { MediaMutationJournal } from '$lib/server/media-journal';
import {
readTalentFormMedia,
saveTalentFromFormInTransaction,
TalentNotFoundError,
type SaveTalentInTransactionResult,
} from '../../routes/isac/talents/talent-save';
import { MechanicSourceFingerprintConflictError } from '$lib/server/mechanic-authored-sources';
import {
parseProtocolForm,
ProtocolFormValidationError,
ProtocolNotFoundError,
saveProtocolInTransaction,
} from '../../routes/isac/protocols/protocol-save';
import {
_saveSkillChipInTransaction,
parseSkillChipForm,
SkillChipFormValidationError,
SkillChipNotFoundError,
} from '../../routes/isac/skillchips/skillchip-save';
import {
parseSpecializationCombatEffectForm,
parseSpecializationNodeForm,
saveSpecializationCombatEffectInTransaction,
saveSpecializationNodeInTransaction,
SpecializationFormValidationError,
} from '../../routes/isac/specializations/specialization-save';
export const DATA_MODULES = [
'configuration',
@@ -93,7 +124,22 @@ export function proposalActionResult(
: { proposalQueued: true, proposalId, resultToken: randomUUID() };
}
type SerializedEntry =
export const MAX_PROPOSAL_ENTRIES = 300;
export const MAX_PROPOSAL_TEXT_ENTRY_CHARS = 100_000;
export const MAX_PROPOSAL_TEXT_BYTES = 2 * 1024 * 1024;
export const MAX_PROPOSAL_FILE_BYTES = 5 * 1024 * 1024;
export const MAX_PROPOSAL_FILE_BYTES_TOTAL = 12 * 1024 * 1024;
const MAX_PROPOSAL_FILE_BASE64_CHARS =
4 * Math.ceil(MAX_PROPOSAL_FILE_BYTES / 3);
export const MAX_PROPOSAL_FIELD_NAME_CHARS = 200;
export const MAX_PROPOSAL_FILE_NAME_CHARS = 255;
export const MAX_PROPOSAL_FILE_TYPE_CHARS = 255;
const PROPOSAL_CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/u;
const PROPOSAL_BASE64_PATTERN =
/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u;
export type SerializedEntry =
| { name: string; kind: 'text'; value: string }
| {
name: string;
@@ -103,58 +149,647 @@ type SerializedEntry =
type: string;
};
type PendingDefinition = {
export class ProposalPayloadValidationError extends Error {
constructor(message: string) {
super(message);
this.name = 'ProposalPayloadValidationError';
}
}
export type PendingDefinition = {
module: DataModule;
action: string;
kind: string;
fields?: string[];
};
async function serialize(form: FormData): Promise<SerializedEntry[]> {
function validateProposalFieldName(name: string) {
if (
Array.from(name).length > MAX_PROPOSAL_FIELD_NAME_CHARS ||
PROPOSAL_CONTROL_CHARACTERS.test(name)
)
throw new ProposalPayloadValidationError(
`Proposal field names must be at most ${MAX_PROPOSAL_FIELD_NAME_CHARS} characters and contain no control characters.`,
);
}
function validateProposalFileMetadata(fileName: string, type: string) {
if (
Array.from(fileName).length > MAX_PROPOSAL_FILE_NAME_CHARS ||
PROPOSAL_CONTROL_CHARACTERS.test(fileName)
)
throw new ProposalPayloadValidationError(
`Proposal file names must be at most ${MAX_PROPOSAL_FILE_NAME_CHARS} characters and contain no control characters.`,
);
if (
Array.from(type).length > MAX_PROPOSAL_FILE_TYPE_CHARS ||
PROPOSAL_CONTROL_CHARACTERS.test(type)
)
throw new ProposalPayloadValidationError(
`Proposal file types must be at most ${MAX_PROPOSAL_FILE_TYPE_CHARS} characters and contain no control characters.`,
);
}
function decodeSerializedProposalFile(value: string) {
if (value.length > MAX_PROPOSAL_FILE_BASE64_CHARS)
throw new ProposalPayloadValidationError('Proposal file data is invalid.');
if (!PROPOSAL_BASE64_PATTERN.test(value))
throw new ProposalPayloadValidationError('Proposal file data is invalid.');
const bytes = Buffer.from(value, 'base64');
if (!bytes.length || bytes.toString('base64') !== value)
throw new ProposalPayloadValidationError('Proposal file data is invalid.');
return bytes;
}
export function validateSerializedProposalEntries(entries: SerializedEntry[]) {
if (!Array.isArray(entries))
throw new ProposalPayloadValidationError('Proposal entries are invalid.');
if (entries.length > MAX_PROPOSAL_ENTRIES)
throw new ProposalPayloadValidationError(
`A proposal may contain at most ${MAX_PROPOSAL_ENTRIES} entries.`,
);
let textBytes = 0;
let fileBytes = 0;
for (const entry of entries) {
if (!entry || typeof entry !== 'object' || typeof entry.name !== 'string')
throw new ProposalPayloadValidationError('Proposal entries are invalid.');
validateProposalFieldName(entry.name);
if (entry.kind === 'text') {
if (typeof entry.value !== 'string')
throw new ProposalPayloadValidationError(
'Proposal entries are invalid.',
);
if (Array.from(entry.value).length > MAX_PROPOSAL_TEXT_ENTRY_CHARS)
throw new ProposalPayloadValidationError(
`Proposal text entries must be at most ${MAX_PROPOSAL_TEXT_ENTRY_CHARS} characters.`,
);
textBytes += Buffer.byteLength(entry.value, 'utf8');
if (textBytes > MAX_PROPOSAL_TEXT_BYTES)
throw new ProposalPayloadValidationError(
'The aggregate proposal text exceeds the safe size limit.',
);
continue;
}
if (entry.kind !== 'file')
throw new ProposalPayloadValidationError('Proposal entries are invalid.');
if (
typeof entry.value !== 'string' ||
typeof entry.fileName !== 'string' ||
typeof entry.type !== 'string'
)
throw new ProposalPayloadValidationError('Proposal entries are invalid.');
validateProposalFileMetadata(entry.fileName, entry.type);
const bytes = decodeSerializedProposalFile(entry.value);
if (bytes.length > MAX_PROPOSAL_FILE_BYTES)
throw new ProposalPayloadValidationError(
'Proposal files exceed the safe per-file size limit.',
);
fileBytes += bytes.length;
if (fileBytes > MAX_PROPOSAL_FILE_BYTES_TOTAL)
throw new ProposalPayloadValidationError(
'The aggregate proposal files exceed the safe size limit.',
);
}
}
export async function serializeProposal(
form: FormData,
): Promise<SerializedEntry[]> {
const entries: SerializedEntry[] = [];
let entryCount = 0;
let textBytes = 0;
let fileBytes = 0;
for (const [name, value] of form.entries()) {
if (typeof value === 'string') entries.push({ name, kind: 'text', value });
else if (value.size > 0)
entries.push({
name,
kind: 'file',
value: Buffer.from(await value.arrayBuffer()).toString('base64'),
fileName: value.name,
type: value.type,
});
entryCount += 1;
if (entryCount > MAX_PROPOSAL_ENTRIES)
throw new ProposalPayloadValidationError(
`A proposal may contain at most ${MAX_PROPOSAL_ENTRIES} entries.`,
);
validateProposalFieldName(name);
if (typeof value === 'string') {
if (Array.from(value).length > MAX_PROPOSAL_TEXT_ENTRY_CHARS)
throw new ProposalPayloadValidationError(
`Proposal text entries must be at most ${MAX_PROPOSAL_TEXT_ENTRY_CHARS} characters.`,
);
textBytes += Buffer.byteLength(value, 'utf8');
if (textBytes > MAX_PROPOSAL_TEXT_BYTES)
throw new ProposalPayloadValidationError(
'The aggregate proposal text exceeds the safe size limit.',
);
entries.push({ name, kind: 'text', value });
continue;
}
validateProposalFileMetadata(value.name, value.type);
if (value.size === 0) continue;
if (value.size > MAX_PROPOSAL_FILE_BYTES)
throw new ProposalPayloadValidationError(
'Proposal files exceed the safe per-file size limit.',
);
if (fileBytes + value.size > MAX_PROPOSAL_FILE_BYTES_TOTAL)
throw new ProposalPayloadValidationError(
'The aggregate proposal files exceed the safe size limit.',
);
const bytes = Buffer.from(await value.arrayBuffer());
if (bytes.length > MAX_PROPOSAL_FILE_BYTES)
throw new ProposalPayloadValidationError(
'Proposal files exceed the safe per-file size limit.',
);
fileBytes += bytes.length;
if (fileBytes > MAX_PROPOSAL_FILE_BYTES_TOTAL)
throw new ProposalPayloadValidationError(
'The aggregate proposal files exceed the safe size limit.',
);
entries.push({
name,
kind: 'file',
value: bytes.toString('base64'),
fileName: value.name,
type: value.type,
});
}
return entries;
}
export function parseSerializedProposalEntries(
payload: string,
): SerializedEntry[] {
let parsed: unknown;
try {
parsed = JSON.parse(payload);
validateSerializedProposalEntries(parsed as SerializedEntry[]);
} catch (error) {
if (error instanceof ProposalPayloadValidationError) throw error;
throw new ProposalPayloadValidationError('Proposal entries are invalid.');
}
return parsed as SerializedEntry[];
}
export function deserializeProposal(payload: string): FormData {
const entries = parseSerializedProposalEntries(payload);
const form = new FormData();
for (const entry of JSON.parse(payload) as SerializedEntry[]) {
for (const entry of entries) {
if (entry.kind === 'text') form.append(entry.name, entry.value);
else
form.append(
entry.name,
new Blob([Buffer.from(entry.value, 'base64')], { type: entry.type }),
new Blob([decodeSerializedProposalFile(entry.value)], {
type: entry.type,
}),
entry.fileName,
);
}
return form;
}
export class ProposalNotPendingError extends Error {
constructor() {
super('That pending proposal no longer exists.');
this.name = 'ProposalNotPendingError';
}
}
export class ProposalDependenciesBlockedError extends Error {
readonly dependencies: string;
constructor(dependencies: string) {
super(`Approve this proposal's dependencies first: ${dependencies}.`);
this.name = 'ProposalDependenciesBlockedError';
this.dependencies = dependencies;
}
}
export class ProposalDependencyCycleError extends Error {
readonly proposalId: string;
readonly dependencyId: string;
constructor(proposalId: string, dependencyId: string) {
super(
`Adding dependency ${proposalId} -> ${dependencyId} would create a cycle.`,
);
this.name = 'ProposalDependencyCycleError';
this.proposalId = proposalId;
this.dependencyId = dependencyId;
}
}
export class ProposalCanonicalValidationError extends Error {
readonly status: number;
constructor(status: number, message: string) {
super(message);
this.name = 'ProposalCanonicalValidationError';
this.status = status;
}
}
const safeValidationMessage = (error: unknown) => {
const message = error instanceof Error ? error.message : '';
return message && message.length <= 500
? message.replace(/[\u0000-\u001f\u007f]/gu, ' ').trim()
: 'The proposal did not pass current canonical validation.';
};
function canonicalFailure(
error: unknown,
): ProposalCanonicalValidationError | null {
if (error instanceof ProposalCanonicalValidationError)
return new ProposalCanonicalValidationError(
error.status,
safeValidationMessage(error),
);
if (error instanceof ProposalPayloadValidationError)
return new ProposalCanonicalValidationError(
400,
'The stored proposal payload is invalid.',
);
if (error instanceof MechanicSourceFingerprintConflictError)
return new ProposalCanonicalValidationError(
409,
safeValidationMessage(error),
);
if (
error instanceof GameDataIconValidationError ||
error instanceof ManagedImageValidationError
)
return new ProposalCanonicalValidationError(
400,
safeValidationMessage(error),
);
if (
error instanceof ProtocolFormValidationError ||
error instanceof SkillChipFormValidationError ||
error instanceof SpecializationFormValidationError
)
return new ProposalCanonicalValidationError(
error.status,
safeValidationMessage(error),
);
if (
error instanceof ProtocolNotFoundError ||
error instanceof TalentNotFoundError ||
error instanceof SkillChipNotFoundError
)
return new ProposalCanonicalValidationError(
404,
safeValidationMessage(error),
);
if (
error instanceof Error &&
/no authored source|identity does not match|cannot be moved|does not belong to this node|submitted more than once|reused an existing ID|no longer exists/i.test(
error.message,
)
)
return new ProposalCanonicalValidationError(
409,
safeValidationMessage(error),
);
return null;
}
const SPECIALIZATION_NOT_FOUND_ERRORS = new Set([
'The specialization tree node no longer exists.',
'The specialization combat effect no longer exists.',
]);
const SPECIALIZATION_CONFLICT_ERRORS = new Set([
'The node identity does not match the supplied record.',
'A saved tree node cannot be moved to another focus.',
'A combat-effect attachment was submitted more than once.',
'A submitted combat-effect attachment does not belong to this node.',
'A new combat-effect attachment reused an existing ID.',
'The combat-effect identity does not match the supplied record.',
'A saved combat effect cannot be moved to another specialization.',
]);
function classifySpecializationTransactionError(error: unknown) {
if (error instanceof SpecializationFormValidationError) return error;
if (error instanceof MechanicSourceFingerprintConflictError) return error;
if (!(error instanceof Error)) return null;
const message = error.message.trim();
if (SPECIALIZATION_NOT_FOUND_ERRORS.has(message))
return new SpecializationFormValidationError(
404,
safeValidationMessage(error),
);
if (SPECIALIZATION_CONFLICT_ERRORS.has(message))
return new SpecializationFormValidationError(
409,
safeValidationMessage(error),
);
const isKnownValidationMessage =
message === 'Every conditional effect requires an authored description.' ||
message ===
'The authored mechanic owner does not match its attachment.' ||
message === 'One or more conditional effects are unavailable for this specialization.' ||
message === 'One or more authored description Attributes are unavailable.' ||
message.startsWith('The authored template ') ||
message.startsWith('Clause ') ||
message.startsWith('Conditional mechanic ');
return isKnownValidationMessage
? new SpecializationFormValidationError(400, safeValidationMessage(error))
: null;
}
function enforcePhase2ProposalIdentity(
proposal: Record<string, unknown>,
form: FormData,
) {
const entityId = String(proposal.entity_id ?? '').trim();
if (entityId) {
form.set('id', entityId);
form.delete('_proposedEntityId');
return;
}
const draftEntityId = String(proposal.draft_entity_id ?? '').trim();
if (!draftEntityId)
throw new ProposalCanonicalValidationError(
400,
'The proposal does not have a stored draft identity.',
);
form.delete('id');
form.set('_proposedEntityId', draftEntityId);
}
async function replayPhase2Owner(
database: Database,
proposal: Record<string, unknown>,
reviewerId: string,
form: FormData,
proposalId: string,
journal: MediaMutationJournal,
) {
const mutationContext = { origin: 'proposal' as const, proposalId };
switch (`${String(proposal.module)}:${String(proposal.action)}`) {
case 'talents:saveTalent': {
const media = await readTalentFormMedia(form);
const result: SaveTalentInTransactionResult =
await saveTalentFromFormInTransaction(database, {
form,
operatorId: reviewerId,
proposedEntityId:
String(proposal.draft_entity_id ?? '').trim() || undefined,
...media,
mutationContext,
journal,
});
if (!result.ok)
throw new ProposalCanonicalValidationError(
result.status,
result.errors[0]?.message ?? 'The talent could not be saved.',
);
return { entityId: result.talentId, mode: result.talentMode };
}
case 'protocols:save': {
const input = await parseProtocolForm(
database,
form,
reviewerId,
mutationContext,
);
const result = await saveProtocolInTransaction(database, {
...input,
journal,
});
return { entityId: result.protocolId, mode: result.mode };
}
case 'skillchips:save': {
const input = await parseSkillChipForm(
database,
form,
reviewerId,
mutationContext,
);
const result = await _saveSkillChipInTransaction(database, {
...input,
journal,
});
return { entityId: result.setId, mode: result.mode };
}
case 'specializations:saveNode': {
const input = await parseSpecializationNodeForm(
database,
form,
reviewerId,
mutationContext,
);
try {
const result = await saveSpecializationNodeInTransaction(database, {
...input,
journal,
});
return {
entityId: input.nodeId,
mode: input.id ? 'edit' : 'add',
result,
};
} catch (error) {
throw classifySpecializationTransactionError(error) ?? error;
}
}
case 'specializations:saveCombatEffect': {
const input = await parseSpecializationCombatEffectForm(
database,
form,
reviewerId,
mutationContext,
);
try {
const result = await saveSpecializationCombatEffectInTransaction(
database,
input,
);
return {
entityId: input.effectId,
mode: input.id ? 'edit' : 'add',
result,
};
} catch (error) {
throw classifySpecializationTransactionError(error) ?? error;
}
}
default:
throw new Error('Unsupported Phase 2 proposal action.');
}
}
export type Phase2ApprovalResult = {
proposal: Record<string, unknown>;
entityId: string;
mode: string;
};
/** Runs one Phase 2 owner replay and proposal decision in one immediate tx. */
export async function approvePhase2Proposal(
database: Database,
reviewerId: string,
proposalId: string,
note: string | null,
): Promise<Phase2ApprovalResult> {
const journal = new MediaMutationJournal();
try {
const operation = database.transactionAsync(async (transaction) => {
const tx = transaction as unknown as Database;
const proposal = await (
await tx.prepare(
"SELECT * FROM data_change_proposals WHERE id=? AND status='pending'",
)
).get(proposalId);
if (!proposal) throw new ProposalNotPendingError();
const claimed = await (
await tx.prepare(
"UPDATE data_change_proposals SET status='approved' WHERE id=? AND status='pending'",
)
).run(proposalId);
if (!Number(claimed?.changes ?? 0)) throw new ProposalNotPendingError();
const unresolved = await (
await tx.prepare(`SELECT dependency.id,dependency.summary,dependency.status
FROM data_change_proposal_dependencies link
JOIN data_change_proposals dependency ON dependency.id=link.depends_on_proposal_id
WHERE link.proposal_id=? AND dependency.status<>'approved'`)
).all(proposalId);
if (unresolved.length) {
const names = unresolved
.map((item) => `${item.summary} (${item.status})`)
.join(', ');
throw new ProposalDependenciesBlockedError(names);
}
let result: { entityId: string; mode: string };
try {
const form = deserializeProposal(String(proposal.payload_json));
enforcePhase2ProposalIdentity(proposal as Record<string, unknown>, form);
result = await replayPhase2Owner(
tx,
proposal,
reviewerId,
form,
proposalId,
journal,
);
} catch (error) {
const failure = canonicalFailure(error);
if (failure) throw failure;
throw error;
}
const now = new Date().toISOString();
const updated = await (
await tx.prepare(
"UPDATE data_change_proposals SET applied_entity_id=?,reviewed_by=?,review_note=?,reviewed_at=?,applied_at=? WHERE id=? AND status='approved'",
)
).run(result.entityId, reviewerId, note || null, now, now, proposalId);
if (!Number(updated?.changes ?? 0)) throw new ProposalNotPendingError();
await recordIsacAudit({
actorId: reviewerId,
category: 'data_moderation',
action: 'proposal.approved',
entityId: proposalId,
before: { status: 'pending' },
after: {
status: 'approved',
module: proposal.module,
action: proposal.action,
proposedBy: proposal.proposed_by,
appliedEntityId: result.entityId,
},
database: tx,
});
return { proposal, entityId: result.entityId, mode: result.mode };
});
const result = await operation.immediate();
journal.clear();
return result;
} catch (error) {
await journal.rollback(error);
throw error;
}
}
export async function failPhase2Proposal(
database: Database,
reviewerId: string,
proposalId: string,
message: string,
) {
const operation = database.transactionAsync(async (transaction) => {
const tx = transaction as unknown as Database;
const proposal = await (
await tx.prepare(
"SELECT * FROM data_change_proposals WHERE id=? AND status='pending'",
)
).get(proposalId);
if (!proposal) throw new ProposalNotPendingError();
const now = new Date().toISOString();
const updated = await (
await tx.prepare(
"UPDATE data_change_proposals SET status='failed',reviewed_by=?,review_note=?,reviewed_at=? WHERE id=? AND status='pending'",
)
).run(reviewerId, message, now, proposalId);
if (!Number(updated?.changes ?? 0)) throw new ProposalNotPendingError();
await recordIsacAudit({
actorId: reviewerId,
category: 'data_moderation',
action: 'proposal.failed',
entityId: proposalId,
before: { status: 'pending' },
after: { status: 'failed', messagePresent: Boolean(message) },
database: tx,
});
return proposal as Record<string, unknown>;
});
return operation.immediate();
}
export async function refreshProposalDependencies(
proposalId: string,
payload: string,
suppliedDatabase?: Database,
) {
const database = await getDatabase();
const text = serializedTextValues(payload)
.map((entry) => entry.value)
.join('\n');
const textValues = serializedTextValues(payload);
// A malformed persisted payload cannot provide reliable dependency evidence.
// Leave existing links intact rather than mutating state during recovery.
if (!textValues) return [];
const database = suppliedDatabase ?? (await getDatabase());
const text = textValues.map((entry) => entry.value).join('\n');
const candidates = await (
await database.prepare(`SELECT id,draft_entity_id FROM data_change_proposals
WHERE id<>? AND draft_entity_id IS NOT NULL`)
WHERE id<>? AND status='pending' AND draft_entity_id IS NOT NULL`)
).all(proposalId);
const dependencies = candidates.filter((candidate) =>
text.includes(String(candidate.draft_entity_id)),
);
const existingLinks = await (
await database.prepare(
'SELECT proposal_id,depends_on_proposal_id FROM data_change_proposal_dependencies',
)
).all();
const links = new Map<string, string[]>();
for (const link of existingLinks) {
const proposalLinks = links.get(String(link.proposal_id)) ?? [];
proposalLinks.push(String(link.depends_on_proposal_id));
links.set(String(link.proposal_id), proposalLinks);
}
const reachesProposal = (start: string) => {
const visited = new Set<string>();
const toVisit = [start];
while (toVisit.length) {
const current = toVisit.pop()!;
if (current === proposalId) return true;
if (visited.has(current)) continue;
visited.add(current);
toVisit.push(...(links.get(current) ?? []));
}
return false;
};
for (const dependency of dependencies) {
const dependencyId = String(dependency.id);
if (reachesProposal(dependencyId))
throw new ProposalDependencyCycleError(proposalId, dependencyId);
}
await (
await database.prepare(
'DELETE FROM data_change_proposal_dependencies WHERE proposal_id=?',
@@ -170,11 +805,19 @@ export async function refreshProposalDependencies(
return dependencies.map((dependency) => String(dependency.id));
}
const serializedTextValues = (payload: string) =>
(JSON.parse(payload) as SerializedEntry[]).filter(
(entry): entry is Extract<SerializedEntry, { kind: 'text' }> =>
entry.kind === 'text',
);
const serializedTextValues = (
payload: string,
): Array<Extract<SerializedEntry, { kind: 'text' }>> | null => {
try {
return parseSerializedProposalEntries(payload).filter(
(entry): entry is Extract<SerializedEntry, { kind: 'text' }> =>
entry.kind === 'text',
);
} catch (error) {
if (error instanceof ProposalPayloadValidationError) return null;
throw error;
}
};
export async function getPendingEntities(
_userId: string,
@@ -211,24 +854,30 @@ export async function getPendingEntities(
(item) => item.module === row.module && item.action === row.action,
)!;
const values = serializedTextValues(String(row.payload_json));
const unusable = values === null;
const safeValues = values ?? [];
const field = (name: string) =>
values.find((entry) => entry.name === name)?.value ?? '';
safeValues.find((entry) => entry.name === name)?.value ?? '';
const fieldValue = (name: string) => {
const matches = values
const matches = safeValues
.filter((entry) => entry.name === name)
.map((entry) => entry.value);
return matches.length > 1 ? matches : (matches[0] ?? '');
};
return {
id: String(row.draft_entity_id),
id: String(row.draft_entity_id ?? row.id),
proposalId: String(row.id),
pending: true,
active: 1,
...(unusable ? { unusable: true } : {}),
kind: definition.kind,
name: field('name'),
name: unusable ? 'UNUSABLE PROPOSAL' : field('name'),
proposerName: String(row.proposer_name),
...Object.fromEntries(
(definition.fields ?? []).map((name) => [name, fieldValue(name)]),
(definition.fields ?? []).map((name) => [
name,
unusable ? '' : fieldValue(name),
]),
),
};
});
@@ -258,58 +907,63 @@ export async function queueDataProposal(
const name = String(form.get('name') ?? '').trim();
const summary = `${action.replace(/([A-Z])/g, ' $1').toUpperCase()} · ${name || entityId || 'NEW RECORD'}`;
const now = new Date().toISOString();
const payload = JSON.stringify(await serialize(form));
const payload = JSON.stringify(await serializeProposal(form));
const database = await getDatabase();
await (
await database.prepare(`INSERT INTO data_change_proposals
const createProposal = database.transactionAsync(async (transaction) => {
const tx = transaction as unknown as Database;
await (
await tx.prepare(`INSERT INTO data_change_proposals
(id,module,action,entity_id,draft_entity_id,summary,contributor_note,payload_json,status,proposed_by,created_at)
VALUES(?,?,?,?,?,?,?,?,'pending',?,?)`)
).run(
id,
module,
action,
entityId,
draftEntityId,
summary,
contributorNote || null,
payload,
user.id,
now,
);
const text = serializedTextValues(payload)
.map((entry) => entry.value)
.join('\n');
const candidates = await (
await database.prepare(`SELECT id,draft_entity_id FROM data_change_proposals
WHERE status='pending' AND draft_entity_id IS NOT NULL`)
).all();
const dependencies = candidates.filter(
(candidate) =>
candidate.id !== id && text.includes(String(candidate.draft_entity_id)),
);
for (const dependency of dependencies) {
await (
await database.prepare(
'INSERT OR IGNORE INTO data_change_proposal_dependencies(proposal_id,depends_on_proposal_id) VALUES(?,?)',
)
).run(id, dependency.id);
}
await recordIsacAudit({
actorId: user.id,
category: 'data_moderation',
action: 'proposal.created',
entityId: id,
before: null,
after: {
).run(
id,
module,
action,
entityId,
draftEntityId,
dependencies: dependencies.map((item) => item.id),
summary,
contributorNote: contributorNote || null,
},
contributorNote || null,
payload,
user.id,
now,
);
const text = (serializedTextValues(payload) ?? [])
.map((entry) => entry.value)
.join('\n');
const candidates = await (
await tx.prepare(`SELECT id,draft_entity_id FROM data_change_proposals
WHERE status='pending' AND draft_entity_id IS NOT NULL`)
).all();
const dependencies = candidates.filter(
(candidate) =>
candidate.id !== id && text.includes(String(candidate.draft_entity_id)),
);
for (const dependency of dependencies) {
await (
await tx.prepare(
'INSERT OR IGNORE INTO data_change_proposal_dependencies(proposal_id,depends_on_proposal_id) VALUES(?,?)',
)
).run(id, dependency.id);
}
await recordIsacAudit({
actorId: user.id,
category: 'data_moderation',
action: 'proposal.created',
entityId: id,
before: null,
after: {
module,
action,
entityId,
draftEntityId,
dependencies: dependencies.map((item) => item.id),
summary,
contributorNotePresent: Boolean(contributorNote),
},
database: tx,
});
});
await createProposal.immediate();
await notifyOversightReview({
category: 'data_changes',
itemId: id,
+23
View File
@@ -0,0 +1,23 @@
import { connect } from '@tursodatabase/database';
import { describe, expect, it } from 'vitest';
import { configureDatabase } from './database';
describe('database connection configuration', () => {
it('enables and enforces foreign keys on application connections', async () => {
const database = await configureDatabase(await connect(':memory:'));
try {
expect(
await (await database.prepare('PRAGMA foreign_keys')).get(),
).toEqual({ foreign_keys: 1 });
await database.exec('CREATE TABLE parent(id TEXT PRIMARY KEY)');
await database.exec(
'CREATE TABLE child(parent_id TEXT REFERENCES parent(id))',
);
await expect(
database.exec("INSERT INTO child(parent_id) VALUES('missing')"),
).rejects.toThrow();
} finally {
await database.close();
}
});
});
+13 -1
View File
@@ -4,6 +4,18 @@ import { resolve } from 'node:path';
let databasePromise: Promise<Database> | undefined;
export async function configureDatabase(database: Database): Promise<Database> {
await database.exec('PRAGMA foreign_keys=ON');
const state = await (
await database.prepare('PRAGMA foreign_keys')
).get();
if (Number(state?.foreign_keys) !== 1) {
await database.close();
throw new Error('Database foreign-key enforcement could not be enabled.');
}
return database;
}
/**
* Returns the process-wide embedded Turso connection.
*
@@ -15,7 +27,7 @@ export function getDatabase(): Promise<Database> {
env.TDR_DATABASE_PATH || './data/shd-build-development.db';
if (!databasePromise) {
databasePromise = connect(resolve(configuredPath));
databasePromise = connect(resolve(configuredPath)).then(configureDatabase);
}
return databasePromise;
+123 -38
View File
@@ -8,6 +8,12 @@ import {
removeBackgroundPreview,
} from './remove-background';
import { weaponModSlotDefinitionById } from '$lib/weapons/mod-slots';
import {
acquireMediaPathLock,
captureMediaFile,
restoreMediaFile,
type MediaMutationJournal,
} from './media-journal';
export const GAME_ICON_TYPES = [
'talent',
@@ -374,54 +380,124 @@ export async function saveGameDataIcon(
entityId: string,
output: Buffer,
actorId: string,
journal?: MediaMutationJournal,
): Promise<string> {
const key = storageKey(type, entityId);
await mkdir(gameDataIconStorageDirectory(), { recursive: true });
await writeFile(storagePath(key), output, { flag: 'w', mode: 0o600 });
const now = new Date().toISOString();
await (
await database.prepare(`INSERT INTO game_data_icons(entity_type,entity_id,storage_key,byte_size,updated_at)
const filePath = storagePath(key);
let release: (() => void) | undefined;
let transactionStarted = false;
let committed = false;
try {
if (journal) {
await journal.lockPath(filePath);
const previous = await captureMediaFile(filePath);
journal.register(() => restoreMediaFile(filePath, previous));
} else {
await database.exec('BEGIN IMMEDIATE');
transactionStarted = true;
release = await acquireMediaPathLock(filePath);
}
await mkdir(gameDataIconStorageDirectory(), { recursive: true });
await writeFile(filePath, output, { flag: 'w', mode: 0o600 });
const now = new Date().toISOString();
await (
await database.prepare(`INSERT INTO game_data_icons(entity_type,entity_id,storage_key,byte_size,updated_at)
VALUES(?,?,?,?,?) ON CONFLICT(entity_type,entity_id) DO UPDATE SET
storage_key=excluded.storage_key,byte_size=excluded.byte_size,updated_at=excluded.updated_at`)
).run(type, entityId, key, output.length, now);
await registerLibraryIcon(
database,
type,
entityId,
key,
output.length,
now,
actorId,
);
return now;
).run(type, entityId, key, output.length, now);
await registerLibraryIcon(
database,
type,
entityId,
key,
output.length,
now,
actorId,
);
if (transactionStarted) {
await database.exec('COMMIT');
committed = true;
}
return now;
} catch (error) {
if (transactionStarted && !committed)
await database.exec('ROLLBACK').catch(() => undefined);
throw error;
} finally {
release?.();
}
}
export async function deleteGameDataIcon(
database: Database,
type: GameDataIconType,
entityId: string,
journal?: MediaMutationJournal,
): Promise<void> {
const icon = await (
await database.prepare(
'SELECT storage_key FROM game_data_icons WHERE entity_type=? AND entity_id=?',
)
).get(type, entityId);
await (
await database.prepare(
'DELETE FROM game_data_icons WHERE entity_type=? AND entity_id=?',
)
).run(type, entityId);
await (
await database.prepare(
'DELETE FROM managed_images WHERE source_entity_type=? AND source_entity_id=?',
)
).run(type, entityId);
if (typeof icon?.storage_key === 'string') {
await unlink(storagePath(icon.storage_key)).catch(
(error: NodeJS.ErrnoException) => {
const canonicalPath = storagePath(storageKey(type, entityId));
let releaseCanonical: (() => void) | undefined;
let releaseFile: (() => void) | undefined;
let transactionStarted = false;
let committed = false;
try {
if (journal) await journal.lockPath(canonicalPath);
else {
await database.exec('BEGIN IMMEDIATE');
transactionStarted = true;
releaseCanonical = await acquireMediaPathLock(canonicalPath);
}
const icon = await (
await database.prepare(
'SELECT storage_key FROM game_data_icons WHERE entity_type=? AND entity_id=?',
)
).get(type, entityId);
const filePath =
typeof icon?.storage_key === 'string'
? storagePath(icon.storage_key)
: null;
if (filePath && path.resolve(filePath) !== path.resolve(canonicalPath)) {
if (journal) await journal.lockPath(filePath);
else releaseFile = await acquireMediaPathLock(filePath);
}
if (journal) {
for (const pathToCapture of [canonicalPath, filePath]) {
if (!pathToCapture) continue;
const previous = await captureMediaFile(pathToCapture);
journal.register(() => restoreMediaFile(pathToCapture, previous));
}
}
await (
await database.prepare(
'DELETE FROM game_data_icons WHERE entity_type=? AND entity_id=?',
)
).run(type, entityId);
await (
await database.prepare(
'DELETE FROM managed_images WHERE source_entity_type=? AND source_entity_id=?',
)
).run(type, entityId);
if (filePath) {
await unlink(filePath).catch((error: NodeJS.ErrnoException) => {
if (error.code !== 'ENOENT') throw error;
},
);
});
}
if (!filePath || path.resolve(filePath) !== path.resolve(canonicalPath)) {
await unlink(canonicalPath).catch((error: NodeJS.ErrnoException) => {
if (error.code !== 'ENOENT') throw error;
});
}
if (transactionStarted) {
await database.exec('COMMIT');
committed = true;
}
} catch (error) {
if (transactionStarted && !committed)
await database.exec('ROLLBACK').catch(() => undefined);
throw error;
} finally {
releaseFile?.();
releaseCanonical?.();
}
}
@@ -462,10 +538,19 @@ export async function applyIconMutation(
entityId: string,
mutation: { output: Buffer | null; remove: boolean },
actorId: string,
journal?: MediaMutationJournal,
): Promise<void> {
if (mutation.output)
await saveGameDataIcon(database, type, entityId, mutation.output, actorId);
else if (mutation.remove) await deleteGameDataIcon(database, type, entityId);
await saveGameDataIcon(
database,
type,
entityId,
mutation.output,
actorId,
journal,
);
else if (mutation.remove)
await deleteGameDataIcon(database, type, entityId, journal);
else {
const icon = await (
await database.prepare(
+4 -4
View File
@@ -160,7 +160,7 @@ describe('deleteManagedImage', () => {
});
expect(database.images.has(imageId)).toBe(false);
expect(database.icons.has(`${sourceType}:${sourceId}`)).toBe(false);
expect(database.executed).toEqual(['BEGIN', 'COMMIT']);
expect(database.executed).toEqual(['BEGIN IMMEDIATE', 'COMMIT']);
expect(rmMock).toHaveBeenCalledOnce();
},
);
@@ -214,7 +214,7 @@ describe('deleteManagedImage', () => {
expect(deleted?.sourceIcon).toBeNull();
expect(database.images.has(imageId)).toBe(false);
expect(database.executed).toEqual(['BEGIN', 'COMMIT']);
expect(database.executed).toEqual(['BEGIN IMMEDIATE', 'COMMIT']);
});
it('fails closed when the library and source rows reference different files', async () => {
@@ -236,7 +236,7 @@ describe('deleteManagedImage', () => {
).rejects.toThrow(ManagedImageDeletionError);
expect(database.images.has(imageId)).toBe(true);
expect(database.icons.has(`talent:${sourceId}`)).toBe(true);
expect(database.executed).toEqual([]);
expect(database.executed).toEqual(['BEGIN IMMEDIATE', 'ROLLBACK']);
expect(rmMock).not.toHaveBeenCalled();
});
@@ -258,7 +258,7 @@ describe('deleteManagedImage', () => {
deleteManagedImage(database as never, imageId),
).rejects.toThrow(ManagedImageDeletionError);
expect(database.images.has(imageId)).toBe(true);
expect(database.executed).toEqual([]);
expect(database.executed).toEqual(['BEGIN IMMEDIATE', 'ROLLBACK']);
expect(rmMock).not.toHaveBeenCalled();
},
);
+331 -103
View File
@@ -4,6 +4,12 @@ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
import sharp from 'sharp';
import { GAME_ICON_TYPES, type GameDataIconType } from './game-data-icons';
import {
acquireMediaPathLock,
captureMediaFile,
restoreMediaFile,
type MediaMutationJournal,
} from './media-journal';
import {
BackgroundRemovalError,
removeBackgroundPreview,
@@ -173,12 +179,48 @@ export async function processManagedImage(
}
}
export async function saveManagedImageFile(id: string, output: Buffer) {
async function saveManagedImageFileUnlocked(
key: string,
filePath: string,
output: Buffer,
journal?: MediaMutationJournal,
) {
await mkdir(managedImageStorageDirectory(), { recursive: true });
await writeFile(filePath, output, { flag: 'wx', mode: 0o600 });
if (journal) {
try {
journal.register(() => rm(filePath, { force: true }));
} catch (error) {
try {
await rm(filePath, { force: true });
} catch (cleanupError) {
throw new AggregateError(
[error, cleanupError],
'Unable to register or clean up the managed image.',
);
}
throw error;
}
}
return key;
}
export async function saveManagedImageFile(
id: string,
output: Buffer,
journal?: MediaMutationJournal,
) {
if (!ID_PATTERN.test(id)) throw new Error('Invalid managed image identifier');
const key = `library-${id}.webp`;
await mkdir(managedImageStorageDirectory(), { recursive: true });
await writeFile(storagePath(key), output, { flag: 'wx', mode: 0o600 });
return key;
const filePath = storagePath(key);
let release: (() => void) | undefined;
if (journal) await journal.lockPath(filePath);
else release = await acquireMediaPathLock(filePath);
try {
return await saveManagedImageFileUnlocked(key, filePath, output, journal);
} finally {
release?.();
}
}
export async function readGameDataRenderMutation(form: FormData): Promise<{
@@ -193,6 +235,45 @@ export async function readGameDataRenderMutation(form: FormData): Promise<{
};
}
type ManagedImageRow = Record<string, unknown>;
type CurrentRenderPathLock = {
current: ManagedImageRow | null;
filePath: string;
release?: () => void;
};
async function lockCurrentGameDataRender(
database: Database,
sourceType: string,
entityId: string,
initialStorageKey: string,
journal?: MediaMutationJournal,
): Promise<CurrentRenderPathLock> {
let filePath = storagePath(initialStorageKey);
let release: (() => void) | undefined;
for (;;) {
if (journal) await journal.lockPath(filePath);
else release = await acquireMediaPathLock(filePath);
const current = (await (
await database.prepare(
'SELECT * FROM managed_images WHERE source_entity_type=? AND source_entity_id=?',
)
).get(sourceType, entityId)) as ManagedImageRow | null;
if (!current) return { current: null, filePath, release };
if (typeof current.storage_key !== 'string') {
release?.();
throw new Error('Invalid managed image storage key');
}
const currentPath = storagePath(current.storage_key);
if (path.resolve(currentPath) === path.resolve(filePath))
return { current, filePath: currentPath, release };
release?.();
release = undefined;
filePath = currentPath;
}
}
export async function applyGameDataRender(
database: Database,
type: GameDataRenderType,
@@ -204,6 +285,7 @@ export async function applyGameDataRender(
processed?: { output: Buffer; width: number; height: number };
},
actorId: string,
journal?: MediaMutationJournal,
) {
const sourceType = `${type}_render`;
const existing = await (
@@ -214,12 +296,52 @@ export async function applyGameDataRender(
if (!mutation.file && !mutation.remove) return existing ?? null;
if (mutation.remove) {
if (!existing) return null;
await (
await database.prepare('DELETE FROM managed_images WHERE id=?')
).run(existing.id);
if (typeof existing.storage_key === 'string')
await rm(storagePath(existing.storage_key), { force: true });
return null;
if (typeof existing.storage_key !== 'string') {
await (
await database.prepare('DELETE FROM managed_images WHERE id=?')
).run(existing.id);
return null;
}
let lock: CurrentRenderPathLock | undefined;
let transactionStarted = false;
let committed = false;
try {
if (!journal) {
await database.exec('BEGIN IMMEDIATE');
transactionStarted = true;
}
lock = await lockCurrentGameDataRender(
database,
sourceType,
entityId,
existing.storage_key,
journal,
);
const activeLock = lock;
if (activeLock.current) {
if (journal) {
const previous = await captureMediaFile(activeLock.filePath);
journal.register(() =>
restoreMediaFile(activeLock.filePath, previous),
);
}
await (
await database.prepare('DELETE FROM managed_images WHERE id=?')
).run(activeLock.current.id);
await rm(activeLock.filePath, { force: true });
}
if (transactionStarted) {
await database.exec('COMMIT');
committed = true;
}
return null;
} catch (error) {
if (transactionStarted && !committed)
await database.exec('ROLLBACK').catch(() => undefined);
throw error;
} finally {
lock?.release?.();
}
}
const processed =
mutation.processed ??
@@ -242,65 +364,137 @@ export async function applyGameDataRender(
specialization_node: 'Specializations',
};
const tags = JSON.stringify(['isac', 'game data', type, 'render']);
if (existing) {
await writeFile(
storagePath(String(existing.storage_key)),
processed.output,
{ mode: 0o600 },
);
await (
await database.prepare(`UPDATE managed_images SET category=?,tags_json=?,is_icon=0,force_white=0,
if (existing && typeof existing.storage_key !== 'string')
throw new Error('Invalid managed image storage key');
if (existing && typeof existing.storage_key === 'string') {
let lock: CurrentRenderPathLock | undefined;
let transactionStarted = false;
let committed = false;
try {
if (!journal) {
await database.exec('BEGIN IMMEDIATE');
transactionStarted = true;
}
lock = await lockCurrentGameDataRender(
database,
sourceType,
entityId,
existing.storage_key,
journal,
);
const activeLock = lock;
if (activeLock.current) {
if (journal) {
const previous = await captureMediaFile(activeLock.filePath);
journal.register(() =>
restoreMediaFile(activeLock.filePath, previous),
);
}
await writeFile(activeLock.filePath, processed.output, {
mode: 0o600,
});
await (
await database.prepare(`UPDATE managed_images SET category=?,tags_json=?,is_icon=0,force_white=0,
original_filename=?,byte_size=?,width=?,height=?,updated_at=? WHERE id=?`)
).run(
category[type],
tags,
mutation.file!.name,
processed.output.length,
processed.width,
processed.height,
now,
activeLock.current.id,
);
const result =
(await (
await database.prepare('SELECT * FROM managed_images WHERE id=?')
).get(activeLock.current.id)) ?? null;
if (transactionStarted) {
await database.exec('COMMIT');
committed = true;
}
return result;
}
if (transactionStarted) {
await database.exec('COMMIT');
committed = true;
}
} catch (error) {
if (transactionStarted && !committed)
await database.exec('ROLLBACK').catch(() => undefined);
throw error;
} finally {
lock?.release?.();
}
}
const id = randomUUID();
const storageKey = `library-${id}.webp`;
const filePath = storagePath(storageKey);
let release: (() => void) | undefined;
let transactionStarted = false;
let committed = false;
try {
if (!journal) {
await database.exec('BEGIN IMMEDIATE');
transactionStarted = true;
release = await acquireMediaPathLock(filePath);
} else await journal.lockPath(filePath);
await saveManagedImageFileUnlocked(
storageKey,
filePath,
processed.output,
journal,
);
const name = `${entityName.slice(0, 56)} · ${type} render · ${entityId.slice(0, 8)}`;
await (
await database.prepare(`INSERT INTO managed_images
(id,name,category,tags_json,is_icon,force_white,storage_key,original_filename,byte_size,width,height,
source_entity_type,source_entity_id,created_by,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
).run(
id,
name,
category[type],
tags,
0,
0,
storageKey,
mutation.file!.name,
processed.output.length,
processed.width,
processed.height,
sourceType,
entityId,
actorId,
now,
now,
existing.id,
);
return (
const result =
(await (
await database.prepare('SELECT * FROM managed_images WHERE id=?')
).get(existing.id)) ?? null
);
).get(id)) ?? null;
if (transactionStarted) {
await database.exec('COMMIT');
committed = true;
}
return result;
} catch (error) {
if (transactionStarted && !committed)
await database.exec('ROLLBACK').catch(() => undefined);
throw error;
} finally {
release?.();
}
const id = randomUUID();
const storageKey = await saveManagedImageFile(id, processed.output);
const name = `${entityName.slice(0, 56)} · ${type} render · ${entityId.slice(0, 8)}`;
await (
await database.prepare(`INSERT INTO managed_images
(id,name,category,tags_json,is_icon,force_white,storage_key,original_filename,byte_size,width,height,
source_entity_type,source_entity_id,created_by,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
).run(
id,
name,
category[type],
tags,
0,
0,
storageKey,
mutation.file!.name,
processed.output.length,
processed.width,
processed.height,
sourceType,
entityId,
actorId,
now,
now,
);
return (
(await (
await database.prepare('SELECT * FROM managed_images WHERE id=?')
).get(id)) ?? null
);
}
export async function deleteManagedImage(database: Database, id: string) {
export async function deleteManagedImage(
database: Database,
id: string,
): Promise<{
image: Record<string, unknown>;
sourceIcon: Record<string, unknown> | null;
} | null> {
const image = await (
await database.prepare('SELECT * FROM managed_images WHERE id=?')
).get(id);
@@ -310,64 +504,98 @@ export async function deleteManagedImage(database: Database, id: string) {
'Managed image is missing its storage key.',
);
const sourceType =
typeof image.source_entity_type === 'string'
? image.source_entity_type
: null;
const sourceId =
typeof image.source_entity_id === 'string' ? image.source_entity_id : null;
if (Boolean(sourceType) !== Boolean(sourceId)) {
throw new ManagedImageDeletionError(
'This image has an incomplete source assignment and cannot be deleted safely.',
);
}
if (
sourceType &&
!GAME_ICON_TYPES.includes(sourceType as GameDataIconType) &&
!GAME_DATA_RENDER_TYPES.some((type) => sourceType === `${type}_render`)
) {
throw new ManagedImageDeletionError(
'This image has an unsupported source assignment and cannot be deleted safely.',
);
}
const sourceIcon =
sourceType &&
sourceId &&
GAME_ICON_TYPES.includes(sourceType as GameDataIconType)
? ((await (
await database.prepare(
'SELECT * FROM game_data_icons WHERE entity_type=? AND entity_id=?',
)
).get(sourceType, sourceId)) ?? null)
: null;
if (sourceIcon && sourceIcon.storage_key !== image.storage_key) {
throw new ManagedImageDeletionError(
'The library image and its source assignment reference different files. Resolve the assignment before deleting.',
);
}
await database.exec('BEGIN');
const filePath = storagePath(image.storage_key);
let release: (() => void) | undefined;
let transactionStarted = false;
let committed = false;
let retry = false;
let deleted: {
image: typeof image;
sourceIcon: typeof image | null;
} | null = null;
try {
if (sourceIcon && sourceType && sourceId) {
await database.exec('BEGIN IMMEDIATE');
transactionStarted = true;
release = await acquireMediaPathLock(filePath);
const current = await (
await database.prepare('SELECT * FROM managed_images WHERE id=?')
).get(id);
if (!current) {
await database.exec('COMMIT');
committed = true;
return null;
}
if (
current.storage_key !== image.storage_key ||
current.source_entity_type !== image.source_entity_type ||
current.source_entity_id !== image.source_entity_id
) {
retry = true;
} else {
const sourceType =
typeof current.source_entity_type === 'string'
? current.source_entity_type
: null;
const sourceId =
typeof current.source_entity_id === 'string'
? current.source_entity_id
: null;
if (Boolean(sourceType) !== Boolean(sourceId)) {
throw new ManagedImageDeletionError(
'This image has an incomplete source assignment and cannot be deleted safely.',
);
}
if (
sourceType &&
!GAME_ICON_TYPES.includes(sourceType as GameDataIconType) &&
!GAME_DATA_RENDER_TYPES.some((type) => sourceType === `${type}_render`)
) {
throw new ManagedImageDeletionError(
'This image has an unsupported source assignment and cannot be deleted safely.',
);
}
const sourceIcon =
sourceType &&
sourceId &&
GAME_ICON_TYPES.includes(sourceType as GameDataIconType)
? ((await (
await database.prepare(
'SELECT * FROM game_data_icons WHERE entity_type=? AND entity_id=?',
)
).get(sourceType, sourceId)) ?? null)
: null;
if (sourceIcon && sourceIcon.storage_key !== current.storage_key) {
throw new ManagedImageDeletionError(
'The library image and its source assignment reference different files. Resolve the assignment before deleting.',
);
}
if (sourceIcon && sourceType && sourceId) {
await (
await database.prepare(
'DELETE FROM game_data_icons WHERE entity_type=? AND entity_id=? AND storage_key=?',
)
).run(sourceType, sourceId, current.storage_key);
}
await (
await database.prepare(
'DELETE FROM game_data_icons WHERE entity_type=? AND entity_id=? AND storage_key=?',
'DELETE FROM managed_images WHERE id=? AND storage_key=?',
)
).run(sourceType, sourceId, image.storage_key);
).run(id, current.storage_key);
await rm(filePath, { force: true });
deleted = { image: current, sourceIcon };
}
await (
await database.prepare(
'DELETE FROM managed_images WHERE id=? AND storage_key=?',
)
).run(id, image.storage_key);
await database.exec('COMMIT');
committed = true;
} catch (error) {
await database.exec('ROLLBACK').catch(() => undefined);
if (transactionStarted && !committed)
await database.exec('ROLLBACK').catch(() => undefined);
throw error;
} finally {
release?.();
}
await rm(storagePath(image.storage_key), { force: true });
return { image, sourceIcon };
return retry ? deleteManagedImage(database, id) : deleted;
}
export async function readManagedImage(database: Database, id: string) {
+311
View File
@@ -0,0 +1,311 @@
import {
parseMechanicAuthoredSource,
type MechanicAuthoredOwner,
type MechanicAuthoredSource,
} from '$lib/calculations/mechanic-authored-description';
import { hashMechanicAuthoredSource } from '$lib/calculations/mechanic-lifecycle';
import type { Database } from '@tursodatabase/database';
import { randomUUID } from 'node:crypto';
export type MechanicAuthoredSourceRecord = {
id: string;
source: MechanicAuthoredSource;
fingerprint: string;
createdAt: string;
updatedAt: string;
};
export type SaveMechanicAuthoredSourceOptions = {
/** Undefined is reserved for migration and trusted repair code. */
expectedSourceFingerprint?: string | null;
};
export class MechanicSourceFingerprintConflictError extends Error {
readonly expectedFingerprint: string | null;
readonly currentFingerprint: string | null;
constructor(
expectedFingerprint: string | null,
currentFingerprint: string | null,
) {
super('The authored mechanic changed after this editor was loaded.');
this.name = 'MechanicSourceFingerprintConflictError';
this.expectedFingerprint = expectedFingerprint;
this.currentFingerprint = currentFingerprint;
}
}
type SourceRow = Record<string, unknown>;
type VariantRow = Record<string, unknown>;
type SlotRow = Record<string, unknown>;
function ownerFromRow(row: SourceRow): MechanicAuthoredOwner {
const type = String(row.owner_type);
const id = String(row.owner_id);
if (type === 'talent') {
return {
type,
id,
scope: row.talent_scope === 'exotic' ? 'exotic' : 'standard',
...(Number(row.talent_has_tier_ii ?? 0) === 1
? { hasTierII: true }
: {}),
};
}
if (
type === 'os_protocol_talent' ||
type === 'skill_chip_talent' ||
type === 'specialization_tree_rank'
) {
return { type, id };
}
throw new Error(`Unsupported authored mechanic owner type "${type}".`);
}
function sourceFromRows(
row: SourceRow,
variants: VariantRow[],
slots: SlotRow[],
): MechanicAuthoredSource {
const owner = ownerFromRow(row);
const source: MechanicAuthoredSource = {
version: Number(
row.authored_contract_version,
) as MechanicAuthoredSource['version'],
owner,
template: String(row.template),
variants: variants.map((variant) => ({
key: String(variant.variant_key),
materialized: String(variant.materialized),
slots: slots
.filter(
(slot) => String(slot.variant_key) === String(variant.variant_key),
)
.map((slot) => ({
key: String(slot.slot_key),
value: String(slot.display_value),
order: Number(slot.sort_order),
...(slot.unit === null || slot.unit === undefined
? {}
: { unit: String(slot.unit) }),
...(slot.stat_attribute_id === null ||
slot.stat_attribute_id === undefined
? {}
: { attributeId: String(slot.stat_attribute_id) }),
})),
})),
...(owner.type === 'specialization_tree_rank'
? {
specialization: {
rankDescription: String(row.specialization_rank_description),
effectId: String(row.specialization_effect_id),
effectTemplate: String(row.specialization_effect_template),
},
}
: {}),
};
const parsed = parseMechanicAuthoredSource(source);
if (!parsed.ok) {
throw new Error(
`Stored authored mechanic source is invalid: ${parsed.errors
.map((error) => error.message)
.join(' ')}`,
);
}
return parsed.value;
}
async function loadSourceRows(database: Database, row: SourceRow) {
const [variants, slots] = await Promise.all([
(
await database.prepare(
`SELECT * FROM mechanic_authored_variants
WHERE source_id=? ORDER BY sort_order`,
)
).all(row.id),
(
await database.prepare(
`SELECT * FROM mechanic_authored_slots
WHERE source_id=? ORDER BY variant_key,sort_order`,
)
).all(row.id),
]);
return { variants, slots };
}
export async function loadMechanicAuthoredSource(
database: Database,
ownerType: MechanicAuthoredOwner['type'],
ownerId: string,
): Promise<MechanicAuthoredSourceRecord | null> {
const row = await (
await database.prepare(
`SELECT * FROM mechanic_authored_sources
WHERE owner_type=? AND owner_id=?`,
)
).get(ownerType, ownerId);
if (!row) return null;
const { variants, slots } = await loadSourceRows(database, row);
return {
id: String(row.id),
source: sourceFromRows(row, variants, slots),
fingerprint: String(row.source_fingerprint),
createdAt: String(row.created_at),
updatedAt: String(row.updated_at),
};
}
export async function loadMechanicAuthoredSourceById(
database: Database,
sourceId: string,
): Promise<MechanicAuthoredSourceRecord | null> {
const row = await (
await database.prepare(
'SELECT * FROM mechanic_authored_sources WHERE id=?',
)
).get(sourceId);
if (!row) return null;
const { variants, slots } = await loadSourceRows(database, row);
return {
id: String(row.id),
source: sourceFromRows(row, variants, slots),
fingerprint: String(row.source_fingerprint),
createdAt: String(row.created_at),
updatedAt: String(row.updated_at),
};
}
/**
* Replaces one mutable authored source inside the caller's active owner
* transaction. Model revisions and selections are intentionally untouched.
*/
export async function saveMechanicAuthoredSourceInTransaction(
database: Database,
source: MechanicAuthoredSource,
options: SaveMechanicAuthoredSourceOptions = {},
): Promise<MechanicAuthoredSourceRecord> {
const parsed = parseMechanicAuthoredSource(source);
if (!parsed.ok) {
throw new Error(parsed.errors.map((error) => error.message).join(' '));
}
const fingerprintResult = await hashMechanicAuthoredSource(parsed.value);
if (!fingerprintResult.ok) {
throw new Error(
fingerprintResult.errors.map((error) => error.message).join(' '),
);
}
const existing = await (
await database.prepare(
`SELECT id,source_fingerprint,created_at
FROM mechanic_authored_sources
WHERE owner_type=? AND owner_id=?`,
)
).get(parsed.value.owner.type, parsed.value.owner.id);
const currentFingerprint = existing
? String(existing.source_fingerprint)
: null;
if (
options.expectedSourceFingerprint !== undefined &&
options.expectedSourceFingerprint !== currentFingerprint
) {
throw new MechanicSourceFingerprintConflictError(
options.expectedSourceFingerprint,
currentFingerprint,
);
}
const now = new Date().toISOString();
const sourceId = String(existing?.id ?? randomUUID());
const owner = parsed.value.owner;
await (
await database.prepare(`INSERT INTO mechanic_authored_sources (
id,owner_type,owner_id,template,authored_contract_version,source_fingerprint,
talent_scope,talent_has_tier_ii,specialization_rank_description,
specialization_effect_id,specialization_effect_template,created_at,updated_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(owner_type,owner_id) DO UPDATE SET
template=excluded.template,
authored_contract_version=excluded.authored_contract_version,
source_fingerprint=excluded.source_fingerprint,
talent_scope=excluded.talent_scope,
talent_has_tier_ii=excluded.talent_has_tier_ii,
specialization_rank_description=excluded.specialization_rank_description,
specialization_effect_id=excluded.specialization_effect_id,
specialization_effect_template=excluded.specialization_effect_template,
updated_at=excluded.updated_at`)
).run(
sourceId,
owner.type,
owner.id,
parsed.value.template,
parsed.value.version,
fingerprintResult.fingerprint,
owner.type === 'talent' ? owner.scope : null,
owner.type === 'talent' ? (owner.hasTierII ? 1 : 0) : null,
parsed.value.specialization?.rankDescription ?? null,
parsed.value.specialization?.effectId ?? null,
parsed.value.specialization?.effectTemplate ?? null,
String(existing?.created_at ?? now),
now,
);
await (
await database.prepare(
'DELETE FROM mechanic_authored_slots WHERE source_id=?',
)
).run(sourceId);
await (
await database.prepare(
'DELETE FROM mechanic_authored_variants WHERE source_id=?',
)
).run(sourceId);
for (const [variantIndex, variant] of parsed.value.variants.entries()) {
await (
await database.prepare(`INSERT INTO mechanic_authored_variants
(source_id,variant_key,materialized,sort_order) VALUES (?,?,?,?)`)
).run(sourceId, variant.key, variant.materialized, variantIndex);
for (const slot of variant.slots) {
await (
await database.prepare(`INSERT INTO mechanic_authored_slots
(source_id,variant_key,slot_key,display_value,sort_order,unit,stat_attribute_id)
VALUES (?,?,?,?,?,?,?)`)
).run(
sourceId,
variant.key,
slot.key,
slot.value,
slot.order,
slot.unit ?? null,
slot.attributeId ?? null,
);
}
}
return {
id: sourceId,
source: parsed.value,
fingerprint: fingerprintResult.fingerprint,
createdAt: String(existing?.created_at ?? now),
updatedAt: now,
};
}
export function mechanicAuthoredSourceSnapshot(
record: MechanicAuthoredSourceRecord | null,
) {
if (!record) return null;
return {
id: record.id,
owner: record.source.owner,
template: record.source.template,
authoredContractVersion: record.source.version,
sourceFingerprint: record.fingerprint,
variants: record.source.variants,
...(record.source.specialization
? { specialization: record.source.specialization }
: {}),
};
}
+634
View File
@@ -0,0 +1,634 @@
import {
parseConditionalMechanicDraft,
type ConditionalMechanicDraftV1,
type ConditionalMechanicOutcomeDraft,
type ConditionalMechanicPoolDraft,
} from '$lib/calculations/mechanic-editor';
import {
MECHANIC_PROGRAM_HASH_VERSION,
hashMechanicProgramVariants,
type MechanicSimulationReadiness,
} from '$lib/calculations/mechanic-lifecycle';
import type { MechanicPublicProgramVariant } from '$lib/calculations/mechanic-public-renderer';
import type { Database } from '@tursodatabase/database';
import { randomUUID } from 'node:crypto';
import type { MechanicAuthoredSourceRecord } from './mechanic-authored-sources';
export const MECHANIC_SELECTION_INTENTS = ['preserve', 'save', 'clear'] as const;
export type MechanicSelectionIntent =
(typeof MECHANIC_SELECTION_INTENTS)[number];
export type MechanicModelRevisionRecord = {
id: string;
sourceId: string;
revisionNumber: number;
sourceFingerprint: string;
mechanicContractVersion: number;
programHash: string;
simulationReadiness: MechanicSimulationReadiness;
programName: string;
draft: ConditionalMechanicDraftV1;
variants: MechanicPublicProgramVariant[];
origin: string;
actorId: string | null;
createdAt: string;
};
export type SaveMechanicModelRevisionOptions = {
programName: string;
origin: string;
actorId: string | null;
};
type Row = Record<string, unknown>;
type ClauseDraft = ConditionalMechanicDraftV1['clauses'][number];
type StoredVariant = { key: string; label: string };
const parseJson = <T>(value: unknown, label: string): T => {
try {
return JSON.parse(String(value)) as T;
} catch {
throw new Error(`Stored mechanic ${label} JSON is invalid.`);
}
};
function variantLabel(key: string) {
if (key === 'I') return 'Tier I';
if (key === 'II') return 'Tier II';
if (key === 'minimum') return 'Minimum';
if (key === 'maximum') return 'Maximum';
return 'Base';
}
function sourceVariants(source: MechanicAuthoredSourceRecord): StoredVariant[] {
return source.source.variants.map((variant) => ({
key: variant.key,
label: variantLabel(variant.key),
}));
}
function parseDraft(
draft: ConditionalMechanicDraftV1,
variants: StoredVariant[],
programName: string,
) {
const parsed = parseConditionalMechanicDraft(JSON.stringify(draft), {
variantKeys: variants.map((variant) => variant.key),
programName,
});
if (!parsed.ok) throw new Error(parsed.errors.join(' '));
return {
draft: parsed.value.draft,
variants: variants.map((variant) => ({
...variant,
program: parsed.value.programs[variant.key],
})),
};
}
function assertVariantMetadataConsistent(
effectKey: string,
rows: Row[],
columns: readonly string[],
) {
const first = rows[0];
for (const row of rows.slice(1)) {
const mismatch = columns.find(
(column) => (row[column] ?? null) !== (first[column] ?? null),
);
if (mismatch) {
throw new Error(
`Stored mechanic outcome "${effectKey}" has inconsistent ${mismatch} metadata.`,
);
}
}
}
const outcomeMetadataColumns = [
'label',
'outcome_kind',
'effect_scope',
'value_basis',
'resource_kind',
'delivery_mode',
'delivery_duration_seconds',
'application_phase',
'combat_timing',
'stat_attribute_id',
'state_pool_id',
'per_stack',
'min_state_value',
'max_state_value',
'targeting_json',
'probability_percent',
'sort_order',
] as const;
function draftFromRows(
revision: Row,
poolRows: Row[],
clauseRows: Row[],
outcomeRows: Row[],
variants: StoredVariant[],
): ConditionalMechanicDraftV1 {
const poolKeyById = new Map(
poolRows.map((row) => [String(row.id), String(row.pool_key)]),
);
const pools = poolRows.map(
(row): ConditionalMechanicPoolDraft => ({
key: String(row.pool_key),
label: String(row.label),
stateKind: String(
row.state_kind,
) as ConditionalMechanicPoolDraft['stateKind'],
scope: String(row.state_scope) as ConditionalMechanicPoolDraft['scope'],
initial: Number(row.initial_value),
...(row.minimum_value === null ? {} : { min: Number(row.minimum_value) }),
...(row.maximum_value === null ? {} : { max: Number(row.maximum_value) }),
...(row.best_case_value === null
? {}
: { bestCaseValue: Number(row.best_case_value) }),
...(row.duration_seconds === null
? {}
: { expiresAfterSeconds: Number(row.duration_seconds) }),
...(row.duration_variants_json === null
? {}
: {
expiresAfterSecondsVariants: parseJson<Record<string, number>>(
row.duration_variants_json,
'pool duration variants',
),
}),
}),
);
const outcomesByClause = new Map<string, Row[]>();
for (const row of outcomeRows) {
const clauseId = String(row.clause_id);
outcomesByClause.set(clauseId, [
...(outcomesByClause.get(clauseId) ?? []),
row,
]);
}
const clauses = clauseRows.map((clause) => {
const grouped = new Map<string, Row[]>();
for (const outcome of outcomesByClause.get(String(clause.id)) ?? []) {
const effectKey = String(outcome.effect_key);
grouped.set(effectKey, [...(grouped.get(effectKey) ?? []), outcome]);
}
const outcomes = [...grouped.entries()]
.sort(
([, left], [, right]) =>
Number(left[0].sort_order) - Number(right[0].sort_order),
)
.map(([effectKey, rows]): ConditionalMechanicOutcomeDraft => {
assertVariantMetadataConsistent(
effectKey,
rows,
outcomeMetadataColumns,
);
const first = rows[0];
const statePool =
first.state_pool_id === null
? undefined
: poolKeyById.get(String(first.state_pool_id));
return {
key: effectKey,
label: String(first.label),
phase: String(
first.application_phase,
) as ConditionalMechanicOutcomeDraft['phase'],
kind: String(
first.outcome_kind,
) as ConditionalMechanicOutcomeDraft['kind'],
scope: String(
first.effect_scope,
) as ConditionalMechanicOutcomeDraft['scope'],
basis: String(
first.value_basis,
) as ConditionalMechanicOutcomeDraft['basis'],
...(first.resource_kind === null
? {}
: {
resource: String(
first.resource_kind,
) as ConditionalMechanicOutcomeDraft['resource'],
}),
...(first.delivery_mode === 'continuous'
? {
delivery: 'continuous' as const,
durationSeconds: Number(first.delivery_duration_seconds),
}
: {}),
...(first.combat_timing === 'pre_event'
? { combatTiming: 'pre_event' as const }
: {}),
...(first.stat_attribute_id === null
? {}
: { attributeId: String(first.stat_attribute_id) }),
...(statePool ? { statePool } : {}),
perStack: Number(first.per_stack) === 1,
...(first.min_state_value === null
? {}
: { minStacks: Number(first.min_state_value) }),
...(first.max_state_value === null
? {}
: { maxStacks: Number(first.max_state_value) }),
...(first.targeting_json === null
? {}
: {
targeting: parseJson(
first.targeting_json,
'outcome targeting',
),
}),
...(first.probability_percent === null
? {}
: { probabilityPercent: Number(first.probability_percent) }),
variants: Object.fromEntries(
variants.map((variant) => {
const row = rows.find(
(candidate) => candidate.variant_key === variant.key,
);
if (!row) {
throw new Error(
`Stored mechanic outcome "${effectKey}" is missing variant ${variant.key}.`,
);
}
return [
variant.key,
{
value: Number(row.value),
...(row.value_expression_json === null
? {}
: {
expression: parseJson(
row.value_expression_json,
'outcome expression',
),
}),
},
];
}),
),
};
});
const result: ClauseDraft = {
key: String(clause.clause_key),
...(clause.label === null ? {} : { label: String(clause.label) }),
event: String(clause.event_kind) as ConditionalMechanicDraftV1['clauses'][number]['event'],
...(clause.recipe_key === null
? {}
: { recipeKey: String(clause.recipe_key) }),
...(clause.conditions_json === null
? {}
: {
if: parseJson<NonNullable<ClauseDraft['if']>>(
clause.conditions_json,
'clause conditions',
),
}),
reducer: parseJson<ClauseDraft['reducer']>(
clause.reducer_json,
'clause reducer',
),
...(clause.weight_expression_json === null
? {}
: {
weight: parseJson<NonNullable<ClauseDraft['weight']>>(
clause.weight_expression_json,
'clause weight',
),
}),
...(clause.cooldown_seconds === null
? {}
: { cooldownSeconds: Number(clause.cooldown_seconds) }),
...(clause.proc_chance_percent === null
? {}
: { procChancePercent: Number(clause.proc_chance_percent) }),
...(clause.requires_assigned_ability_active === null
? {}
: {
requiresAssignedAbilityActive:
Number(clause.requires_assigned_ability_active) === 1,
}),
mutations: parseJson<ClauseDraft['mutations']>(
clause.mutations_json,
'clause mutations',
),
outcomes,
};
return result;
});
return {
version: 1,
simulationReadiness: String(
revision.simulation_readiness,
) as MechanicSimulationReadiness,
scenarioInputs: parseJson(
revision.scenario_inputs_json,
'Scenario Inputs',
),
pools,
clauses,
};
}
async function revisionFromRow(
database: Database,
revision: Row,
): Promise<MechanicModelRevisionRecord> {
const [pools, clauses, outcomes] = await Promise.all([
(
await database.prepare(
`SELECT * FROM mechanic_model_revision_pools
WHERE revision_id=? ORDER BY sort_order`,
)
).all(revision.id),
(
await database.prepare(
`SELECT * FROM mechanic_model_revision_clauses
WHERE revision_id=? ORDER BY sort_order`,
)
).all(revision.id),
(
await database.prepare(
`SELECT * FROM mechanic_model_revision_outcomes
WHERE revision_id=? ORDER BY sort_order,variant_key`,
)
).all(revision.id),
]);
const storedVariants = parseJson<StoredVariant[]>(
revision.source_variants_json,
'source variants',
);
const draft = draftFromRows(revision, pools, clauses, outcomes, storedVariants);
const parsed = parseDraft(draft, storedVariants, String(revision.program_name));
return {
id: String(revision.id),
sourceId: String(revision.source_id),
revisionNumber: Number(revision.revision_number),
sourceFingerprint: String(revision.source_fingerprint),
mechanicContractVersion: Number(revision.mechanic_contract_version),
programHash: String(revision.program_hash),
simulationReadiness: String(
revision.simulation_readiness,
) as MechanicSimulationReadiness,
programName: String(revision.program_name),
draft: parsed.draft,
variants: parsed.variants,
origin: String(revision.origin),
actorId:
revision.actor_id === null || revision.actor_id === undefined
? null
: String(revision.actor_id),
createdAt: String(revision.created_at),
};
}
export async function loadMechanicModelRevision(
database: Database,
revisionId: string,
): Promise<MechanicModelRevisionRecord | null> {
const revision = await (
await database.prepare(
'SELECT * FROM mechanic_model_revisions WHERE id=?',
)
).get(revisionId);
return revision ? revisionFromRow(database, revision) : null;
}
export async function loadSelectedMechanicModelRevision(
database: Database,
sourceId: string,
): Promise<MechanicModelRevisionRecord | null> {
const revision = await (
await database.prepare(`SELECT revision.*
FROM mechanic_model_selections selection
JOIN mechanic_model_revisions revision
ON revision.id=selection.revision_id
AND revision.source_id=selection.source_id
WHERE selection.source_id=?`)
).get(sourceId);
return revision ? revisionFromRow(database, revision) : null;
}
export async function selectMechanicModelRevisionInTransaction(
database: Database,
sourceId: string,
revisionId: string,
) {
await (
await database.prepare(`INSERT INTO mechanic_model_selections
(source_id,revision_id,updated_at) VALUES (?,?,?)
ON CONFLICT(source_id) DO UPDATE SET
revision_id=excluded.revision_id,
updated_at=excluded.updated_at`)
).run(sourceId, revisionId, new Date().toISOString());
}
export async function clearMechanicModelSelectionInTransaction(
database: Database,
sourceId: string,
) {
await (
await database.prepare(
'DELETE FROM mechanic_model_selections WHERE source_id=?',
)
).run(sourceId);
}
/** Appends and selects a normalized model revision in the caller transaction. */
export async function saveMechanicModelRevisionInTransaction(
database: Database,
source: MechanicAuthoredSourceRecord,
draft: ConditionalMechanicDraftV1,
options: SaveMechanicModelRevisionOptions,
): Promise<{ created: boolean; revision: MechanicModelRevisionRecord }> {
const storedVariants = sourceVariants(source);
const parsed = parseDraft(draft, storedVariants, options.programName);
const programHash = await hashMechanicProgramVariants({
simulationReadiness: parsed.draft.simulationReadiness,
variants: parsed.variants,
});
const selected = await loadSelectedMechanicModelRevision(database, source.id);
if (
selected?.sourceFingerprint === source.fingerprint &&
selected.mechanicContractVersion === MECHANIC_PROGRAM_HASH_VERSION &&
selected.programHash === programHash
) {
return { created: false, revision: selected };
}
const latest = await (
await database.prepare(
`SELECT COALESCE(MAX(revision_number),0) AS revision_number
FROM mechanic_model_revisions WHERE source_id=?`,
)
).get(source.id);
const revisionNumber = Number(latest?.revision_number ?? 0) + 1;
const revisionId = randomUUID();
const now = new Date().toISOString();
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,actor_id,created_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`)
).run(
revisionId,
source.id,
revisionNumber,
source.fingerprint,
MECHANIC_PROGRAM_HASH_VERSION,
programHash,
parsed.draft.simulationReadiness,
JSON.stringify(parsed.draft.scenarioInputs),
JSON.stringify(storedVariants),
options.programName,
options.origin,
options.actorId,
now,
);
const poolIds = new Map<string, string>();
for (const [index, pool] of parsed.draft.pools.entries()) {
const poolId = randomUUID();
poolIds.set(pool.key, poolId);
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 (?,?,?,?,?,?,?,?,?,?,?,?,?)`)
).run(
poolId,
revisionId,
pool.key,
pool.label,
pool.stateKind,
pool.scope,
pool.initial,
pool.min ?? null,
pool.max ?? null,
pool.bestCaseValue ?? null,
pool.expiresAfterSeconds ?? null,
pool.expiresAfterSecondsVariants === undefined
? null
: JSON.stringify(pool.expiresAfterSecondsVariants),
index,
);
}
for (const [clauseIndex, clause] of parsed.draft.clauses.entries()) {
const clauseId = randomUUID();
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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
).run(
clauseId,
revisionId,
clause.key,
clause.label ?? null,
clause.event,
clause.recipeKey ?? null,
clause.if === undefined ? null : JSON.stringify(clause.if),
JSON.stringify(clause.reducer),
clause.weight === undefined ? null : JSON.stringify(clause.weight),
JSON.stringify(clause.mutations),
clause.cooldownSeconds ?? null,
clause.procChancePercent ?? null,
clause.requiresAssignedAbilityActive === undefined
? null
: clause.requiresAssignedAbilityActive
? 1
: 0,
clauseIndex,
);
for (const [outcomeIndex, outcome] of clause.outcomes.entries()) {
for (const storedVariant of storedVariants) {
const value = outcome.variants[storedVariant.key];
if (!value) {
throw new Error(
`Mechanic outcome ${outcome.key} is missing variant ${storedVariant.key}.`,
);
}
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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
).run(
randomUUID(),
revisionId,
clauseId,
outcome.statePool ? (poolIds.get(outcome.statePool) ?? null) : null,
outcome.key,
storedVariant.key,
outcome.label,
outcome.kind,
outcome.scope,
value.value,
value.expression === undefined
? null
: JSON.stringify(value.expression),
outcome.basis,
outcome.resource ?? null,
outcome.delivery ?? 'instant',
outcome.durationSeconds ?? null,
outcome.phase,
outcome.combatTiming ?? 'current',
outcome.attributeId ?? null,
outcome.perStack ? 1 : 0,
outcome.minStacks ?? null,
outcome.maxStacks ?? null,
outcome.targeting === undefined
? null
: JSON.stringify(outcome.targeting),
outcome.probabilityPercent ?? null,
outcomeIndex,
);
}
}
}
await selectMechanicModelRevisionInTransaction(
database,
source.id,
revisionId,
);
const revision = await loadMechanicModelRevision(database, revisionId);
if (!revision) throw new Error('The appended mechanic revision was not found.');
return { created: true, revision };
}
export function mechanicModelRevisionSnapshot(
revision: MechanicModelRevisionRecord | null,
) {
if (!revision) return null;
return {
id: revision.id,
revisionNumber: revision.revisionNumber,
sourceFingerprint: revision.sourceFingerprint,
mechanicContractVersion: revision.mechanicContractVersion,
programHash: revision.programHash,
simulationReadiness: revision.simulationReadiness,
scenarioInputs: revision.draft.scenarioInputs,
pools: revision.draft.pools,
clauses: revision.draft.clauses,
origin: revision.origin,
actorId: revision.actorId,
createdAt: revision.createdAt,
};
}
@@ -0,0 +1,147 @@
import { describe, expect, it } from 'vitest';
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
import {
parseExpectedOwnerUpdatedAt,
validateMechanicAuthoredTemplate,
validateMechanicOwnerCapabilities,
} from './mechanic-owner-mutation';
const draft = (): ConditionalMechanicDraftV1 => ({
version: 1,
simulationReadiness: 'captured_only',
scenarioInputs: [],
pools: [],
clauses: [
{
key: 'primary',
label: 'Primary',
event: 'weapon_hit',
if: { kind: 'true' },
reducer: { kind: 'none' },
mutations: [],
outcomes: [
{
key: 'effect',
label: 'Effect',
phase: 'on_event',
kind: 'marker',
scope: 'self',
basis: 'flat',
variants: { base: { value: 1 } },
},
],
},
],
});
const withAssignedAbilityBehavior = () => {
const value = draft();
value.clauses[0].requiresAssignedAbilityActive = true;
value.clauses[0].mutations = [
{ kind: 'extend_duration', target: 'assigned_ability', seconds: 1 },
];
value.clauses[0].outcomes[0].scope = 'assigned_ability';
value.clauses[0].if = {
kind: 'exists',
value: { source: 'assigned_ability', path: 'active' },
};
return value;
};
const withProtocolUpgradeReference = () => {
const value = draft();
value.clauses[0].outcomes[0].variants.base.expression = {
op: 'add',
left: { ref: { source: 'variant_value' } },
right: {
ref: { source: 'equipment', path: 'protocolTalentUpgrades' },
},
};
return value;
};
describe('owner optimistic-concurrency token parsing', () => {
it('requires exact bounded tokens for existing owners', () => {
expect(parseExpectedOwnerUpdatedAt('2026-08-02T12:00:00.000Z', true)).toBe(
'2026-08-02T12:00:00.000Z',
);
expect(parseExpectedOwnerUpdatedAt('', false)).toBeNull();
expect(() => parseExpectedOwnerUpdatedAt('', true)).toThrow(/required/);
expect(() => parseExpectedOwnerUpdatedAt(' padded ', true)).toThrow(/invalid/);
expect(() => parseExpectedOwnerUpdatedAt('bad\u0000token', true)).toThrow(
/invalid/,
);
expect(() => parseExpectedOwnerUpdatedAt('x'.repeat(101), true)).toThrow(
/invalid/,
);
});
});
describe('mechanic owner capability validation', () => {
it('rejects assigned-ability behavior for Talent, Protocol, and Specialization owners', () => {
for (const ownerType of [
'talent',
'os_protocol_talent',
'specialization_tree_rank',
] as const) {
expect(
validateMechanicOwnerCapabilities(
ownerType,
withAssignedAbilityBehavior(),
),
).toEqual(
expect.arrayContaining([
expect.stringContaining('assigned ability'),
expect.stringContaining('assigned-ability'),
]),
);
}
});
it('allows the supported owner-specific capability and rejects protocol scaling elsewhere', () => {
expect(
validateMechanicOwnerCapabilities(
'skill_chip_talent',
withAssignedAbilityBehavior(),
),
).toEqual([]);
expect(
validateMechanicOwnerCapabilities(
'os_protocol_talent',
withProtocolUpgradeReference(),
),
).toEqual([]);
for (const ownerType of [
'talent',
'skill_chip_talent',
'specialization_tree_rank',
] as const) {
expect(
validateMechanicOwnerCapabilities(
ownerType,
withProtocolUpgradeReference(),
),
).toEqual(
expect.arrayContaining([
expect.stringContaining('protocolTalentUpgrades'),
]),
);
}
});
it('uses the authored template grammar without requiring an owner source', () => {
expect(
validateMechanicAuthoredTemplate('Gain {1} for {duration}.'),
).toEqual([]);
expect(validateMechanicAuthoredTemplate('Gain {unsupported}.')).toEqual(
expect.arrayContaining([expect.stringContaining('unsupported token')]),
);
expect(validateMechanicAuthoredTemplate('Gain {1.')).toEqual(
expect.arrayContaining([expect.stringContaining('malformed')]),
);
expect(validateMechanicAuthoredTemplate('')).toEqual(
expect.arrayContaining([expect.stringContaining('required')]),
);
});
});
+340
View File
@@ -0,0 +1,340 @@
import {
parseMechanicAuthoredSource,
type MechanicAuthoredOwner,
type MechanicAuthoredSource,
type SpecializationAuthoredInputs,
} from '$lib/calculations/mechanic-authored-description';
import {
parseConditionalMechanicDraft,
type ConditionalMechanicDraftV1,
} from '$lib/calculations/mechanic-editor';
import type { MechanicAttributeLabelResolver } from '$lib/calculations/mechanic-public-renderer';
import type { Database } from '@tursodatabase/database';
import {
mechanicAuthoredSourceSnapshot,
saveMechanicAuthoredSourceInTransaction,
} from './mechanic-authored-sources';
import {
clearMechanicModelSelectionInTransaction,
loadSelectedMechanicModelRevision,
mechanicModelRevisionSnapshot,
saveMechanicModelRevisionInTransaction,
type MechanicModelRevisionRecord,
type MechanicSelectionIntent,
} from './mechanic-model-revisions';
import {
mechanicPublicProjectionSnapshot,
refreshMechanicPublicProjectionInTransaction,
} from './mechanic-public-projection';
type AuthoredPayload = Pick<
MechanicAuthoredSource,
'version' | 'template' | 'variants'
>;
type MechanicOwnerType = MechanicAuthoredOwner['type'];
const authoredTokenPattern = /\{([^{}]+)\}/g;
const authoredNamedTokens = new Set(['duration', 'max_stacks']);
/**
* Validates the token grammar shared by authored descriptions and reusable
* Specialization combat-effect templates. This intentionally does not require
* an owner or variant payload, so templates can be checked before any source
* exists (including a reusable effect with no rank attachments).
*/
export function validateMechanicAuthoredTemplate(template: unknown): string[] {
const errors: string[] = [];
if (typeof template !== 'string' || !template.trim()) {
errors.push('The authored template is required.');
return errors;
}
if (/[{}]/.test(template.replace(authoredTokenPattern, ''))) {
errors.push('The authored template contains malformed placeholder braces.');
}
for (const match of template.matchAll(authoredTokenPattern)) {
const token = match[1];
if (!authoredNamedTokens.has(token) && !/^[1-9]\d*$/.test(token)) {
errors.push(
`The authored template contains unsupported token {${token}}.`,
);
}
}
return errors;
}
/**
* Returns owner-specific restrictions for a typed conditional mechanic draft.
* The draft has already passed its structural parser, but this second walk is
* required because callers outside a form action can still supply forged JSON.
*/
export function validateMechanicOwnerCapabilities(
ownerType: MechanicOwnerType,
draft: ConditionalMechanicDraftV1,
): string[] {
const errors: string[] = [];
const allowsAssignedAbility = ownerType === 'skill_chip_talent';
const allowsProtocolUpgradeScaling = ownerType === 'os_protocol_talent';
const add = (message: string) => {
if (!errors.includes(message)) errors.push(message);
};
if (!allowsAssignedAbility) {
draft.clauses.forEach((clause, clauseIndex) => {
const path = `Clause ${clauseIndex + 1}`;
if (clause.requiresAssignedAbilityActive === true) {
add(
`${path} cannot require an active assigned ability for this owner.`,
);
}
clause.mutations.forEach((mutation, mutationIndex) => {
if (
mutation.kind === 'extend_duration' &&
mutation.target === 'assigned_ability'
) {
add(
`${path} mutation ${mutationIndex + 1} cannot extend assigned-ability duration for this owner.`,
);
}
});
clause.outcomes.forEach((outcome, outcomeIndex) => {
if (outcome.scope === 'assigned_ability') {
add(
`${path} outcome ${outcomeIndex + 1} cannot use the assigned-ability scope for this owner.`,
);
}
});
});
}
// Walk every typed expression, predicate, reducer, and nested draft value so
// references cannot be hidden in a branch that the editor does not expose.
const visit = (value: unknown, path: string) => {
if (Array.isArray(value)) {
value.forEach((item, index) => visit(item, `${path}[${index}]`));
return;
}
if (typeof value !== 'object' || value === null) return;
const record = value as Record<string, unknown>;
if (!allowsAssignedAbility && record.source === 'assigned_ability') {
add(`${path} cannot reference assigned-ability state for this owner.`);
}
if (
!allowsProtocolUpgradeScaling &&
record.source === 'equipment' &&
record.path === 'protocolTalentUpgrades'
) {
add(
`${path} cannot reference equipment.protocolTalentUpgrades for this owner.`,
);
}
Object.entries(record).forEach(([key, nested]) =>
visit(nested, `${path}.${key}`),
);
};
visit(draft, 'Conditional mechanic');
return errors;
}
export type MechanicOwnerSelection =
| { intent: 'preserve' }
| { intent: 'clear' }
| { intent: 'save'; draft: ConditionalMechanicDraftV1 };
export type PersistMechanicOwnerStateInput = {
source: MechanicAuthoredSource;
expectedSourceFingerprint: string | null;
selection: MechanicOwnerSelection;
programName: string;
origin: string;
actorId: string | null;
resolveAttributeLabel?: MechanicAttributeLabelResolver;
};
export type PersistedMechanicOwnerState = {
source: Awaited<ReturnType<typeof saveMechanicAuthoredSourceInTransaction>>;
selectedRevision: MechanicModelRevisionRecord | null;
revisionCreated: boolean;
projection: Awaited<
ReturnType<typeof refreshMechanicPublicProjectionInTransaction>
>;
};
function parseJsonObject(
raw: FormDataEntryValue | string | null,
label: string,
) {
if (typeof raw !== 'string' || !raw) throw new Error(`${label} is required.`);
try {
const value: unknown = JSON.parse(raw);
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new Error();
}
return value as Record<string, unknown>;
} catch {
throw new Error(`${label} must be valid JSON.`);
}
}
export function parseMechanicAuthoredSourcePayload(
raw: FormDataEntryValue | string | null,
owner: MechanicAuthoredOwner,
specialization?: SpecializationAuthoredInputs,
): MechanicAuthoredSource {
const payload = parseJsonObject(raw, 'The authored description');
const allowed = new Set(['version', 'template', 'variants']);
if (Object.keys(payload).some((key) => !allowed.has(key))) {
throw new Error('The authored description contains an unsupported field.');
}
const parsed = parseMechanicAuthoredSource({
...payload,
owner,
...(specialization ? { specialization } : {}),
});
if (!parsed.ok) {
throw new Error(parsed.errors.map((error) => error.message).join(' '));
}
return parsed.value;
}
export function parseExpectedSourceFingerprint(
raw: FormDataEntryValue | string | null,
): string | null {
if (raw === null || raw === '') return null;
const value = String(raw);
if (!/^[0-9a-f]{64}$/.test(value)) {
throw new Error('The expected authored-source fingerprint is invalid.');
}
return value;
}
export function parseExpectedOwnerUpdatedAt(
raw: FormDataEntryValue | string | null,
required: boolean,
): string | null {
if (raw === null || raw === '') {
if (required)
throw new Error('The expected owner version is required. Reload the editor and try again.');
return null;
}
const value = String(raw);
if (
value !== value.trim() ||
value.length > 100 ||
/[\u0000-\u001f\u007f-\u009f]/u.test(value)
) {
throw new Error('The expected owner version is invalid.');
}
return value;
}
export function parseMechanicSelectionIntent(
rawIntent: FormDataEntryValue | string | null,
rawDraft: FormDataEntryValue | string | null,
options: {
variantKeys: readonly string[];
allowedAttributeIds?: ReadonlySet<string>;
allowedRecipeKeys?: ReadonlySet<string>;
programName: string;
ownerType?: MechanicOwnerType;
},
): MechanicOwnerSelection {
const intent = String(rawIntent ?? '') as MechanicSelectionIntent;
if (intent === 'preserve' || intent === 'clear') return { intent };
if (intent !== 'save') {
throw new Error('Choose whether to preserve, save, or clear the model.');
}
const parsed = parseConditionalMechanicDraft(rawDraft, {
variantKeys: options.variantKeys,
allowedAttributeIds: options.allowedAttributeIds,
allowedRecipeKeys: options.allowedRecipeKeys,
programName: options.programName,
});
if (!parsed.ok) throw new Error(parsed.errors.join(' '));
const capabilityErrors = validateMechanicOwnerCapabilities(
options.ownerType ?? 'talent',
parsed.value.draft,
);
if (capabilityErrors.length) throw new Error(capabilityErrors.join(' '));
return { intent, draft: parsed.value.draft };
}
/**
* Persists authored source, selection intent, immutable revision, and public
* projection inside the caller's active owner transaction.
*/
export async function persistMechanicOwnerStateInTransaction(
database: Database,
input: PersistMechanicOwnerStateInput,
): Promise<PersistedMechanicOwnerState> {
if (input.selection.intent === 'save') {
const capabilityErrors = validateMechanicOwnerCapabilities(
input.source.owner.type,
input.selection.draft,
);
if (capabilityErrors.length) throw new Error(capabilityErrors.join(' '));
}
const source = await saveMechanicAuthoredSourceInTransaction(
database,
input.source,
{ expectedSourceFingerprint: input.expectedSourceFingerprint },
);
let revisionCreated = false;
let selectedRevision: MechanicModelRevisionRecord | null;
let forceAuthoredFallback = false;
if (input.selection.intent === 'save') {
const saved = await saveMechanicModelRevisionInTransaction(
database,
source,
input.selection.draft,
{
programName: input.programName,
origin: input.origin,
actorId: input.actorId,
},
);
revisionCreated = saved.created;
selectedRevision = saved.revision;
} else if (input.selection.intent === 'clear') {
await clearMechanicModelSelectionInTransaction(database, source.id);
selectedRevision = null;
} else {
selectedRevision = await loadSelectedMechanicModelRevision(
database,
source.id,
);
if (selectedRevision) {
forceAuthoredFallback =
validateMechanicOwnerCapabilities(
source.source.owner.type,
selectedRevision.draft,
).length > 0;
}
}
const projection = await refreshMechanicPublicProjectionInTransaction(
database,
source,
{
resolveAttributeLabel: input.resolveAttributeLabel,
forceAuthoredFallback,
},
);
return { source, selectedRevision, revisionCreated, projection };
}
export function mechanicOwnerStateSnapshot(
state: PersistedMechanicOwnerState | null,
) {
if (!state) return null;
return {
authoredSource: mechanicAuthoredSourceSnapshot(state.source),
selectedRevision: mechanicModelRevisionSnapshot(state.selectedRevision),
publicProjection: mechanicPublicProjectionSnapshot(
state.selectedRevision,
state.projection.variants,
),
};
}
+598
View File
@@ -0,0 +1,598 @@
import type { MechanicAuthoredSource } from '$lib/calculations/mechanic-authored-description';
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
import { connect, type Database } from '@tursodatabase/database';
import { readFile } from 'node:fs/promises';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
MechanicSourceFingerprintConflictError,
loadMechanicAuthoredSource,
saveMechanicAuthoredSourceInTransaction,
} from './mechanic-authored-sources';
import {
clearMechanicModelSelectionInTransaction,
loadSelectedMechanicModelRevision,
saveMechanicModelRevisionInTransaction,
} from './mechanic-model-revisions';
import {
parseExpectedSourceFingerprint,
parseMechanicAuthoredSourcePayload,
parseMechanicSelectionIntent,
persistMechanicOwnerStateInTransaction,
} from './mechanic-owner-mutation';
import {
loadMechanicPublicProjection,
refreshMechanicPublicProjectionInTransaction,
} from './mechanic-public-projection';
let database: Database;
const applySchema = async (target: Database) => {
const schema = await readFile(
new URL('../../../database/schema.sql', import.meta.url),
'utf8',
);
for (const statement of schema
.split(';')
.map((value) => value.trim())
.filter(Boolean)) {
await target.exec(statement);
}
};
const talentSource = (secondTierDamage = '20'): MechanicAuthoredSource => ({
version: 1,
owner: { type: 'talent', id: 'talent-1', scope: 'standard' },
template: 'After {1} hits, gain {2}% weapon damage for {duration} seconds.',
variants: [
{
key: 'I',
materialized: 'After 10 hits, gain 15% weapon damage for 5 seconds.',
slots: [
{ key: '1', value: '10', order: 0 },
{
key: '2',
value: '15',
order: 1,
unit: '%',
attributeId: 'weapon-damage',
},
{ key: 'duration', value: '5', order: 2, unit: 'seconds' },
],
},
{
key: 'II',
materialized: `After 8 hits, gain ${secondTierDamage}% weapon damage for 7 seconds.`,
slots: [
{ key: '1', value: '8', order: 0 },
{
key: '2',
value: secondTierDamage,
order: 1,
unit: '%',
attributeId: 'weapon-damage',
},
{ key: 'duration', value: '7', order: 2, unit: 'seconds' },
],
},
],
});
const mechanicDraft = (tierTwoValue = 20): ConditionalMechanicDraftV1 => ({
version: 1,
simulationReadiness: 'ready',
scenarioInputs: [],
pools: [],
clauses: [
{
key: 'weapon-hit',
label: 'Weapon hit',
event: 'weapon_hit',
reducer: { kind: 'count', threshold: 10 },
mutations: [],
outcomes: [
{
key: 'weapon-damage',
label: 'Weapon damage',
phase: 'after_event',
kind: 'attribute_modifier',
scope: 'equipped_weapon',
basis: 'flat',
attributeId: 'weapon-damage',
perStack: false,
variants: {
I: { value: 15 },
II: { value: tierTwoValue },
},
},
],
},
],
});
const protocolSource = (
description = 'Gain weapon damage when the protocol activates.',
): MechanicAuthoredSource => ({
version: 1,
owner: { type: 'os_protocol_talent', id: 'protocol-1' },
template: description,
variants: [{ key: 'base', materialized: description, slots: [] }],
});
const protocolDraftWithForbiddenScaling = (): ConditionalMechanicDraftV1 => {
const value = mechanicDraft();
value.clauses[0].outcomes[0].variants = { base: { value: 15 } };
value.clauses[0].outcomes[0].variants.base.expression = {
op: 'add',
left: { ref: { source: 'variant_value' } },
right: {
ref: { source: 'equipment', path: 'protocolTalentUpgrades' },
},
};
return value;
};
const count = async (table: string) =>
Number(
(
await (
await database.prepare(`SELECT COUNT(*) AS count FROM ${table}`)
).get()
)?.count ?? 0,
);
const execute = async (sql: string, ...parameters: unknown[]) =>
(await database.prepare(sql)).run(...parameters);
describe('mechanic authored source and revision persistence', () => {
beforeEach(async () => {
database = await connect(':memory:');
await database.exec('PRAGMA foreign_keys=ON');
await applySchema(database);
await database.exec(`INSERT INTO users(id,username,role,created_at)
VALUES('actor-1','Agent','admin','now')`);
await database.exec(`INSERT INTO stat_attributes(
id,name,type,category,calculation_type,unit,created_at,updated_at
) VALUES('weapon-damage','Weapon Damage','Offensive','Weapon','Additive','%','now','now')`);
});
afterEach(async () => {
await database.close();
});
it('constructs trusted owner sources and explicit selection intent from form payloads', () => {
const source = talentSource();
const payload = JSON.stringify({
version: source.version,
template: source.template,
variants: source.variants,
});
expect(parseMechanicAuthoredSourcePayload(payload, source.owner)).toEqual(
source,
);
expect(() =>
parseMechanicAuthoredSourcePayload(
JSON.stringify({
version: source.version,
owner: { type: 'talent', id: 'attacker' },
template: source.template,
variants: source.variants,
}),
source.owner,
),
).toThrow(/unsupported field/);
expect(parseExpectedSourceFingerprint('a'.repeat(64))).toBe('a'.repeat(64));
expect(parseExpectedSourceFingerprint('')).toBeNull();
expect(() => parseExpectedSourceFingerprint('not-a-hash')).toThrow(
/fingerprint is invalid/,
);
expect(
parseMechanicSelectionIntent('preserve', null, {
variantKeys: ['I', 'II'],
programName: 'Measured fire',
}),
).toEqual({ intent: 'preserve' });
expect(
parseMechanicSelectionIntent('save', JSON.stringify(mechanicDraft()), {
variantKeys: ['I', 'II'],
programName: 'Measured fire',
allowedAttributeIds: new Set(['weapon-damage']),
}),
).toMatchObject({ intent: 'save', draft: mechanicDraft() });
});
it('rejects a forged owner-restricted draft before writing its authored source', async () => {
const forged = mechanicDraft();
forged.clauses[0].requiresAssignedAbilityActive = true;
forged.clauses[0].outcomes[0].scope = 'assigned_ability';
forged.clauses[0].if = {
kind: 'exists',
value: { source: 'assigned_ability', path: 'active' },
};
await expect(
persistMechanicOwnerStateInTransaction(database, {
source: talentSource(),
expectedSourceFingerprint: null,
selection: { intent: 'save', draft: forged },
programName: 'Measured fire',
origin: 'isac',
actorId: 'actor-1',
}),
).rejects.toThrow(/assigned/);
expect(await count('mechanic_authored_sources')).toBe(0);
expect(await count('mechanic_model_revisions')).toBe(0);
});
it('round-trips exact authored variants and rejects stale fingerprints without writes', async () => {
const saved = await saveMechanicAuthoredSourceInTransaction(
database,
talentSource(),
{ expectedSourceFingerprint: null },
);
expect(
await loadMechanicAuthoredSource(database, 'talent', 'talent-1'),
).toEqual(saved);
await expect(
saveMechanicAuthoredSourceInTransaction(database, talentSource('25'), {
expectedSourceFingerprint: 'f'.repeat(64),
}),
).rejects.toBeInstanceOf(MechanicSourceFingerprintConflictError);
expect(await count('mechanic_authored_sources')).toBe(1);
expect(await count('mechanic_authored_variants')).toBe(2);
expect(await count('mechanic_authored_slots')).toBe(6);
expect(
(await loadMechanicAuthoredSource(database, 'talent', 'talent-1'))
?.fingerprint,
).toBe(saved.fingerprint);
});
it('appends revisions, skips the identical selected retry, and preserves history when cleared', async () => {
const source = await saveMechanicAuthoredSourceInTransaction(
database,
talentSource(),
{ expectedSourceFingerprint: null },
);
const first = await saveMechanicModelRevisionInTransaction(
database,
source,
mechanicDraft(),
{ programName: 'Measured fire', origin: 'isac', actorId: 'actor-1' },
);
expect(first.created).toBe(true);
expect(first.revision.revisionNumber).toBe(1);
const retry = await saveMechanicModelRevisionInTransaction(
database,
source,
mechanicDraft(),
{ programName: 'Measured fire', origin: 'isac', actorId: 'actor-1' },
);
expect(retry).toMatchObject({
created: false,
revision: { id: first.revision.id, revisionNumber: 1 },
});
expect(await count('mechanic_model_revisions')).toBe(1);
const second = await saveMechanicModelRevisionInTransaction(
database,
source,
mechanicDraft(25),
{ programName: 'Measured fire', origin: 'isac', actorId: 'actor-1' },
);
expect(second).toMatchObject({
created: true,
revision: { revisionNumber: 2 },
});
expect(await count('mechanic_model_revisions')).toBe(2);
expect(await count('mechanic_model_revision_outcomes')).toBe(4);
expect(
await loadSelectedMechanicModelRevision(database, source.id),
).toMatchObject({
id: second.revision.id,
revisionNumber: 2,
draft: mechanicDraft(25),
});
await clearMechanicModelSelectionInTransaction(database, source.id);
expect(
await loadSelectedMechanicModelRevision(database, source.id),
).toBeNull();
expect(await count('mechanic_model_revisions')).toBe(2);
expect(await count('mechanic_model_revision_outcomes')).toBe(4);
});
it('applies save, preserve, and clear intent through one owner persistence seam', async () => {
const source = talentSource();
const saved = await persistMechanicOwnerStateInTransaction(database, {
source,
expectedSourceFingerprint: null,
selection: { intent: 'save', draft: mechanicDraft() },
programName: 'Measured fire',
origin: 'isac',
actorId: 'actor-1',
resolveAttributeLabel: () => 'Weapon Damage',
});
expect(saved).toMatchObject({
revisionCreated: true,
selectedRevision: { revisionNumber: 1 },
projection: { lifecycle: { status: 'current' } },
});
const proseChanged = talentSource('25');
const preserved = await persistMechanicOwnerStateInTransaction(database, {
source: proseChanged,
expectedSourceFingerprint: saved.source.fingerprint,
selection: { intent: 'preserve' },
programName: 'Measured fire',
origin: 'isac',
actorId: 'actor-1',
resolveAttributeLabel: () => 'Weapon Damage',
});
expect(preserved).toMatchObject({
revisionCreated: false,
selectedRevision: { id: saved.selectedRevision?.id },
projection: {
lifecycle: { status: 'stale', reason: 'fingerprint_mismatch' },
},
});
const cleared = await persistMechanicOwnerStateInTransaction(database, {
source: proseChanged,
expectedSourceFingerprint: preserved.source.fingerprint,
selection: { intent: 'clear' },
programName: 'Measured fire',
origin: 'isac',
actorId: 'actor-1',
resolveAttributeLabel: () => 'Weapon Damage',
});
expect(cleared).toMatchObject({
revisionCreated: false,
selectedRevision: null,
projection: { lifecycle: { status: 'absent' } },
});
expect(await count('mechanic_model_revisions')).toBe(1);
});
it('preserves an invalid selected capability while falling back to authored prose', async () => {
const source = await saveMechanicAuthoredSourceInTransaction(
database,
protocolSource(),
{ expectedSourceFingerprint: null },
);
const revision = await saveMechanicModelRevisionInTransaction(
database,
source,
protocolDraftWithForbiddenScaling(),
{ programName: 'Protocol scaling', origin: 'isac', actorId: 'actor-1' },
);
const changedSource = protocolSource(
'Gain weapon damage after the protocol activates.',
);
const preserved = await persistMechanicOwnerStateInTransaction(database, {
source: changedSource,
expectedSourceFingerprint: source.fingerprint,
selection: { intent: 'preserve' },
programName: 'Protocol scaling',
origin: 'isac',
actorId: 'actor-1',
});
expect(preserved.selectedRevision?.id).toBe(revision.revision.id);
expect(preserved.projection.variants).toMatchObject([
{
variantKey: 'base',
status: 'not_modeled',
displaySource: 'authored',
behaviorText: 'Gain weapon damage after the protocol activates.',
},
]);
expect(preserved.source.source.template).toBe(changedSource.template);
expect(
await loadSelectedMechanicModelRevision(database, source.id),
).toMatchObject({
id: revision.revision.id,
});
expect(await count('mechanic_model_revisions')).toBe(1);
});
it('fails closed for corrupted public projection rows', async () => {
const persisted = await persistMechanicOwnerStateInTransaction(database, {
source: talentSource(),
expectedSourceFingerprint: null,
selection: { intent: 'save', draft: mechanicDraft() },
programName: 'Measured fire',
origin: 'isac',
actorId: 'actor-1',
resolveAttributeLabel: () => 'Weapon Damage',
});
const second = await saveMechanicModelRevisionInTransaction(
database,
persisted.source,
mechanicDraft(25),
{ programName: 'Measured fire', origin: 'isac', actorId: 'actor-1' },
);
await refreshMechanicPublicProjectionInTransaction(
database,
persisted.source,
{ resolveAttributeLabel: () => 'Weapon Damage' },
);
const selectedRevisionId = second.revision.id;
const alternateRevisionId = persisted.selectedRevision?.id;
expect(alternateRevisionId).toBeTruthy();
expect(
await loadMechanicPublicProjection(database, persisted.source.id),
).toHaveLength(2);
const expectClosed = async () =>
expect(
await loadMechanicPublicProjection(database, persisted.source.id),
).toEqual([]);
const refresh = async () => {
await refreshMechanicPublicProjectionInTransaction(
database,
persisted.source,
{ resolveAttributeLabel: () => 'Weapon Damage' },
);
};
await execute(
"UPDATE mechanic_public_variants SET behavior_text='tampered model text' WHERE source_id=? AND variant_key='I'",
persisted.source.id,
);
await expectClosed();
await refresh();
await execute(
'UPDATE mechanic_model_revisions SET program_hash=? WHERE id=?',
'b'.repeat(64),
selectedRevisionId,
);
await expectClosed();
await execute(
'UPDATE mechanic_model_revisions SET program_hash=? WHERE id=?',
second.revision.programHash,
selectedRevisionId,
);
await refresh();
await execute(
'UPDATE mechanic_public_variants SET current_source_fingerprint=? WHERE source_id=?',
'b'.repeat(64),
persisted.source.id,
);
await expectClosed();
await refresh();
await execute(
"DELETE FROM mechanic_public_variants WHERE source_id=? AND variant_key='II'",
persisted.source.id,
);
await expectClosed();
await refresh();
await database.exec('PRAGMA foreign_keys=OFF');
await execute(
"UPDATE mechanic_public_variants SET variant_key='extra' WHERE source_id=? AND variant_key='I'",
persisted.source.id,
);
await expectClosed();
await database.exec('PRAGMA foreign_keys=ON');
await refresh();
await execute(
"UPDATE mechanic_public_variants SET selected_revision_id=? WHERE source_id=? AND variant_key='I'",
alternateRevisionId,
persisted.source.id,
);
await expectClosed();
await refresh();
await execute(
'UPDATE mechanic_public_variants SET authored_contract_version=2 WHERE source_id=?',
persisted.source.id,
);
await expectClosed();
await refresh();
await execute(
'UPDATE mechanic_public_variants SET mechanic_contract_version=2 WHERE source_id=?',
persisted.source.id,
);
await expectClosed();
await refresh();
await database.exec('PRAGMA ignore_check_constraints=ON');
await execute(
"UPDATE mechanic_public_variants SET status='not_modeled' WHERE source_id=? AND variant_key='I'",
persisted.source.id,
);
await expectClosed();
await database.exec('PRAGMA ignore_check_constraints=OFF');
await refresh();
await database.exec('PRAGMA ignore_check_constraints=ON');
await execute(
"UPDATE mechanic_public_variants SET status='not_modeled',display_source='authored',behavior_text='forged fallback' WHERE source_id=? AND variant_key='I'",
persisted.source.id,
);
await expectClosed();
await database.exec('PRAGMA ignore_check_constraints=OFF');
await refresh();
await execute(
'UPDATE mechanic_public_variants SET updated_at=? WHERE source_id=?',
'not-a-timestamp',
persisted.source.id,
);
await expectClosed();
await refresh();
expect(
await loadMechanicPublicProjection(database, persisted.source.id),
).toHaveLength(2);
expect(selectedRevisionId).toBe(
(await loadSelectedMechanicModelRevision(database, persisted.source.id))
?.id,
);
});
it('keeps a selected revision stale after prose changes and projects authored fallback', async () => {
const source = await saveMechanicAuthoredSourceInTransaction(
database,
talentSource(),
{ expectedSourceFingerprint: null },
);
const revision = await saveMechanicModelRevisionInTransaction(
database,
source,
mechanicDraft(),
{ programName: 'Measured fire', origin: 'isac', actorId: 'actor-1' },
);
const modeled = await refreshMechanicPublicProjectionInTransaction(
database,
source,
{ resolveAttributeLabel: () => 'Weapon Damage' },
);
expect(modeled.lifecycle.status).toBe('current');
expect(
modeled.variants.every((variant) => variant.status === 'modeled'),
).toBe(true);
const changedSource = await saveMechanicAuthoredSourceInTransaction(
database,
talentSource('25'),
{ expectedSourceFingerprint: source.fingerprint },
);
const fallback = await refreshMechanicPublicProjectionInTransaction(
database,
changedSource,
{ resolveAttributeLabel: () => 'Weapon Damage' },
);
expect(fallback.lifecycle).toEqual({
status: 'stale',
reason: 'fingerprint_mismatch',
});
expect(fallback.variants).toMatchObject([
{ variantKey: 'I', status: 'not_modeled', displaySource: 'authored' },
{
variantKey: 'II',
status: 'not_modeled',
displaySource: 'authored',
behaviorText: 'After 8 hits, gain 25% weapon damage for 7 seconds.',
},
]);
expect(
await loadSelectedMechanicModelRevision(database, source.id),
).toMatchObject({
id: revision.revision.id,
});
expect(await loadMechanicPublicProjection(database, source.id)).toEqual(
fallback.variants,
);
});
});
@@ -0,0 +1,302 @@
import type {
MechanicAuthoredOwner,
MechanicAuthoredSource,
} from '$lib/calculations/mechanic-authored-description';
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
import { connect, type Database } from '@tursodatabase/database';
import { readFile } from 'node:fs/promises';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { saveMechanicAuthoredSourceInTransaction } from './mechanic-authored-sources';
import { saveMechanicModelRevisionInTransaction } from './mechanic-model-revisions';
import {
loadMechanicPublicProjectionForOwner,
loadMechanicPublicProjectionsForOwners,
mechanicPublicProjectionOwnerKey,
refreshMechanicPublicProjectionInTransaction,
type MechanicPublicProjectionOwnerRef,
} from './mechanic-public-projection';
let database: Database;
const applySchema = async (target: Database) => {
const schema = await readFile(
new URL('../../../database/schema.sql', import.meta.url),
'utf8',
);
for (const statement of schema
.split(';')
.map((value) => value.trim())
.filter(Boolean)) {
await target.exec(statement);
}
};
const requiredVariantKeys = (
owner: MechanicPublicProjectionOwnerRef,
): readonly string[] => {
switch (owner.type) {
case 'talent':
return ['I', 'II'];
case 'os_protocol_talent':
case 'specialization_tree_rank':
return ['base'];
case 'skill_chip_talent':
return ['minimum', 'maximum'];
}
};
const authoredOwner = (
owner: MechanicPublicProjectionOwnerRef,
): MechanicAuthoredOwner =>
owner.type === 'talent' ? { ...owner, scope: 'standard' } : owner;
const sourceFor = (
owner: MechanicPublicProjectionOwnerRef,
): MechanicAuthoredSource => ({
version: 1,
owner: authoredOwner(owner),
template: 'Gain {1} output.',
variants: requiredVariantKeys(owner).map((key, index) => ({
key,
materialized: `Gain ${String(index + 1)} output.`,
slots: [{ key: '1', value: String(index + 1), order: 0 }],
})),
...(owner.type === 'specialization_tree_rank'
? {
specialization: {
rankDescription: 'Rank effect description.',
effectId: 'effect-1',
effectTemplate: 'Gain {1} output.',
},
}
: {}),
});
const modeledDraftFor = (
owner: MechanicPublicProjectionOwnerRef,
): ConditionalMechanicDraftV1 => ({
version: 1,
simulationReadiness: 'ready',
scenarioInputs: [],
pools: [],
clauses: [
{
key: 'mark-target',
label: 'Mark target',
event: 'weapon_hit',
reducer: { kind: 'none' },
mutations: [],
outcomes: [
{
key: 'mark',
label: 'Mark',
phase: 'on_event',
kind: 'marker',
scope: 'target',
basis: 'flat',
variants: Object.fromEntries(
requiredVariantKeys(owner).map((key) => [key, { value: 1 }]),
),
},
],
},
],
});
const createProjection = async (
owner: MechanicPublicProjectionOwnerRef,
options: { modeled?: boolean } = {},
) => {
const source = await saveMechanicAuthoredSourceInTransaction(
database,
sourceFor(owner),
{ expectedSourceFingerprint: null },
);
if (options.modeled) {
await saveMechanicModelRevisionInTransaction(
database,
source,
modeledDraftFor(owner),
{ programName: 'Target mark', origin: 'test', actorId: null },
);
}
await refreshMechanicPublicProjectionInTransaction(database, source);
return source;
};
const execute = async (sql: string, ...parameters: unknown[]) =>
(await database.prepare(sql)).run(...parameters);
const databaseWithPreparedQueryCount = () => {
let queryCount = 0;
const reader = new Proxy(database, {
get(target, property, receiver) {
if (property !== 'prepare') {
const value = Reflect.get(target, property, receiver);
return typeof value === 'function' ? value.bind(target) : value;
}
return async (sql: string) => {
queryCount += 1;
return target.prepare(sql);
};
},
}) as unknown as Database;
return { reader, queryCount: () => queryCount };
};
describe('mechanic public projection owner readers', () => {
beforeEach(async () => {
database = await connect(':memory:');
await database.exec('PRAGMA foreign_keys=ON');
await applySchema(database);
});
afterEach(async () => {
await database.close();
});
it('creates distinct typed keys for every owner identity', () => {
const owners: MechanicPublicProjectionOwnerRef[] = [
{ type: 'talent', id: 'shared' },
{ type: 'os_protocol_talent', id: 'shared' },
{ type: 'skill_chip_talent', id: 'shared' },
{ type: 'specialization_tree_rank', id: 'shared' },
];
const keys = owners.map(mechanicPublicProjectionOwnerKey);
expect(new Set(keys)).toHaveLength(owners.length);
expect(mechanicPublicProjectionOwnerKey(owners[0])).not.toBe(
mechanicPublicProjectionOwnerKey({
type: 'talent',
id: 'os_protocol_talent:shared',
}),
);
});
it('loads one owner in authored variant order', async () => {
const owner = { type: 'skill_chip_talent', id: 'chip-1' } as const;
await createProjection(owner);
const projection = await loadMechanicPublicProjectionForOwner(
database,
owner,
);
expect(projection.map((variant) => variant.variantKey)).toEqual([
'minimum',
'maximum',
]);
expect(projection.map((variant) => variant.behaviorText)).toEqual([
'Gain 1 output.',
'Gain 2 output.',
]);
});
it('keeps missing owners empty without suppressing other projections', async () => {
const present = { type: 'talent', id: 'talent-1' } as const;
const missing = { type: 'os_protocol_talent', id: 'missing' } as const;
await createProjection(present);
const projections = await loadMechanicPublicProjectionsForOwners(database, [
present,
missing,
]);
expect(
projections[mechanicPublicProjectionOwnerKey(present)].map(
(variant) => variant.variantKey,
),
).toEqual(['I', 'II']);
expect(projections[mechanicPublicProjectionOwnerKey(missing)]).toEqual([]);
});
it('excludes one corrupt projection without suppressing a valid owner', async () => {
const valid = { type: 'os_protocol_talent', id: 'protocol-1' } as const;
const corrupt = { type: 'specialization_tree_rank', id: 'rank-1' } as const;
const corruptSource = await createProjection(corrupt);
await createProjection(valid);
await execute(
"UPDATE mechanic_public_variants SET behavior_text='forged text' WHERE source_id=?",
corruptSource.id,
);
const projections = await loadMechanicPublicProjectionsForOwners(database, [
corrupt,
valid,
]);
expect(projections[mechanicPublicProjectionOwnerKey(corrupt)]).toEqual([]);
expect(
projections[mechanicPublicProjectionOwnerKey(valid)].map(
(variant) => variant.behaviorText,
),
).toEqual(['Gain 1 output.']);
});
it('uses a bounded query snapshot for many modeled owners', async () => {
const owners = Array.from({ length: 8 }, (_, index) => ({
type: 'talent' as const,
id: `talent-${String(index + 1)}`,
}));
for (const owner of owners) {
await createProjection(owner, { modeled: true });
}
const { reader, queryCount } = databaseWithPreparedQueryCount();
const projections = await loadMechanicPublicProjectionsForOwners(
reader,
owners,
);
expect(queryCount()).toBeLessThanOrEqual(10);
for (const owner of owners) {
expect(
projections[mechanicPublicProjectionOwnerKey(owner)],
).toMatchObject([
{ variantKey: 'I', displaySource: 'model' },
{ variantKey: 'II', displaySource: 'model' },
]);
}
});
it('isolates a corrupt selected revision while preserving another modeled owner', async () => {
const valid = { type: 'talent', id: 'valid' } as const;
const corrupt = { type: 'talent', id: 'corrupt' } as const;
const validSource = await createProjection(valid, { modeled: true });
const corruptSource = await createProjection(corrupt, { modeled: true });
await execute(
'UPDATE mechanic_model_revisions SET program_hash=? WHERE source_id=?',
'b'.repeat(64),
corruptSource.id,
);
const owners = [corrupt, valid];
const projections = await loadMechanicPublicProjectionsForOwners(
database,
owners,
);
const oneOwnerProjections = Object.fromEntries(
await Promise.all(
owners.map(async (owner) => [
mechanicPublicProjectionOwnerKey(owner),
await loadMechanicPublicProjectionForOwner(database, owner),
]),
),
);
expect(projections).toEqual(oneOwnerProjections);
expect(projections[mechanicPublicProjectionOwnerKey(corrupt)]).toEqual([]);
expect(projections[mechanicPublicProjectionOwnerKey(valid)]).toMatchObject([
{
sourceId: validSource.id,
variantKey: 'I',
displaySource: 'model',
},
{
sourceId: validSource.id,
variantKey: 'II',
displaySource: 'model',
},
]);
});
});
@@ -0,0 +1,849 @@
import { MECHANIC_AUTHORED_CONTRACT_VERSION } from '$lib/calculations/mechanic-authored-description';
import {
MECHANIC_PROGRAM_HASH_VERSION,
classifyMechanicVariantLifecycle,
hashMechanicAuthoredSource,
type MechanicLifecycleResult,
} from '$lib/calculations/mechanic-lifecycle';
import {
renderPublicMechanicPrograms,
type MechanicAttributeLabelResolver,
} from '$lib/calculations/mechanic-public-renderer';
import type { Database } from '@tursodatabase/database';
import type { MechanicAuthoredOwner } from '$lib/calculations/mechanic-authored-description';
import {
loadMechanicAuthoredSource,
loadMechanicAuthoredSourceById,
type MechanicAuthoredSourceRecord,
} from './mechanic-authored-sources';
import {
loadSelectedMechanicModelRevision,
type MechanicModelRevisionRecord,
} from './mechanic-model-revisions';
export type MechanicPublicVariantRecord = {
sourceId: string;
variantKey: string;
selectedRevisionId: string | null;
currentSourceFingerprint: string;
authoredContractVersion: number;
mechanicContractVersion: number | null;
status: 'modeled' | 'not_modeled';
behaviorText: string;
displaySource: 'model' | 'authored';
updatedAt: string;
};
export type MechanicPublicProjection = {
lifecycle: MechanicLifecycleResult;
variants: MechanicPublicVariantRecord[];
};
/** Stable public identity for a mechanic owner, without authoring-only fields. */
export type MechanicPublicProjectionOwnerRef = {
[Type in MechanicAuthoredOwner['type']]: Pick<
Extract<MechanicAuthoredOwner, { type: Type }>,
'type' | 'id'
>;
}[MechanicAuthoredOwner['type']];
declare const mechanicPublicProjectionOwnerKeyBrand: unique symbol;
/** A serializable, collision-safe map key derived from an owner type and ID. */
export type MechanicPublicProjectionOwnerKey = string & {
readonly [mechanicPublicProjectionOwnerKeyBrand]: true;
};
export type MechanicPublicProjectionsByOwner = Record<
MechanicPublicProjectionOwnerKey,
MechanicPublicVariantRecord[]
>;
/**
* Length-prefixes each part so owner IDs cannot collide with another owner
* type or ID when projections are stored in a route-loader-safe object.
*/
export function mechanicPublicProjectionOwnerKey(
owner: MechanicPublicProjectionOwnerRef,
): MechanicPublicProjectionOwnerKey {
return `${owner.type.length}:${owner.type}${owner.id.length}:${owner.id}` as MechanicPublicProjectionOwnerKey;
}
function isMechanicPublicProjectionOwnerRef(
value: unknown,
): value is MechanicPublicProjectionOwnerRef {
if (typeof value !== 'object' || value === null) return false;
const owner = value as Record<string, unknown>;
return (
isNonEmptyString(owner.id) &&
(owner.type === 'talent' ||
owner.type === 'os_protocol_talent' ||
owner.type === 'skill_chip_talent' ||
owner.type === 'specialization_tree_rank')
);
}
/** Rebuilds one owner's complete public projection in the caller transaction. */
export async function refreshMechanicPublicProjectionInTransaction(
database: Database,
source: MechanicAuthoredSourceRecord,
options: {
resolveAttributeLabel?: MechanicAttributeLabelResolver;
/** Keeps a selected revision while deliberately publishing authored text. */
forceAuthoredFallback?: boolean;
} = {},
): Promise<MechanicPublicProjection> {
const selected = await loadSelectedMechanicModelRevision(database, source.id);
const lifecycle = await classifyMechanicVariantLifecycle({
currentSourceFingerprint: source.fingerprint,
resolveAttributeLabel: options.resolveAttributeLabel,
selectedRevision: selected
? {
variants: selected.variants,
sourceFingerprint: selected.sourceFingerprint,
programHash: selected.programHash,
mechanicContractVersion: selected.mechanicContractVersion,
simulationReadiness: selected.simulationReadiness,
}
: null,
});
const renderedByVariant = new Map<string, string>();
const modeled =
!options.forceAuthoredFallback && lifecycle.status === 'current';
if (modeled && selected) {
const rendered = renderPublicMechanicPrograms(selected.variants, {
resolveAttributeLabel: options.resolveAttributeLabel,
});
if (!rendered.ok) {
throw new Error(
`A current mechanic revision could not be rendered: ${rendered.errors
.map((error) => error.message)
.join(' ')}`,
);
}
for (const variant of rendered.variants) {
renderedByVariant.set(variant.key, variant.text);
}
}
const now = new Date().toISOString();
const variants: MechanicPublicVariantRecord[] = source.source.variants.map(
(variant) => {
const behaviorText = modeled
? renderedByVariant.get(variant.key)
: variant.materialized;
if (behaviorText === undefined) {
throw new Error(
`The selected mechanic revision is missing public variant ${variant.key}.`,
);
}
return {
sourceId: source.id,
variantKey: variant.key,
selectedRevisionId: selected?.id ?? null,
currentSourceFingerprint: source.fingerprint,
authoredContractVersion: source.source.version,
mechanicContractVersion: selected?.mechanicContractVersion ?? null,
status: modeled ? 'modeled' : 'not_modeled',
behaviorText,
displaySource: modeled ? 'model' : 'authored',
updatedAt: now,
};
},
);
await (
await database.prepare(
'DELETE FROM mechanic_public_variants WHERE source_id=?',
)
).run(source.id);
for (const variant of variants) {
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 (?,?,?,?,?,?,?,?,?,?)`)
).run(
variant.sourceId,
variant.variantKey,
variant.selectedRevisionId,
variant.currentSourceFingerprint,
variant.authoredContractVersion,
variant.mechanicContractVersion,
variant.status,
variant.behaviorText,
variant.displaySource,
variant.updatedAt,
);
}
return { lifecycle, variants };
}
const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/;
function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0;
}
function isValidTimestamp(value: unknown): value is string {
return isNonEmptyString(value) && !Number.isNaN(Date.parse(value));
}
type MechanicPublicProjectionSnapshotRow = Record<string, unknown>;
type MechanicPublicProjectionSnapshot = {
sourceByOwner: Map<string, MechanicPublicProjectionSnapshotRow>;
sourceById: Map<string, MechanicPublicProjectionSnapshotRow>;
variantsBySourceId: Map<string, MechanicPublicProjectionSnapshotRow[]>;
slotsBySourceId: Map<string, MechanicPublicProjectionSnapshotRow[]>;
publicVariantsBySourceId: Map<string, MechanicPublicProjectionSnapshotRow[]>;
selectionBySourceId: Map<string, MechanicPublicProjectionSnapshotRow>;
selectedRevisionBySourceId: Map<string, MechanicPublicProjectionSnapshotRow>;
poolsByRevisionId: Map<string, MechanicPublicProjectionSnapshotRow[]>;
clausesByRevisionId: Map<string, MechanicPublicProjectionSnapshotRow[]>;
outcomesByRevisionId: Map<string, MechanicPublicProjectionSnapshotRow[]>;
activeAttributes: MechanicPublicProjectionSnapshotRow[];
};
function ownerSnapshotKey(type: string, id: string) {
return `${type.length}:${type}${id.length}:${id}`;
}
function mapRowsByStringColumn(
rows: readonly MechanicPublicProjectionSnapshotRow[],
column: string,
) {
const result = new Map<string, MechanicPublicProjectionSnapshotRow[]>();
for (const row of rows) {
const value = row[column];
if (typeof value !== 'string') continue;
result.set(value, [...(result.get(value) ?? []), row]);
}
return result;
}
function mapFirstRowByStringColumn(
rows: readonly MechanicPublicProjectionSnapshotRow[],
column: string,
) {
const result = new Map<string, MechanicPublicProjectionSnapshotRow>();
for (const row of rows) {
const value = row[column];
if (typeof value === 'string' && !result.has(value)) {
result.set(value, row);
}
}
return result;
}
function copiedRow(row: MechanicPublicProjectionSnapshotRow | undefined) {
return row === undefined ? undefined : { ...row };
}
function copiedRows(rows: MechanicPublicProjectionSnapshotRow[] | undefined) {
return (rows ?? []).map((row) => ({ ...row }));
}
async function loadMechanicPublicProjectionSnapshotRows(
database: Database,
sql: string,
ids: readonly string[],
): Promise<MechanicPublicProjectionSnapshotRow[]> {
return (await (
await database.prepare(sql)
).all(JSON.stringify(ids))) as MechanicPublicProjectionSnapshotRow[];
}
/**
* Reads every table the single-owner validator needs in a bounded number of
* queries. The returned rows are private input to that validator, never a
* public projection by themselves.
*/
async function loadMechanicPublicProjectionSnapshot(
database: Database,
owners: readonly MechanicPublicProjectionOwnerRef[],
): Promise<MechanicPublicProjectionSnapshot> {
const requestedOwners = [
...new Map(
owners
.filter(isMechanicPublicProjectionOwnerRef)
.map((owner) => [ownerSnapshotKey(owner.type, owner.id), owner]),
).values(),
];
const sourceRows = (await (
await database.prepare(`SELECT source.*
FROM mechanic_authored_sources source
JOIN json_each(?) requested
ON source.owner_type=json_extract(requested.value,'$.type')
AND source.owner_id=json_extract(requested.value,'$.id')`)
).all(
JSON.stringify(requestedOwners),
)) as MechanicPublicProjectionSnapshotRow[];
const sourceByOwner = new Map<string, MechanicPublicProjectionSnapshotRow>();
for (const source of sourceRows) {
if (
typeof source.owner_type === 'string' &&
typeof source.owner_id === 'string'
) {
sourceByOwner.set(
ownerSnapshotKey(source.owner_type, source.owner_id),
source,
);
}
}
const sourceById = mapFirstRowByStringColumn(sourceRows, 'id');
const sourceIds = [...sourceById.keys()];
if (!sourceIds.length) {
return {
sourceByOwner,
sourceById,
variantsBySourceId: new Map(),
slotsBySourceId: new Map(),
publicVariantsBySourceId: new Map(),
selectionBySourceId: new Map(),
selectedRevisionBySourceId: new Map(),
poolsByRevisionId: new Map(),
clausesByRevisionId: new Map(),
outcomesByRevisionId: new Map(),
activeAttributes: [],
};
}
const [
variantRows,
slotRows,
publicVariantRows,
selectionRows,
revisionRows,
activeAttributes,
] = await Promise.all([
loadMechanicPublicProjectionSnapshotRows(
database,
`SELECT * FROM mechanic_authored_variants
WHERE source_id IN (SELECT value FROM json_each(?))
ORDER BY source_id,sort_order`,
sourceIds,
),
loadMechanicPublicProjectionSnapshotRows(
database,
`SELECT * FROM mechanic_authored_slots
WHERE source_id IN (SELECT value FROM json_each(?))
ORDER BY source_id,variant_key,sort_order`,
sourceIds,
),
loadMechanicPublicProjectionSnapshotRows(
database,
`SELECT * FROM mechanic_public_variants
WHERE source_id IN (SELECT value FROM json_each(?))`,
sourceIds,
),
loadMechanicPublicProjectionSnapshotRows(
database,
`SELECT source_id,revision_id FROM mechanic_model_selections
WHERE source_id IN (SELECT value FROM json_each(?))`,
sourceIds,
),
loadMechanicPublicProjectionSnapshotRows(
database,
`SELECT revision.*
FROM mechanic_model_selections selection
JOIN mechanic_model_revisions revision
ON revision.id=selection.revision_id
AND revision.source_id=selection.source_id
WHERE selection.source_id IN (SELECT value FROM json_each(?))`,
sourceIds,
),
(async () =>
(await (
await database.prepare(
'SELECT id,name FROM stat_attributes WHERE active=1',
)
).all()) as MechanicPublicProjectionSnapshotRow[])(),
]);
const revisionBySourceId = mapFirstRowByStringColumn(
revisionRows,
'source_id',
);
const revisionIds = [
...new Set(
revisionRows.flatMap((revision) =>
typeof revision.id === 'string' ? [revision.id] : [],
),
),
];
const [poolRows, clauseRows, outcomeRows] = revisionIds.length
? await Promise.all([
loadMechanicPublicProjectionSnapshotRows(
database,
`SELECT * FROM mechanic_model_revision_pools
WHERE revision_id IN (SELECT value FROM json_each(?))
ORDER BY revision_id,sort_order`,
revisionIds,
),
loadMechanicPublicProjectionSnapshotRows(
database,
`SELECT * FROM mechanic_model_revision_clauses
WHERE revision_id IN (SELECT value FROM json_each(?))
ORDER BY revision_id,sort_order`,
revisionIds,
),
loadMechanicPublicProjectionSnapshotRows(
database,
`SELECT * FROM mechanic_model_revision_outcomes
WHERE revision_id IN (SELECT value FROM json_each(?))
ORDER BY revision_id,sort_order,variant_key`,
revisionIds,
),
])
: [[], [], []];
return {
sourceByOwner,
sourceById,
variantsBySourceId: mapRowsByStringColumn(variantRows, 'source_id'),
slotsBySourceId: mapRowsByStringColumn(slotRows, 'source_id'),
publicVariantsBySourceId: mapRowsByStringColumn(
publicVariantRows,
'source_id',
),
selectionBySourceId: mapFirstRowByStringColumn(selectionRows, 'source_id'),
selectedRevisionBySourceId: revisionBySourceId,
poolsByRevisionId: mapRowsByStringColumn(poolRows, 'revision_id'),
clausesByRevisionId: mapRowsByStringColumn(clauseRows, 'revision_id'),
outcomesByRevisionId: mapRowsByStringColumn(outcomeRows, 'revision_id'),
activeAttributes,
};
}
/**
* Replays the exact read shapes used by the single-owner boundary against a
* private, immutable-in-practice bulk snapshot. Reusing those readers keeps
* source parsing, revision parsing, hashes, lifecycle checks, rendering, and
* cache validation identical for one and many owners.
*/
function mechanicPublicProjectionSnapshotDatabase(
snapshot: MechanicPublicProjectionSnapshot,
): Database {
return {
prepare: async (sql: string) => {
const normalizedSql = sql.replace(/\s+/g, ' ').trim();
const sourceId = (parameters: unknown[]) =>
typeof parameters[0] === 'string' ? parameters[0] : '';
const rowForOwner = (parameters: unknown[]) => {
const [ownerType, ownerId] = parameters;
return typeof ownerType === 'string' && typeof ownerId === 'string'
? snapshot.sourceByOwner.get(ownerSnapshotKey(ownerType, ownerId))
: undefined;
};
const selectedRevision = (parameters: unknown[]) =>
snapshot.selectedRevisionBySourceId.get(sourceId(parameters));
return {
get: async (...parameters: unknown[]) => {
if (
normalizedSql ===
'SELECT * FROM mechanic_authored_sources WHERE owner_type=? AND owner_id=?'
) {
return copiedRow(rowForOwner(parameters));
}
if (
normalizedSql ===
'SELECT * FROM mechanic_authored_sources WHERE id=?'
) {
return copiedRow(snapshot.sourceById.get(sourceId(parameters)));
}
if (
normalizedSql ===
'SELECT revision_id FROM mechanic_model_selections WHERE source_id=?'
) {
return copiedRow(
snapshot.selectionBySourceId.get(sourceId(parameters)),
);
}
if (
normalizedSql.startsWith(
'SELECT revision.* FROM mechanic_model_selections selection JOIN mechanic_model_revisions revision ',
)
) {
return copiedRow(selectedRevision(parameters));
}
throw new Error(
`Unsupported mechanic public projection snapshot query: ${normalizedSql}`,
);
},
all: async (...parameters: unknown[]) => {
if (
normalizedSql.startsWith(
'SELECT * FROM mechanic_authored_variants ',
)
) {
return copiedRows(
snapshot.variantsBySourceId.get(sourceId(parameters)),
);
}
if (
normalizedSql.startsWith('SELECT * FROM mechanic_authored_slots ')
) {
return copiedRows(
snapshot.slotsBySourceId.get(sourceId(parameters)),
);
}
if (
normalizedSql ===
'SELECT * FROM mechanic_public_variants WHERE source_id=?'
) {
return copiedRows(
snapshot.publicVariantsBySourceId.get(sourceId(parameters)),
);
}
if (
normalizedSql ===
'SELECT id,name FROM stat_attributes WHERE active=1'
) {
return copiedRows(snapshot.activeAttributes);
}
if (
normalizedSql.startsWith(
'SELECT * FROM mechanic_model_revision_pools ',
)
) {
return copiedRows(
snapshot.poolsByRevisionId.get(sourceId(parameters)),
);
}
if (
normalizedSql.startsWith(
'SELECT * FROM mechanic_model_revision_clauses ',
)
) {
return copiedRows(
snapshot.clausesByRevisionId.get(sourceId(parameters)),
);
}
if (
normalizedSql.startsWith(
'SELECT * FROM mechanic_model_revision_outcomes ',
)
) {
return copiedRows(
snapshot.outcomesByRevisionId.get(sourceId(parameters)),
);
}
throw new Error(
`Unsupported mechanic public projection snapshot query: ${normalizedSql}`,
);
},
};
},
} as unknown as Database;
}
/**
* Loads only a complete, internally consistent projection. The projection is
* cached text, so a malformed or partially replaced cache must never become a
* public response and must not make a page load throw.
*/
export async function loadMechanicPublicProjection(
database: Database,
sourceId: string,
): Promise<MechanicPublicVariantRecord[]> {
try {
if (!isNonEmptyString(sourceId)) return [];
const source = await loadMechanicAuthoredSourceById(database, sourceId);
if (!source || source.id !== sourceId) return [];
if (
!SHA256_HEX_PATTERN.test(source.fingerprint) ||
source.source.version !== MECHANIC_AUTHORED_CONTRACT_VERSION
) {
return [];
}
const fingerprint = await hashMechanicAuthoredSource(source.source);
if (!fingerprint.ok || fingerprint.fingerprint !== source.fingerprint) {
return [];
}
const [rows, selectionRow, attributeRows] = await Promise.all([
(
await database.prepare(
'SELECT * FROM mechanic_public_variants WHERE source_id=?',
)
).all(sourceId),
(
await database.prepare(
'SELECT revision_id FROM mechanic_model_selections WHERE source_id=?',
)
).get(sourceId),
(
await database.prepare(
'SELECT id,name FROM stat_attributes WHERE active=1',
)
).all(),
]);
const selected = await loadSelectedMechanicModelRevision(
database,
sourceId,
);
const selectionRevisionId = selectionRow?.revision_id;
if (selectionRow != null && !isNonEmptyString(selectionRevisionId)) {
return [];
}
if (
(selectionRevisionId === undefined) !== (selected === null) ||
(selected !== null && selectionRevisionId !== selected.id)
) {
return [];
}
const expectedSelectedRevisionId = selected?.id ?? null;
const expectedMechanicContract = selected?.mechanicContractVersion ?? null;
if (
selected &&
(!Number.isInteger(expectedMechanicContract) ||
expectedMechanicContract !== MECHANIC_PROGRAM_HASH_VERSION)
) {
return [];
}
const attributeLabels = new Map<string, string>();
for (const row of attributeRows) {
if (isNonEmptyString(row.id) && isNonEmptyString(row.name)) {
attributeLabels.set(row.id, row.name);
}
}
const resolveAttributeLabel: MechanicAttributeLabelResolver = (id) =>
attributeLabels.get(id) ?? null;
const authoredVariants = source.source.variants;
const lifecycle = await classifyMechanicVariantLifecycle({
currentSourceFingerprint: source.fingerprint,
resolveAttributeLabel,
selectedRevision: selected
? {
variants: selected.variants,
sourceFingerprint: selected.sourceFingerprint,
programHash: selected.programHash,
mechanicContractVersion: selected.mechanicContractVersion,
simulationReadiness: selected.simulationReadiness,
}
: null,
});
if (rows.length !== authoredVariants.length) return [];
const seenKeys = new Set<string>();
const rowsByKey = new Map<string, MechanicPublicVariantRecord>();
for (const row of rows) {
if (
typeof row.source_id !== 'string' ||
row.source_id !== sourceId ||
typeof row.variant_key !== 'string' ||
!row.variant_key.trim() ||
seenKeys.has(row.variant_key) ||
row.selected_revision_id === undefined ||
row.current_source_fingerprint === undefined ||
row.authored_contract_version === undefined ||
row.mechanic_contract_version === undefined ||
typeof row.status !== 'string' ||
typeof row.display_source !== 'string'
) {
return [];
}
seenKeys.add(row.variant_key);
const selectedRevisionId =
row.selected_revision_id === null ? null : row.selected_revision_id;
const mechanicContractVersion =
row.mechanic_contract_version === null
? null
: row.mechanic_contract_version;
if (
(selectedRevisionId !== null &&
!isNonEmptyString(selectedRevisionId)) ||
(mechanicContractVersion !== null &&
(!Number.isInteger(mechanicContractVersion) ||
mechanicContractVersion <= 0)) ||
selectedRevisionId !== expectedSelectedRevisionId ||
mechanicContractVersion !== expectedMechanicContract ||
typeof row.current_source_fingerprint !== 'string' ||
row.current_source_fingerprint !== source.fingerprint ||
!SHA256_HEX_PATTERN.test(row.current_source_fingerprint) ||
typeof row.authored_contract_version !== 'number' ||
!Number.isInteger(row.authored_contract_version) ||
row.authored_contract_version !== source.source.version ||
(row.status !== 'modeled' && row.status !== 'not_modeled') ||
(row.display_source !== 'model' && row.display_source !== 'authored') ||
(row.status === 'modeled' &&
(row.display_source !== 'model' ||
selectedRevisionId === null ||
!selected ||
selected.sourceFingerprint !== source.fingerprint ||
selected.mechanicContractVersion !==
MECHANIC_PROGRAM_HASH_VERSION)) ||
(row.status === 'not_modeled' && row.display_source !== 'authored') ||
!isNonEmptyString(row.behavior_text) ||
/[{}]/.test(row.behavior_text) ||
!isValidTimestamp(row.updated_at)
) {
return [];
}
const parsed: MechanicPublicVariantRecord = {
sourceId: row.source_id,
variantKey: row.variant_key,
selectedRevisionId,
currentSourceFingerprint: row.current_source_fingerprint,
authoredContractVersion: row.authored_contract_version,
mechanicContractVersion,
status: row.status,
behaviorText: row.behavior_text,
displaySource: row.display_source,
updatedAt: row.updated_at,
};
rowsByKey.set(parsed.variantKey, parsed);
}
const variants = authoredVariants.map((authored) => {
const projection = rowsByKey.get(authored.key);
if (!projection) throw new Error('Missing authored mechanic variant.');
if (
projection.displaySource === 'authored' &&
projection.behaviorText !== authored.materialized
) {
throw new Error('Authored mechanic fallback text does not match.');
}
return projection;
});
const hasModeledRows = variants.some(
(variant) => variant.displaySource === 'model',
);
if (hasModeledRows && lifecycle.status !== 'current') return [];
const renderedByVariant = new Map<string, string>();
if (hasModeledRows && selected) {
const rendered = renderPublicMechanicPrograms(selected.variants, {
resolveAttributeLabel,
});
if (!rendered.ok) return [];
if (
rendered.variants.length !== authoredVariants.length ||
rendered.variants.some(
(variant, index) => variant.key !== authoredVariants[index].key,
)
) {
return [];
}
for (const variant of rendered.variants) {
renderedByVariant.set(variant.key, variant.text);
}
if (
variants.some(
(variant) =>
variant.displaySource === 'model' &&
renderedByVariant.get(variant.variantKey) !== variant.behaviorText,
)
) {
return [];
}
}
if (
seenKeys.size !== authoredVariants.length ||
authoredVariants.some(
(variant, index) => variants[index].variantKey !== variant.key,
)
) {
return [];
}
const firstStatus = variants[0]?.status;
if (
firstStatus === undefined ||
variants.some((variant) => variant.status !== firstStatus)
) {
return [];
}
return variants;
} catch {
return [];
}
}
/**
* Loads one owner's projection only after resolving its source identity and
* passing it through the complete fail-closed projection validator.
*/
export async function loadMechanicPublicProjectionForOwner(
database: Database,
owner: MechanicPublicProjectionOwnerRef,
): Promise<MechanicPublicVariantRecord[]> {
try {
if (!isMechanicPublicProjectionOwnerRef(owner)) return [];
const source = await loadMechanicAuthoredSource(
database,
owner.type,
owner.id,
);
if (
!source ||
source.source.owner.type !== owner.type ||
source.source.owner.id !== owner.id
) {
return [];
}
return await loadMechanicPublicProjection(database, source.id);
} catch {
return [];
}
}
/**
* Loads independent, validated public projections for route loaders. Missing
* or corrupt owners retain an empty entry without suppressing valid owners.
*/
export async function loadMechanicPublicProjectionsForOwners(
database: Database,
owners: readonly MechanicPublicProjectionOwnerRef[],
): Promise<MechanicPublicProjectionsByOwner> {
const ownerKeys = owners.map(mechanicPublicProjectionOwnerKey);
const emptyProjections = Object.fromEntries(
ownerKeys.map((key) => [key, []]),
) as MechanicPublicProjectionsByOwner;
if (!owners.length) return emptyProjections;
try {
const snapshot = await loadMechanicPublicProjectionSnapshot(
database,
owners,
);
const snapshotDatabase = mechanicPublicProjectionSnapshotDatabase(snapshot);
const entries = await Promise.all(
owners.map(async (owner, index) => {
const projection = await loadMechanicPublicProjectionForOwner(
snapshotDatabase,
owner,
);
return [ownerKeys[index], projection] as const;
}),
);
return Object.fromEntries(entries) as MechanicPublicProjectionsByOwner;
} catch {
return emptyProjections;
}
}
export function mechanicPublicProjectionSnapshot(
selected: MechanicModelRevisionRecord | null,
projection: MechanicPublicVariantRecord[],
) {
return {
selectedRevisionId: selected?.id ?? null,
variants: projection.map((variant) => ({
key: variant.variantKey,
status: variant.status,
displaySource: variant.displaySource,
behaviorText: variant.behaviorText,
sourceFingerprint: variant.currentSourceFingerprint,
authoredContractVersion: variant.authoredContractVersion,
mechanicContractVersion: variant.mechanicContractVersion,
})),
};
}
@@ -0,0 +1,623 @@
import type {
MechanicAuthoredOwner,
MechanicAuthoredSource,
} from '$lib/calculations/mechanic-authored-description';
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
import { connect, type Database } from '@tursodatabase/database';
import { readFile } from 'node:fs/promises';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { saveMechanicAuthoredSourceInTransaction } from './mechanic-authored-sources';
import { saveMechanicModelRevisionInTransaction } from './mechanic-model-revisions';
import {
loadSelectedMechanicRuntime,
loadSelectedMechanicRuntimes,
mechanicRuntimeOwnerKey,
mechanicRuntimeProgramId,
} from './mechanic-runtime-selection';
let database: Database;
const applySchema = async (target: Database) => {
const schema = await readFile(
new URL('../../../database/schema.sql', import.meta.url),
'utf8',
);
for (const statement of schema
.split(';')
.map((value) => value.trim())
.filter(Boolean)) {
await target.exec(statement);
}
};
function variantKeys(owner: MechanicAuthoredOwner) {
switch (owner.type) {
case 'talent':
return owner.scope === 'standard' || owner.hasTierII
? ['I', 'II']
: ['I'];
case 'skill_chip_talent':
return ['minimum', 'maximum'];
case 'os_protocol_talent':
case 'specialization_tree_rank':
return ['base'];
}
}
function sourceFor(
owner: MechanicAuthoredOwner,
text = 'Mark the target.',
): MechanicAuthoredSource {
return {
version: 1,
owner,
template: text,
variants: variantKeys(owner).map((key) => ({
key,
materialized: text,
slots: [],
})),
...(owner.type === 'specialization_tree_rank'
? {
specialization: {
rankDescription: 'Applies a mark.',
effectId: 'specialization-mark',
effectTemplate: 'Mark the target.',
},
}
: {}),
};
}
function draftFor(
owner: MechanicAuthoredOwner,
simulationReadiness: ConditionalMechanicDraftV1['simulationReadiness'] = 'ready',
): ConditionalMechanicDraftV1 {
return {
version: 1,
simulationReadiness,
scenarioInputs:
simulationReadiness === 'requires_assumptions'
? [
{
key: 'nearby-enemies',
label: 'Nearby enemies',
kind: 'number',
readiness: 'requires_assumption',
min: 1,
max: 4,
},
]
: [],
pools: [],
clauses: [
{
key: 'mark-target',
label: 'Mark target',
event: 'weapon_hit',
reducer: { kind: 'none' },
mutations: [],
outcomes: [
{
key: 'mark',
label: 'Mark',
phase: 'on_event',
kind: 'marker',
scope: 'target',
basis: 'flat',
variants: Object.fromEntries(
variantKeys(owner).map((key) => [key, { value: 1 }]),
),
},
],
},
],
};
}
async function saveSelected(
owner: MechanicAuthoredOwner,
options: {
text?: string;
simulationReadiness?: ConditionalMechanicDraftV1['simulationReadiness'];
} = {},
) {
const source = await saveMechanicAuthoredSourceInTransaction(
database,
sourceFor(owner, options.text),
{ expectedSourceFingerprint: null },
);
const saved = await saveMechanicModelRevisionInTransaction(
database,
source,
draftFor(owner, options.simulationReadiness),
{ programName: 'Target mark', origin: 'test', actorId: null },
);
return { source, revision: saved.revision };
}
const execute = async (sql: string, ...parameters: unknown[]) =>
(await database.prepare(sql)).run(...parameters);
function databaseThatMutatesAfterFirstSelectedRevisionRead(
mutate: () => Promise<void>,
) {
let hasMutated = false;
const reader = new Proxy(database, {
get(target, property, receiver) {
if (property !== 'prepare') {
const value = Reflect.get(target, property, receiver);
return typeof value === 'function' ? value.bind(target) : value;
}
return async (sql: string) => {
const statement = await target.prepare(sql);
if (!sql.includes('FROM mechanic_model_selections selection')) {
return statement;
}
return new Proxy(statement, {
get(statementTarget, statementProperty, statementReceiver) {
if (statementProperty !== 'get' && statementProperty !== 'all') {
return Reflect.get(
statementTarget,
statementProperty,
statementReceiver,
);
}
const read = Reflect.get(statementTarget, statementProperty);
return async (...parameters: unknown[]) => {
const result = await Reflect.apply(
read,
statementTarget,
parameters,
);
if (!hasMutated) {
hasMutated = true;
await mutate();
}
return result;
};
},
});
};
},
}) as unknown as Database;
return { reader, hasMutated: () => hasMutated };
}
function databaseThatMutatesBeforeSecondSnapshotSourceRead(
mutate: () => Promise<void>,
) {
let sourceReadCount = 0;
let hasMutated = false;
const reader = new Proxy(database, {
get(target, property, receiver) {
if (property !== 'prepare') {
const value = Reflect.get(target, property, receiver);
return typeof value === 'function' ? value.bind(target) : value;
}
return async (sql: string) => {
const statement = await target.prepare(sql);
if (!sql.includes('FROM mechanic_authored_sources source')) {
return statement;
}
return new Proxy(statement, {
get(statementTarget, statementProperty, statementReceiver) {
if (statementProperty !== 'all') {
return Reflect.get(
statementTarget,
statementProperty,
statementReceiver,
);
}
const all = Reflect.get(statementTarget, statementProperty);
return async (...parameters: unknown[]) => {
sourceReadCount += 1;
if (sourceReadCount === 2 && !hasMutated) {
hasMutated = true;
await mutate();
}
return Reflect.apply(all, statementTarget, parameters);
};
},
});
};
},
}) as unknown as Database;
return { reader, hasMutated: () => hasMutated };
}
function databaseThatCountsQueries() {
let queryCount = 0;
const reader = new Proxy(database, {
get(target, property, receiver) {
if (property !== 'prepare') {
const value = Reflect.get(target, property, receiver);
return typeof value === 'function' ? value.bind(target) : value;
}
return async (...parameters: unknown[]) => {
queryCount += 1;
return Reflect.apply(target.prepare, target, parameters);
};
},
}) as unknown as Database;
return { reader, queryCount: () => queryCount };
}
describe('selected mechanic runtime reader', () => {
beforeEach(async () => {
database = await connect(':memory:');
await database.exec('PRAGMA foreign_keys=ON');
await applySchema(database);
});
afterEach(async () => {
await database.close();
});
it('reads validated selected revisions for every typed authored owner shape', async () => {
const owners: MechanicAuthoredOwner[] = [
{ type: 'talent', id: 'talent-1', scope: 'standard' },
{ type: 'os_protocol_talent', id: 'protocol-1' },
{ type: 'skill_chip_talent', id: 'chip-1' },
{ type: 'specialization_tree_rank', id: 'rank-1' },
];
for (const owner of owners) await saveSelected(owner);
const runtimeIds: string[] = [];
for (const owner of owners) {
const selection = await loadSelectedMechanicRuntime(database, owner);
expect(selection).toMatchObject({
owner,
simulationReadiness: 'ready',
scenarioInputs: [],
variants: variantKeys(owner).map((key) => ({ key })),
});
const selectionRuntimeIds = selection?.variants.map(
(variant) => variant.program.id,
);
expect(selectionRuntimeIds).toEqual(
selection?.variants.map((variant) =>
mechanicRuntimeProgramId(selection.sourceId, variant.key),
),
);
runtimeIds.push(...(selectionRuntimeIds ?? []));
}
expect(new Set(runtimeIds).size).toBe(runtimeIds.length);
});
it('returns null when no revision has been explicitly selected', async () => {
const owner: MechanicAuthoredOwner = {
type: 'os_protocol_talent',
id: 'protocol-no-selection',
};
await saveMechanicAuthoredSourceInTransaction(database, sourceFor(owner), {
expectedSourceFingerprint: null,
});
await expect(
loadSelectedMechanicRuntime(database, owner),
).resolves.toBeNull();
});
it('fails closed when the selected revision is stale or the stored source fingerprint is tampered', async () => {
const staleOwner: MechanicAuthoredOwner = {
type: 'os_protocol_talent',
id: 'protocol-stale',
};
const stale = await saveSelected(staleOwner);
await saveMechanicAuthoredSourceInTransaction(
database,
sourceFor(staleOwner, 'Mark a different target.'),
{ expectedSourceFingerprint: stale.source.fingerprint },
);
await expect(
loadSelectedMechanicRuntime(database, staleOwner),
).resolves.toBeNull();
const tamperedOwner: MechanicAuthoredOwner = {
type: 'os_protocol_talent',
id: 'protocol-tampered-source',
};
const tampered = await saveSelected(tamperedOwner);
await execute(
'UPDATE mechanic_authored_sources SET source_fingerprint=? WHERE id=?',
'b'.repeat(64),
tampered.source.id,
);
await expect(
loadSelectedMechanicRuntime(database, tamperedOwner),
).resolves.toBeNull();
});
it('rechecks the current source after its selected revision is read', async () => {
const owner: MechanicAuthoredOwner = {
type: 'os_protocol_talent',
id: 'protocol-changes-during-read',
};
const saved = await saveSelected(owner);
const intercepted = databaseThatMutatesAfterFirstSelectedRevisionRead(() =>
saveMechanicAuthoredSourceInTransaction(
database,
sourceFor(owner, 'Mark a newly authored target.'),
{ expectedSourceFingerprint: saved.source.fingerprint },
).then(() => undefined),
);
await expect(
loadSelectedMechanicRuntime(intercepted.reader, owner),
).resolves.toBeNull();
expect(intercepted.hasMutated()).toBe(true);
await expect(
loadSelectedMechanicRuntime(database, owner),
).resolves.toBeNull();
});
it('rejects an in-place revision graph mutation after lifecycle validation', async () => {
const owner: MechanicAuthoredOwner = {
type: 'os_protocol_talent',
id: 'protocol-revision-changes-after-validation',
};
const saved = await saveSelected(owner);
const intercepted = databaseThatMutatesBeforeSecondSnapshotSourceRead(() =>
execute(
'UPDATE mechanic_model_revision_outcomes SET value=? WHERE revision_id=?',
2,
saved.revision.id,
).then(() => undefined),
);
await expect(
loadSelectedMechanicRuntime(intercepted.reader, owner),
).resolves.toBeNull();
expect(intercepted.hasMutated()).toBe(true);
});
it('fails closed for malformed graphs, unsupported contracts, and invalid hashes', async () => {
const malformedOwner: MechanicAuthoredOwner = {
type: 'os_protocol_talent',
id: 'protocol-malformed',
};
const malformed = await saveSelected(malformedOwner);
await execute(
'UPDATE mechanic_model_revision_clauses SET reducer_json=? WHERE revision_id=?',
'{"kind":"unsupported"}',
malformed.revision.id,
);
await expect(
loadSelectedMechanicRuntime(database, malformedOwner),
).resolves.toBeNull();
const unsupportedOwner: MechanicAuthoredOwner = {
type: 'os_protocol_talent',
id: 'protocol-unsupported-contract',
};
const unsupported = await saveSelected(unsupportedOwner);
await execute(
'UPDATE mechanic_model_revisions SET mechanic_contract_version=? WHERE id=?',
2,
unsupported.revision.id,
);
await expect(
loadSelectedMechanicRuntime(database, unsupportedOwner),
).resolves.toBeNull();
const invalidHashOwner: MechanicAuthoredOwner = {
type: 'os_protocol_talent',
id: 'protocol-invalid-hash',
};
const invalidHash = await saveSelected(invalidHashOwner);
await execute(
'UPDATE mechanic_model_revisions SET program_hash=? WHERE id=?',
'b'.repeat(64),
invalidHash.revision.id,
);
await expect(
loadSelectedMechanicRuntime(database, invalidHashOwner),
).resolves.toBeNull();
});
it('rejects empty persisted IDs and non-integer revision metadata', async () => {
const emptyIdOwner: MechanicAuthoredOwner = {
type: 'os_protocol_talent',
id: 'protocol-empty-runtime-ids',
};
const emptyIds = await saveSelected(emptyIdOwner);
await database.exec('PRAGMA foreign_keys=OFF');
try {
await execute(
'UPDATE mechanic_authored_sources SET id=? WHERE id=?',
'',
emptyIds.source.id,
);
await execute(
'UPDATE mechanic_authored_variants SET source_id=? WHERE source_id=?',
'',
emptyIds.source.id,
);
await execute(
'UPDATE mechanic_authored_slots SET source_id=? WHERE source_id=?',
'',
emptyIds.source.id,
);
await execute(
'UPDATE mechanic_model_revisions SET source_id=? WHERE source_id=?',
'',
emptyIds.source.id,
);
await execute(
'UPDATE mechanic_model_revisions SET id=? WHERE id=?',
'',
emptyIds.revision.id,
);
await execute(
'UPDATE mechanic_model_selections SET source_id=?,revision_id=? WHERE source_id=?',
'',
'',
emptyIds.source.id,
);
for (const table of [
'mechanic_model_revision_pools',
'mechanic_model_revision_clauses',
'mechanic_model_revision_outcomes',
]) {
await execute(
`UPDATE ${table} SET revision_id=? WHERE revision_id=?`,
'',
emptyIds.revision.id,
);
}
} finally {
await database.exec('PRAGMA foreign_keys=ON');
}
await expect(
loadSelectedMechanicRuntime(database, emptyIdOwner),
).resolves.toBeNull();
const fractionalOwner: MechanicAuthoredOwner = {
type: 'os_protocol_talent',
id: 'protocol-fractional-revision',
};
const fractional = await saveSelected(fractionalOwner);
await execute(
'UPDATE mechanic_model_revisions SET revision_number=? WHERE id=?',
1.5,
fractional.revision.id,
);
await expect(
loadSelectedMechanicRuntime(database, fractionalOwner),
).resolves.toBeNull();
const nonNumericOwner: MechanicAuthoredOwner = {
type: 'os_protocol_talent',
id: 'protocol-nonnumeric-revision',
};
const nonNumeric = await saveSelected(nonNumericOwner);
await execute(
'UPDATE mechanic_model_revisions SET revision_number=? WHERE id=?',
'not-a-number',
nonNumeric.revision.id,
);
await expect(
loadSelectedMechanicRuntime(database, nonNumericOwner),
).resolves.toBeNull();
});
it('excludes captured-only models and preserves requires-assumptions inputs', async () => {
const capturedOwner: MechanicAuthoredOwner = {
type: 'skill_chip_talent',
id: 'chip-captured',
};
await saveSelected(capturedOwner, { simulationReadiness: 'captured_only' });
await expect(
loadSelectedMechanicRuntime(database, capturedOwner),
).resolves.toBeNull();
const assumedOwner: MechanicAuthoredOwner = {
type: 'skill_chip_talent',
id: 'chip-assumed',
};
await saveSelected(assumedOwner, {
simulationReadiness: 'requires_assumptions',
});
await expect(
loadSelectedMechanicRuntime(database, assumedOwner),
).resolves.toMatchObject({
simulationReadiness: 'requires_assumptions',
scenarioInputs: [
{
key: 'nearby-enemies',
readiness: 'requires_assumption',
min: 1,
max: 4,
},
],
});
});
it('keeps bulk runtime reads bounded at realistic owner counts', async () => {
const owners: MechanicAuthoredOwner[] = Array.from(
{ length: 24 },
(_, index) => {
switch (index % 4) {
case 0:
return {
type: 'talent' as const,
id: `talent-bulk-${index}`,
scope: 'standard' as const,
};
case 1:
return {
type: 'os_protocol_talent' as const,
id: `protocol-bulk-${index}`,
};
case 2:
return {
type: 'skill_chip_talent' as const,
id: `chip-bulk-${index}`,
};
default:
return {
type: 'specialization_tree_rank' as const,
id: `rank-bulk-${index}`,
};
}
},
);
for (const owner of owners) await saveSelected(owner);
const counted = databaseThatCountsQueries();
const selections = await loadSelectedMechanicRuntimes(
counted.reader,
owners,
);
expect(
[...selections.values()].filter((selection) => selection !== null),
).toHaveLength(owners.length);
expect(counted.queryCount()).toBeLessThanOrEqual(16);
});
it('isolates corrupt owners in multi-owner reads and produces unique runtime IDs', async () => {
const healthyOwner: MechanicAuthoredOwner = {
type: 'talent',
id: 'talent-healthy',
scope: 'standard',
};
const corruptOwner: MechanicAuthoredOwner = {
type: 'os_protocol_talent',
id: 'protocol-corrupt',
};
const healthy = await saveSelected(healthyOwner);
const corrupt = await saveSelected(corruptOwner);
await execute(
'UPDATE mechanic_model_revisions SET program_hash=? WHERE id=?',
'b'.repeat(64),
corrupt.revision.id,
);
const selections = await loadSelectedMechanicRuntimes(database, [
healthyOwner,
corruptOwner,
]);
const healthySelection = selections.get(
mechanicRuntimeOwnerKey(healthyOwner),
);
expect(healthySelection).toMatchObject({ sourceId: healthy.source.id });
expect(selections.get(mechanicRuntimeOwnerKey(corruptOwner))).toBeNull();
const runtimeIds = healthySelection?.variants.map(
(variant) => variant.program.id,
);
expect(runtimeIds).toHaveLength(2);
expect(new Set(runtimeIds).size).toBe(2);
expect(runtimeIds).toEqual([
mechanicRuntimeProgramId(healthy.source.id, 'I'),
mechanicRuntimeProgramId(healthy.source.id, 'II'),
]);
});
});
@@ -0,0 +1,735 @@
import type { MechanicAuthoredOwner } from '$lib/calculations/mechanic-authored-description';
import {
classifyMechanicVariantLifecycle,
hashMechanicAuthoredSource,
type MechanicSimulationReadiness,
} from '$lib/calculations/mechanic-lifecycle';
import type {
MechanicProgram,
ScenarioInputDefinition,
} from '$lib/calculations/mechanic';
import type { MechanicAttributeLabelResolver } from '$lib/calculations/mechanic-public-renderer';
import type { Database } from '@tursodatabase/database';
import {
loadMechanicAuthoredSource,
type MechanicAuthoredSourceRecord,
} from './mechanic-authored-sources';
import {
loadSelectedMechanicModelRevision,
type MechanicModelRevisionRecord,
} from './mechanic-model-revisions';
export type MechanicRuntimeOwnerKey = string;
export type MechanicRuntimeOwnerReference = Pick<
MechanicAuthoredOwner,
'type' | 'id'
>;
export type MechanicRuntimeOwnerReferenceKey = string;
export type MechanicRuntimeSimulationReadiness = Exclude<
MechanicSimulationReadiness,
'captured_only'
>;
/** A validated selected program, ordered by its current authored variants. */
export type MechanicRuntimeVariant = {
key: string;
label: string;
program: MechanicProgram;
};
/**
* The executable portion of one explicitly selected, current mechanic model.
* Captured-only models intentionally never reach this boundary.
*/
export type MechanicRuntimeSelection = {
owner: MechanicAuthoredOwner;
sourceId: string;
sourceFingerprint: string;
revisionId: string;
revisionNumber: number;
simulationReadiness: MechanicRuntimeSimulationReadiness;
scenarioInputs: ScenarioInputDefinition[];
variants: MechanicRuntimeVariant[];
};
/**
* Produces an unambiguous stable key for a typed owner reference. Talent
* metadata is included because it is part of that owner's authored contract.
*/
export function mechanicRuntimeOwnerKey(
owner: MechanicAuthoredOwner,
): MechanicRuntimeOwnerKey {
return JSON.stringify(
owner.type === 'talent'
? [owner.type, owner.id, owner.scope, Boolean(owner.hasTierII)]
: [owner.type, owner.id],
);
}
/** Produces a stable key before the source row supplies talent metadata. */
export function mechanicRuntimeOwnerReferenceKey(
owner: MechanicRuntimeOwnerReference,
): MechanicRuntimeOwnerReferenceKey {
return JSON.stringify([owner.type, owner.id]);
}
/** The generated ID is deliberately outside the stored canonical hash. */
export function mechanicRuntimeProgramId(sourceId: string, variantKey: string) {
return `mechanic-runtime:${JSON.stringify([sourceId, variantKey])}`;
}
function ownersMatch(
expected: MechanicAuthoredOwner,
actual: MechanicAuthoredOwner,
) {
if (expected.type !== actual.type || expected.id !== actual.id) return false;
return (
expected.type !== 'talent' ||
(actual.type === 'talent' &&
expected.scope === actual.scope &&
Boolean(expected.hasTierII) === Boolean(actual.hasTierII))
);
}
function hasCurrentVariantOrder(
ownerVariants: readonly { key: string }[],
selectedVariants: readonly { key: string }[],
) {
return (
ownerVariants.length === selectedVariants.length &&
ownerVariants.every(
(variant, index) => variant.key === selectedVariants[index]?.key,
)
);
}
async function loadAttributeLabelResolver(
database: Database,
): Promise<MechanicAttributeLabelResolver> {
const rows = await (
await database.prepare('SELECT id,name FROM stat_attributes WHERE active=1')
).all();
const labels = new Map<string, string>();
for (const row of rows) {
if (
typeof row.id === 'string' &&
row.id.trim() &&
typeof row.name === 'string' &&
row.name.trim()
) {
labels.set(row.id, row.name);
}
}
return (attributeId) => labels.get(attributeId) ?? null;
}
type RuntimeSnapshotRow = Record<string, unknown>;
type RuntimeSnapshot = {
sourceByOwner: Map<string, RuntimeSnapshotRow>;
variantsBySourceId: Map<string, RuntimeSnapshotRow[]>;
slotsBySourceId: Map<string, RuntimeSnapshotRow[]>;
selectionBySourceId: Map<string, RuntimeSnapshotRow>;
selectedRevisionBySourceId: Map<string, RuntimeSnapshotRow>;
poolsByRevisionId: Map<string, RuntimeSnapshotRow[]>;
clausesByRevisionId: Map<string, RuntimeSnapshotRow[]>;
outcomesByRevisionId: Map<string, RuntimeSnapshotRow[]>;
activeAttributes: RuntimeSnapshotRow[];
};
type RuntimeSnapshotCandidate = {
selection: MechanicRuntimeSelection;
token: string;
};
const MAX_STABLE_READ_ATTEMPTS = 2;
function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0;
}
function hasValidRuntimeIdentity(
source: MechanicAuthoredSourceRecord,
selected: MechanicModelRevisionRecord,
) {
return (
isNonEmptyString(source.id) &&
isNonEmptyString(source.fingerprint) &&
isNonEmptyString(selected.id) &&
isNonEmptyString(selected.sourceId) &&
Number.isFinite(selected.revisionNumber) &&
Number.isInteger(selected.revisionNumber) &&
selected.revisionNumber > 0
);
}
function runtimeSnapshotOwnerKey(type: unknown, id: unknown) {
return `${String(type)}:${String(id)}`;
}
function runtimeOwnerFromSnapshotRow(
row: RuntimeSnapshotRow,
): MechanicAuthoredOwner | null {
if (!isNonEmptyString(row.owner_id)) return null;
switch (row.owner_type) {
case 'talent':
if (row.talent_scope !== 'standard' && row.talent_scope !== 'exotic') {
return null;
}
return {
type: 'talent',
id: row.owner_id,
scope: row.talent_scope,
...(Number(row.talent_has_tier_ii ?? 0) === 1
? { hasTierII: true }
: {}),
};
case 'os_protocol_talent':
return { type: 'os_protocol_talent', id: row.owner_id };
case 'skill_chip_talent':
return { type: 'skill_chip_talent', id: row.owner_id };
case 'specialization_tree_rank':
return { type: 'specialization_tree_rank', id: row.owner_id };
default:
return null;
}
}
function mapFirstRowByStringColumn(
rows: readonly RuntimeSnapshotRow[],
column: string,
) {
const result = new Map<string, RuntimeSnapshotRow>();
for (const row of rows) {
const value = row[column];
if (typeof value === 'string' && !result.has(value)) {
result.set(value, row);
}
}
return result;
}
function mapRowsByStringColumn(
rows: readonly RuntimeSnapshotRow[],
column: string,
) {
const result = new Map<string, RuntimeSnapshotRow[]>();
for (const row of rows) {
const value = row[column];
if (typeof value !== 'string') continue;
result.set(value, [...(result.get(value) ?? []), row]);
}
return result;
}
function copiedRow(row: RuntimeSnapshotRow | undefined) {
return row === undefined ? undefined : { ...row };
}
function copiedRows(rows: RuntimeSnapshotRow[] | undefined) {
return (rows ?? []).map((row) => ({ ...row }));
}
async function loadRuntimeSnapshotRows(
database: Database,
sql: string,
ids: readonly string[],
): Promise<RuntimeSnapshotRow[]> {
return (await (
await database.prepare(sql)
).all(JSON.stringify(ids))) as RuntimeSnapshotRow[];
}
/**
* Reads every row the runtime validator needs in a bounded number of queries.
* These rows remain private input to the existing source and revision readers.
*/
async function loadRuntimeSnapshot(
database: Database,
owners: readonly MechanicRuntimeOwnerReference[],
): Promise<RuntimeSnapshot> {
const requestedOwners = [
...new Map(
owners
.filter((owner) => isNonEmptyString(owner.id))
.map((owner) => [mechanicRuntimeOwnerReferenceKey(owner), owner]),
).values(),
];
const sourceRows = (await (
await database.prepare(`SELECT source.*
FROM mechanic_authored_sources source
JOIN json_each(?) requested
ON source.owner_type=json_extract(requested.value,'$.type')
AND source.owner_id=json_extract(requested.value,'$.id')`)
).all(JSON.stringify(requestedOwners))) as RuntimeSnapshotRow[];
const sourceByOwner = new Map<string, RuntimeSnapshotRow>();
for (const source of sourceRows) {
if (
typeof source.owner_type === 'string' &&
typeof source.owner_id === 'string'
) {
sourceByOwner.set(
runtimeSnapshotOwnerKey(source.owner_type, source.owner_id),
source,
);
}
}
const sourceById = mapFirstRowByStringColumn(sourceRows, 'id');
const sourceIds = [...sourceById.keys()];
if (!sourceIds.length) {
return {
sourceByOwner,
variantsBySourceId: new Map(),
slotsBySourceId: new Map(),
selectionBySourceId: new Map(),
selectedRevisionBySourceId: new Map(),
poolsByRevisionId: new Map(),
clausesByRevisionId: new Map(),
outcomesByRevisionId: new Map(),
activeAttributes: [],
};
}
const [variantRows, slotRows, revisionRows, activeAttributes] = await Promise.all([
loadRuntimeSnapshotRows(
database,
`SELECT * FROM mechanic_authored_variants
WHERE source_id IN (SELECT value FROM json_each(?))
ORDER BY source_id,sort_order`,
sourceIds,
),
loadRuntimeSnapshotRows(
database,
`SELECT * FROM mechanic_authored_slots
WHERE source_id IN (SELECT value FROM json_each(?))
ORDER BY source_id,variant_key,sort_order`,
sourceIds,
),
loadRuntimeSnapshotRows(
database,
`SELECT
selection.source_id AS selection_source_id,
selection.revision_id AS selection_revision_id,
selection.updated_at AS selection_updated_at,
revision.*
FROM mechanic_model_selections selection
JOIN mechanic_model_revisions revision
ON revision.id=selection.revision_id
AND revision.source_id=selection.source_id
WHERE selection.source_id IN (SELECT value FROM json_each(?))`,
sourceIds,
),
(async () =>
(await (
await database.prepare(
'SELECT id,name FROM stat_attributes WHERE active=1',
)
).all()) as RuntimeSnapshotRow[])(),
]);
const revisionIds = [
...new Set(
revisionRows.flatMap((revision) =>
typeof revision.id === 'string' ? [revision.id] : [],
),
),
];
const [poolRows, clauseRows, outcomeRows] = revisionIds.length
? await Promise.all([
loadRuntimeSnapshotRows(
database,
`SELECT * FROM mechanic_model_revision_pools
WHERE revision_id IN (SELECT value FROM json_each(?))
ORDER BY revision_id,sort_order`,
revisionIds,
),
loadRuntimeSnapshotRows(
database,
`SELECT * FROM mechanic_model_revision_clauses
WHERE revision_id IN (SELECT value FROM json_each(?))
ORDER BY revision_id,sort_order`,
revisionIds,
),
loadRuntimeSnapshotRows(
database,
`SELECT * FROM mechanic_model_revision_outcomes
WHERE revision_id IN (SELECT value FROM json_each(?))
ORDER BY revision_id,sort_order,variant_key`,
revisionIds,
),
])
: [[], [], []];
return {
sourceByOwner,
variantsBySourceId: mapRowsByStringColumn(variantRows, 'source_id'),
slotsBySourceId: mapRowsByStringColumn(slotRows, 'source_id'),
selectionBySourceId: mapFirstRowByStringColumn(
revisionRows,
'selection_source_id',
),
selectedRevisionBySourceId: mapFirstRowByStringColumn(
revisionRows,
'source_id',
),
poolsByRevisionId: mapRowsByStringColumn(poolRows, 'revision_id'),
clausesByRevisionId: mapRowsByStringColumn(clauseRows, 'revision_id'),
outcomesByRevisionId: mapRowsByStringColumn(outcomeRows, 'revision_id'),
activeAttributes,
};
}
/**
* Replays the exact source, selected-revision, and active-attribute queries
* against a private bulk snapshot. This retains the established single-owner
* validation and parser behavior without per-owner database fan-out.
*/
function runtimeSnapshotDatabase(snapshot: RuntimeSnapshot): Database {
return {
prepare: async (sql: string) => {
const normalizedSql = sql.replace(/\s+/g, ' ').trim();
const sourceId = (parameters: unknown[]) =>
typeof parameters[0] === 'string' ? parameters[0] : '';
const sourceForOwner = (parameters: unknown[]) => {
const [ownerType, ownerId] = parameters;
return typeof ownerType === 'string' && typeof ownerId === 'string'
? snapshot.sourceByOwner.get(runtimeSnapshotOwnerKey(ownerType, ownerId))
: undefined;
};
const selectedRevision = (parameters: unknown[]) =>
snapshot.selectedRevisionBySourceId.get(sourceId(parameters));
return {
get: async (...parameters: unknown[]) => {
if (
normalizedSql ===
'SELECT * FROM mechanic_authored_sources WHERE owner_type=? AND owner_id=?'
) {
return copiedRow(sourceForOwner(parameters));
}
if (
normalizedSql.startsWith(
'SELECT revision.* FROM mechanic_model_selections selection JOIN mechanic_model_revisions revision ',
)
) {
return copiedRow(selectedRevision(parameters));
}
throw new Error(
`Unsupported mechanic runtime snapshot query: ${normalizedSql}`,
);
},
all: async (...parameters: unknown[]) => {
if (
normalizedSql.startsWith(
'SELECT * FROM mechanic_authored_variants ',
)
) {
return copiedRows(
snapshot.variantsBySourceId.get(sourceId(parameters)),
);
}
if (
normalizedSql.startsWith('SELECT * FROM mechanic_authored_slots ')
) {
return copiedRows(snapshot.slotsBySourceId.get(sourceId(parameters)));
}
if (
normalizedSql ===
'SELECT id,name FROM stat_attributes WHERE active=1'
) {
return copiedRows(snapshot.activeAttributes);
}
if (
normalizedSql.startsWith(
'SELECT * FROM mechanic_model_revision_pools ',
)
) {
return copiedRows(
snapshot.poolsByRevisionId.get(sourceId(parameters)),
);
}
if (
normalizedSql.startsWith(
'SELECT * FROM mechanic_model_revision_clauses ',
)
) {
return copiedRows(
snapshot.clausesByRevisionId.get(sourceId(parameters)),
);
}
if (
normalizedSql.startsWith(
'SELECT * FROM mechanic_model_revision_outcomes ',
)
) {
return copiedRows(
snapshot.outcomesByRevisionId.get(sourceId(parameters)),
);
}
throw new Error(
`Unsupported mechanic runtime snapshot query: ${normalizedSql}`,
);
},
};
},
} as unknown as Database;
}
async function loadVerifiedAuthoredSource(
database: Database,
owner: MechanicAuthoredOwner,
): Promise<{
source: MechanicAuthoredSourceRecord;
fingerprint: string;
} | null> {
const source = await loadMechanicAuthoredSource(
database,
owner.type,
owner.id,
);
if (!source || !ownersMatch(owner, source.source.owner)) return null;
const fingerprint = await hashMechanicAuthoredSource(source.source);
if (!fingerprint.ok || fingerprint.fingerprint !== source.fingerprint) {
return null;
}
return { source, fingerprint: fingerprint.fingerprint };
}
async function loadRuntimeSelection(
database: Database,
owner: MechanicAuthoredOwner,
): Promise<MechanicRuntimeSelection | null> {
const current = await loadVerifiedAuthoredSource(database, owner);
if (!current) return null;
// This reader intentionally never falls back to a latest or historical row.
const selected = await loadSelectedMechanicModelRevision(
database,
current.source.id,
);
if (
!selected ||
!hasValidRuntimeIdentity(current.source, selected) ||
selected.sourceId !== current.source.id ||
!hasCurrentVariantOrder(current.source.source.variants, selected.variants)
) {
return null;
}
const lifecycle = await classifyMechanicVariantLifecycle({
currentSourceFingerprint: current.fingerprint,
resolveAttributeLabel: await loadAttributeLabelResolver(database),
selectedRevision: {
sourceFingerprint: selected.sourceFingerprint,
mechanicContractVersion: selected.mechanicContractVersion,
programHash: selected.programHash,
simulationReadiness: selected.simulationReadiness,
variants: selected.variants,
},
});
if (lifecycle.status !== 'current') return null;
if (lifecycle.simulationReadiness === 'captured_only') return null;
return {
owner: current.source.source.owner,
sourceId: current.source.id,
sourceFingerprint: lifecycle.fingerprint,
revisionId: selected.id,
revisionNumber: selected.revisionNumber,
simulationReadiness: lifecycle.simulationReadiness,
scenarioInputs: selected.draft.scenarioInputs,
variants: selected.variants.map((variant) => ({
key: variant.key,
label: variant.label ?? variant.key,
// Stored programs are validated and hash-checked before this runtime-only
// generated identity is assigned.
program: {
...variant.program,
id: mechanicRuntimeProgramId(current.source.id, variant.key),
},
})),
};
}
/**
* Includes every executable input for one owner. A selection ID check alone is
* insufficient because revision rows are conventionally append-only rather
* than database-enforced immutable.
*/
function runtimeSnapshotToken(
snapshot: RuntimeSnapshot,
reference: MechanicRuntimeOwnerReference,
selection: MechanicRuntimeSelection,
): string | null {
const source = snapshot.sourceByOwner.get(
runtimeSnapshotOwnerKey(reference.type, reference.id),
);
if (!source || source.id !== selection.sourceId) return null;
const selected = snapshot.selectionBySourceId.get(selection.sourceId);
const revision = snapshot.selectedRevisionBySourceId.get(selection.sourceId);
if (
!selected ||
!revision ||
selected.selection_revision_id !== selection.revisionId ||
revision.id !== selection.revisionId ||
revision.source_id !== selection.sourceId
) {
return null;
}
return JSON.stringify({
source,
variants: snapshot.variantsBySourceId.get(selection.sourceId) ?? [],
slots: snapshot.slotsBySourceId.get(selection.sourceId) ?? [],
selection: selected,
revision,
pools: snapshot.poolsByRevisionId.get(selection.revisionId) ?? [],
clauses: snapshot.clausesByRevisionId.get(selection.revisionId) ?? [],
outcomes: snapshot.outcomesByRevisionId.get(selection.revisionId) ?? [],
attributes: snapshot.activeAttributes,
});
}
async function runtimeSnapshotCandidate(
snapshot: RuntimeSnapshot,
reference: MechanicRuntimeOwnerReference,
): Promise<RuntimeSnapshotCandidate | null> {
const sourceRow = snapshot.sourceByOwner.get(
runtimeSnapshotOwnerKey(reference.type, reference.id),
);
if (!sourceRow) return null;
const owner = runtimeOwnerFromSnapshotRow(sourceRow);
if (!owner) return null;
try {
const selection = await loadRuntimeSelection(
runtimeSnapshotDatabase(snapshot),
owner,
);
if (!selection) return null;
const token = runtimeSnapshotToken(snapshot, reference, selection);
return token ? { selection, token } : null;
} catch {
return null;
}
}
function uniqueRuntimeOwnerReferences(
owners: readonly MechanicRuntimeOwnerReference[],
) {
return [
...new Map(
owners
.filter((owner) => isNonEmptyString(owner.id))
.map((owner) => [mechanicRuntimeOwnerReferenceKey(owner), owner]),
).values(),
];
}
async function loadStableRuntimeSelections(
database: Database,
owners: readonly MechanicRuntimeOwnerReference[],
) {
const result = new Map<
MechanicRuntimeOwnerReferenceKey,
MechanicRuntimeSelection | null
>(owners.map((owner) => [mechanicRuntimeOwnerReferenceKey(owner), null]));
let pendingOwners = [...owners];
for (let attempt = 0; attempt < MAX_STABLE_READ_ATTEMPTS; attempt += 1) {
const initial = await loadRuntimeSnapshot(database, pendingOwners);
const candidates = await Promise.all(
pendingOwners.map((owner) => runtimeSnapshotCandidate(initial, owner)),
);
if (!candidates.some((candidate) => candidate !== null)) break;
const reloaded = await loadRuntimeSnapshot(database, pendingOwners);
const unstableOwners: MechanicRuntimeOwnerReference[] = [];
for (const [index, owner] of pendingOwners.entries()) {
const candidate = candidates[index];
if (!candidate) continue;
const reloadedToken = runtimeSnapshotToken(
reloaded,
owner,
candidate.selection,
);
if (candidate.token === reloadedToken) {
result.set(mechanicRuntimeOwnerReferenceKey(owner), candidate.selection);
} else {
unstableOwners.push(owner);
}
}
pendingOwners = unstableOwners;
if (!pendingOwners.length) break;
}
return result;
}
/**
* Bulk-hydrates selected runtimes when caller metadata has only an owner type
* and ID. The current source supplies talent scope and tier metadata, which is
* validated again while assembling every executable selection.
*/
export async function loadSelectedMechanicRuntimesForOwnerReferences(
database: Database,
owners: readonly MechanicRuntimeOwnerReference[],
): Promise<
Map<MechanicRuntimeOwnerReferenceKey, MechanicRuntimeSelection | null>
> {
const references = uniqueRuntimeOwnerReferences(owners);
const selections = new Map<
MechanicRuntimeOwnerReferenceKey,
MechanicRuntimeSelection | null
>(owners.map((owner) => [mechanicRuntimeOwnerReferenceKey(owner), null]));
if (!references.length) return selections;
try {
const stable = await loadStableRuntimeSelections(database, references);
for (const reference of references) {
selections.set(
mechanicRuntimeOwnerReferenceKey(reference),
stable.get(mechanicRuntimeOwnerReferenceKey(reference)) ?? null,
);
}
} catch {
// A failed bulk read has no executable fallback at this boundary.
}
return selections;
}
/**
* Loads only the explicitly selected revision for one typed owner. Any stale,
* unsupported, malformed, or captured-only record is unavailable to runtime.
*/
export async function loadSelectedMechanicRuntime(
database: Database,
owner: MechanicAuthoredOwner,
): Promise<MechanicRuntimeSelection | null> {
const selections = await loadSelectedMechanicRuntimes(database, [owner]);
return selections.get(mechanicRuntimeOwnerKey(owner)) ?? null;
}
/**
* Resolves each owner independently. A corrupt source remains null without
* suppressing healthy owners in the shared bounded runtime snapshot.
*/
export async function loadSelectedMechanicRuntimes(
database: Database,
owners: readonly MechanicAuthoredOwner[],
): Promise<Map<MechanicRuntimeOwnerKey, MechanicRuntimeSelection | null>> {
const byReference = await loadSelectedMechanicRuntimesForOwnerReferences(
database,
owners,
);
return new Map(
owners.map((owner) => {
const selection =
byReference.get(mechanicRuntimeOwnerReferenceKey(owner)) ?? null;
return [
mechanicRuntimeOwnerKey(owner),
selection && ownersMatch(owner, selection.owner) ? selection : null,
];
}),
);
}
+709
View File
@@ -0,0 +1,709 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { connect, type Database } from '@tursodatabase/database';
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {
applyIconMutation,
deleteGameDataIcon,
gameDataIconStorageDirectory,
saveGameDataIcon,
} from './game-data-icons';
import {
applyGameDataRender,
deleteManagedImage,
managedImageStorageDirectory as renderStorageDirectory,
saveManagedImageFile,
} from './managed-images';
import {
MediaMutationJournal,
captureMediaFile,
restoreMediaFile,
} from './media-journal';
const iconId = '11111111-1111-4111-8111-111111111111';
const imageId = '33333333-3333-4333-8333-333333333333';
const alternateImageId = '44444444-4444-4444-8444-444444444444';
let database: Database;
let storageDirectory: string;
const previousStorageDirectory = process.env.TDR_GAME_ICON_STORAGE_PATH;
beforeEach(async () => {
storageDirectory = await mkdtemp(
path.join(os.tmpdir(), 'shd-media-journal-'),
);
process.env.TDR_GAME_ICON_STORAGE_PATH = storageDirectory;
database = await connect(':memory:');
await database.exec(`
CREATE TABLE talents (id TEXT PRIMARY KEY, name TEXT NOT NULL, category TEXT NOT NULL);
CREATE TABLE talent_weapon_scopes (talent_id TEXT NOT NULL, scope TEXT NOT NULL);
CREATE TABLE game_data_icons (
entity_type TEXT NOT NULL, entity_id TEXT NOT NULL, storage_key TEXT,
byte_size INTEGER, updated_at TEXT, PRIMARY KEY(entity_type, entity_id)
);
CREATE TABLE managed_images (
id TEXT PRIMARY KEY, name TEXT, category TEXT, tags_json TEXT, is_icon INTEGER,
force_white INTEGER, storage_key TEXT, original_filename TEXT, byte_size INTEGER,
width INTEGER, height INTEGER, source_entity_type TEXT, source_entity_id TEXT,
created_by TEXT, created_at TEXT, updated_at TEXT
);
`);
await database.exec(
`INSERT INTO talents(id,name,category) VALUES('${iconId}','Test Talent','Weapon')`,
);
});
afterEach(async () => {
await database.close();
await rm(storageDirectory, { recursive: true, force: true });
if (previousStorageDirectory === undefined)
delete process.env.TDR_GAME_ICON_STORAGE_PATH;
else process.env.TDR_GAME_ICON_STORAGE_PATH = previousStorageDirectory;
});
const iconPath = () =>
path.join(gameDataIconStorageDirectory(), `talent-${iconId}.webp`);
const renderPath = (key: string) => path.join(renderStorageDirectory(), key);
const renderMutation = (output: string, fileName = 'render.png') => ({
file: new File(['source'], fileName, { type: 'image/png' }),
remove: false,
processed: { output: Buffer.from(output), width: 1000, height: 1000 },
});
const flushTasks = () => new Promise<void>((resolve) => setImmediate(resolve));
const seedRender = async (sourceType = 'talent_render') => {
const key = `library-${imageId}.webp`;
await mkdir(renderStorageDirectory(), { recursive: true });
await writeFile(renderPath(key), Buffer.from('old-render'), { flag: 'w' });
await (
await database.prepare(`INSERT INTO managed_images
(id,name,category,tags_json,is_icon,force_white,storage_key,original_filename,byte_size,width,height,
source_entity_type,source_entity_id,created_by,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
).run(
imageId,
'Old render',
'Talents',
'[]',
0,
0,
key,
'old.png',
10,
1000,
1000,
sourceType,
iconId,
'operator',
'old',
'old',
);
};
describe('MediaMutationJournal', () => {
it('runs compensations in LIFO order once', async () => {
const journal = new MediaMutationJournal();
const events: string[] = [];
journal.register(async () => {
events.push('first');
});
journal.register(async () => {
events.push('second');
});
await journal.rollback(new Error('operation failed'));
await journal.rollback(new Error('operation failed again'));
expect(events).toEqual(['second', 'first']);
});
it('aggregates the operation and compensation failures', async () => {
const journal = new MediaMutationJournal();
const original = new Error('audit failed');
const compensation = new Error('restore failed');
journal.register(async () => {
throw compensation;
});
let failure: unknown;
try {
await journal.rollback(original);
} catch (error) {
failure = error;
}
expect(failure).toBeInstanceOf(AggregateError);
expect((failure as AggregateError).errors).toEqual([
original,
compensation,
]);
});
it('restores exact bytes and absence', async () => {
const filePath = path.join(storageDirectory, 'snapshot.webp');
await writeFile(filePath, Buffer.from('before'));
const snapshot = await captureMediaFile(filePath);
await writeFile(filePath, Buffer.from('after'));
await restoreMediaFile(filePath, snapshot);
expect(await readFile(filePath, 'utf8')).toBe('before');
const absentPath = path.join(storageDirectory, 'absent.webp');
const absent = await captureMediaFile(absentPath);
await writeFile(absentPath, Buffer.from('new'));
await restoreMediaFile(absentPath, absent);
await expect(readFile(absentPath)).rejects.toMatchObject({
code: 'ENOENT',
});
});
it('does not deadlock when a journal locks a path repeatedly', async () => {
const journal = new MediaMutationJournal();
const filePath = path.join(storageDirectory, 'repeat.webp');
await journal.lockPath(filePath);
await journal.lockPath(filePath);
journal.clear();
});
it('releases a path when rollback compensation throws', async () => {
const first = new MediaMutationJournal();
const second = new MediaMutationJournal();
const filePath = path.join(storageDirectory, 'throwing.webp');
await first.lockPath(filePath);
first.register(() => {
throw new Error('restore failed');
});
const waiting = second.lockPath(filePath);
await expect(first.rollback(new Error('operation failed'))).rejects.toThrow(
'Media rollback did not complete.',
);
await waiting;
second.clear();
});
it('starts legacy DB work before waiting on media across connections', async () => {
const databasePath = path.join(storageDirectory, 'deadlock.db');
const firstDatabase = await connect(databasePath);
const secondDatabase = await connect(databasePath);
const storageKey = `library-${imageId}.webp`;
const filePath = renderPath(storageKey);
let beginResolved!: () => void;
const beginStarted = new Promise<void>((resolve) => {
beginResolved = resolve;
});
try {
await firstDatabase.exec(`
CREATE TABLE managed_images (
id TEXT PRIMARY KEY, name TEXT, category TEXT, tags_json TEXT,
is_icon INTEGER, force_white INTEGER, storage_key TEXT,
original_filename TEXT, byte_size INTEGER, width INTEGER, height INTEGER,
source_entity_type TEXT, source_entity_id TEXT, created_by TEXT,
created_at TEXT, updated_at TEXT
);
`);
await (
await firstDatabase.prepare(`INSERT INTO managed_images
(id,name,category,tags_json,is_icon,force_white,storage_key,original_filename,byte_size,width,height,
source_entity_type,source_entity_id,created_by,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
).run(
imageId,
'Render',
'Talents',
'[]',
0,
0,
storageKey,
'old.png',
10,
1000,
1000,
'talent_render',
iconId,
'operator',
'old',
'old',
);
await mkdir(renderStorageDirectory(), { recursive: true });
await writeFile(filePath, Buffer.from('old-render'));
const legacyDatabase = {
exec: async (sql: string) => {
const result = await secondDatabase.exec(sql);
if (sql === 'BEGIN IMMEDIATE') beginResolved();
return result;
},
prepare: (sql: string) => secondDatabase.prepare(sql),
} as unknown as Database;
const holder = new MediaMutationJournal();
await holder.lockPath(filePath);
const deletion = deleteManagedImage(legacyDatabase, imageId);
await Promise.race([
beginStarted,
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error('legacy DB transaction did not start')),
1000,
),
),
]);
await expect(readFile(filePath, 'utf8')).resolves.toBe('old-render');
holder.clear();
await deletion;
await expect(readFile(filePath)).rejects.toMatchObject({
code: 'ENOENT',
});
} finally {
await firstDatabase.close();
await secondDatabase.close();
}
});
});
describe('game-data icon media rollback', () => {
it.each([
{
label: 'create',
before: null,
mutation: { output: Buffer.from('new-icon'), remove: false },
},
{
label: 'replace',
before: Buffer.from('old-icon'),
mutation: { output: Buffer.from('new-icon'), remove: false },
},
{
label: 'remove',
before: Buffer.from('old-icon'),
mutation: { output: null, remove: true },
},
])(
'$label restores after a later operation failure',
async ({ before, mutation }) => {
if (before) {
await mkdir(gameDataIconStorageDirectory(), { recursive: true });
await writeFile(iconPath(), before);
await (
await database.prepare(
'INSERT INTO game_data_icons(entity_type,entity_id,storage_key,byte_size,updated_at) VALUES(?,?,?,?,?)',
)
).run('talent', iconId, `talent-${iconId}.webp`, before.length, 'old');
}
const journal = new MediaMutationJournal();
await applyIconMutation(
database,
'talent',
iconId,
mutation,
'operator',
journal,
);
if (before) {
if (mutation.remove)
await expect(readFile(iconPath())).rejects.toMatchObject({
code: 'ENOENT',
});
else expect(await readFile(iconPath())).toEqual(mutation.output);
} else expect(await readFile(iconPath())).toEqual(mutation.output);
await journal.rollback(new Error('audit failed'));
if (before) expect(await readFile(iconPath())).toEqual(before);
else
await expect(readFile(iconPath())).rejects.toMatchObject({
code: 'ENOENT',
});
},
);
it('retains a new icon after commit', async () => {
const journal = new MediaMutationJournal();
await applyIconMutation(
database,
'talent',
iconId,
{ output: Buffer.from('committed-icon'), remove: false },
'operator',
journal,
);
journal.clear();
expect(await readFile(iconPath(), 'utf8')).toBe('committed-icon');
});
it('waits for an earlier journal to clear before mutating the icon', async () => {
const first = new MediaMutationJournal();
await applyIconMutation(
database,
'talent',
iconId,
{ output: Buffer.from('first-icon'), remove: false },
'operator',
first,
);
const second = new MediaMutationJournal();
const mutation = applyIconMutation(
database,
'talent',
iconId,
{ output: Buffer.from('second-icon'), remove: false },
'operator',
second,
);
await flushTasks();
await flushTasks();
expect(await readFile(iconPath(), 'utf8')).toBe('first-icon');
first.clear();
await mutation;
expect(await readFile(iconPath(), 'utf8')).toBe('second-icon');
second.clear();
});
it('does not let rollback overwrite a later serialized icon mutation', async () => {
await mkdir(gameDataIconStorageDirectory(), { recursive: true });
await writeFile(iconPath(), Buffer.from('old-icon'));
await (
await database.prepare(
'INSERT INTO game_data_icons(entity_type,entity_id,storage_key,byte_size,updated_at) VALUES(?,?,?,?,?)',
)
).run('talent', iconId, `talent-${iconId}.webp`, 8, 'old');
const first = new MediaMutationJournal();
await applyIconMutation(
database,
'talent',
iconId,
{ output: Buffer.from('first-icon'), remove: false },
'operator',
first,
);
const second = new MediaMutationJournal();
const mutation = applyIconMutation(
database,
'talent',
iconId,
{ output: Buffer.from('second-icon'), remove: false },
'operator',
second,
);
await flushTasks();
await flushTasks();
expect(await readFile(iconPath(), 'utf8')).toBe('first-icon');
await first.rollback(new Error('transaction failed'));
expect(await readFile(iconPath(), 'utf8')).toBe('old-icon');
await mutation;
expect(await readFile(iconPath(), 'utf8')).toBe('second-icon');
second.clear();
});
it('also serializes a legacy icon writer without a journal', async () => {
const first = new MediaMutationJournal();
await first.lockPath(iconPath());
const mutation = saveGameDataIcon(
database,
'talent',
iconId,
Buffer.from('legacy-icon'),
'operator',
);
await flushTasks();
await expect(readFile(iconPath())).rejects.toMatchObject({
code: 'ENOENT',
});
first.clear();
await mutation;
expect(await readFile(iconPath(), 'utf8')).toBe('legacy-icon');
});
it('also serializes a legacy icon deleter without a journal', async () => {
await mkdir(gameDataIconStorageDirectory(), { recursive: true });
await writeFile(iconPath(), Buffer.from('old-icon'));
await (
await database.prepare(
'INSERT INTO game_data_icons(entity_type,entity_id,storage_key,byte_size,updated_at) VALUES(?,?,?,?,?)',
)
).run('talent', iconId, `talent-${iconId}.webp`, 8, 'old');
const first = new MediaMutationJournal();
await first.lockPath(iconPath());
const deletion = deleteGameDataIcon(database, 'talent', iconId);
await flushTasks();
expect(await readFile(iconPath(), 'utf8')).toBe('old-icon');
first.clear();
await deletion;
await expect(readFile(iconPath())).rejects.toMatchObject({
code: 'ENOENT',
});
});
});
describe('managed render media rollback', () => {
it('removes a newly created render after a later operation failure', async () => {
const journal = new MediaMutationJournal();
const result = await applyGameDataRender(
database,
'talent',
iconId,
'Test Talent',
renderMutation('new-render'),
'operator',
journal,
);
const key = String(result?.storage_key);
expect(await readFile(renderPath(key), 'utf8')).toBe('new-render');
await journal.rollback(new Error('audit failed'));
await expect(readFile(renderPath(key))).rejects.toMatchObject({
code: 'ENOENT',
});
});
it('restores replacement and removal renders after a later operation failure', async () => {
await seedRender();
const replacement = new MediaMutationJournal();
await applyGameDataRender(
database,
'talent',
iconId,
'Test Talent',
renderMutation('replacement'),
'operator',
replacement,
);
expect(await readFile(renderPath(`library-${imageId}.webp`), 'utf8')).toBe(
'replacement',
);
await replacement.rollback(new Error('audit failed'));
expect(await readFile(renderPath(`library-${imageId}.webp`), 'utf8')).toBe(
'old-render',
);
const removal = new MediaMutationJournal();
await applyGameDataRender(
database,
'talent',
iconId,
'Test Talent',
{ file: null, remove: true },
'operator',
removal,
);
await removal.rollback(new Error('audit failed'));
expect(await readFile(renderPath(`library-${imageId}.webp`), 'utf8')).toBe(
'old-render',
);
});
it('cleans up a new file if compensation registration is unavailable', async () => {
const journal = new MediaMutationJournal();
journal.clear();
await expect(
saveManagedImageFile(imageId, Buffer.from('orphan'), journal),
).rejects.toThrow('Cannot register media compensation after commit.');
await expect(
readFile(renderPath(`library-${imageId}.webp`)),
).rejects.toMatchObject({ code: 'ENOENT' });
});
it('does not register or delete a pre-existing file after an exclusive-create collision', async () => {
await mkdir(renderStorageDirectory(), { recursive: true });
const filePath = renderPath(`library-${imageId}.webp`);
await writeFile(filePath, Buffer.from('pre-existing'));
const journal = new MediaMutationJournal();
await expect(
saveManagedImageFile(imageId, Buffer.from('replacement'), journal),
).rejects.toMatchObject({ code: 'EEXIST' });
await journal.rollback(new Error('collision'));
expect(await readFile(filePath, 'utf8')).toBe('pre-existing');
});
it('retains a new render after commit', async () => {
const journal = new MediaMutationJournal();
const result = await applyGameDataRender(
database,
'talent',
iconId,
'Test Talent',
renderMutation('committed-render'),
'operator',
journal,
);
const key = String(result?.storage_key);
journal.clear();
expect(await readFile(renderPath(key), 'utf8')).toBe('committed-render');
});
it('waits for an earlier journal to clear before mutating a render', async () => {
await seedRender();
const first = new MediaMutationJournal();
await applyGameDataRender(
database,
'talent',
iconId,
'Test Talent',
renderMutation('first-render'),
'operator',
first,
);
const second = new MediaMutationJournal();
const filePath = renderPath(`library-${imageId}.webp`);
const mutation = applyGameDataRender(
database,
'talent',
iconId,
'Test Talent',
renderMutation('second-render'),
'operator',
second,
);
await flushTasks();
await flushTasks();
expect(await readFile(filePath, 'utf8')).toBe('first-render');
first.clear();
await mutation;
expect(await readFile(filePath, 'utf8')).toBe('second-render');
second.clear();
});
it('does not let rollback overwrite a later serialized render mutation', async () => {
await seedRender();
const first = new MediaMutationJournal();
const filePath = renderPath(`library-${imageId}.webp`);
await applyGameDataRender(
database,
'talent',
iconId,
'Test Talent',
renderMutation('first-render'),
'operator',
first,
);
const second = new MediaMutationJournal();
const mutation = applyGameDataRender(
database,
'talent',
iconId,
'Test Talent',
renderMutation('second-render'),
'operator',
second,
);
await flushTasks();
await flushTasks();
expect(await readFile(filePath, 'utf8')).toBe('first-render');
await first.rollback(new Error('transaction failed'));
expect(await readFile(filePath, 'utf8')).toBe('old-render');
await mutation;
expect(await readFile(filePath, 'utf8')).toBe('second-render');
second.clear();
});
it('also serializes a legacy managed-image writer without a journal', async () => {
const first = new MediaMutationJournal();
const filePath = renderPath(`library-${imageId}.webp`);
await first.lockPath(filePath);
const mutation = saveManagedImageFile(
imageId,
Buffer.from('legacy-render'),
);
await flushTasks();
await expect(readFile(filePath)).rejects.toMatchObject({ code: 'ENOENT' });
first.clear();
await mutation;
expect(await readFile(filePath, 'utf8')).toBe('legacy-render');
});
it('also serializes a legacy managed-image deleter without a journal', async () => {
await seedRender();
const first = new MediaMutationJournal();
const filePath = renderPath(`library-${imageId}.webp`);
await first.lockPath(filePath);
const deletion = deleteManagedImage(database, imageId);
await flushTasks();
expect(await readFile(filePath, 'utf8')).toBe('old-render');
first.clear();
await deletion;
await expect(readFile(filePath)).rejects.toMatchObject({ code: 'ENOENT' });
});
it('revalidates the render path before a waiting removal', async () => {
await seedRender();
const oldPath = renderPath(`library-${imageId}.webp`);
const alternateKey = `library-${alternateImageId}.webp`;
const alternatePath = renderPath(alternateKey);
const first = new MediaMutationJournal();
await first.lockPath(oldPath);
const second = new MediaMutationJournal();
const waiting = second.lockPath(oldPath);
const removal = applyGameDataRender(
database,
'talent',
iconId,
'Test Talent',
{ file: null, remove: true },
'operator',
second,
);
await flushTasks();
await flushTasks();
await writeFile(alternatePath, Buffer.from('current-render'));
await (
await database.prepare(
'UPDATE managed_images SET storage_key=? WHERE id=?',
)
).run(alternateKey, imageId);
first.clear();
await waiting;
await removal;
await expect(readFile(alternatePath)).rejects.toMatchObject({
code: 'ENOENT',
});
const removed = await (
await database.prepare('SELECT id FROM managed_images WHERE id=?')
).get(imageId);
expect(removed).toBeUndefined();
await expect(readFile(oldPath, 'utf8')).resolves.toBe('old-render');
second.clear();
});
it('revalidates the render path before a waiting replacement', async () => {
await seedRender();
const oldPath = renderPath(`library-${imageId}.webp`);
const alternateKey = `library-${alternateImageId}.webp`;
const alternatePath = renderPath(alternateKey);
const first = new MediaMutationJournal();
await first.lockPath(oldPath);
const second = new MediaMutationJournal();
const waiting = second.lockPath(oldPath);
const replacement = applyGameDataRender(
database,
'talent',
iconId,
'Test Talent',
renderMutation('second-render'),
'operator',
second,
);
await flushTasks();
await flushTasks();
await writeFile(alternatePath, Buffer.from('current-render'));
await (
await database.prepare(
'UPDATE managed_images SET storage_key=? WHERE id=?',
)
).run(alternateKey, imageId);
first.clear();
await waiting;
const result = await replacement;
expect(result).toMatchObject({ id: imageId, storage_key: alternateKey });
expect(await readFile(alternatePath, 'utf8')).toBe('second-render');
expect(await readFile(oldPath, 'utf8')).toBe('old-render');
second.clear();
});
});
+172
View File
@@ -0,0 +1,172 @@
import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
import path from 'node:path';
export type MediaFileSnapshot = {
exists: boolean;
bytes: Buffer | null;
};
/** Captures a media file exactly so a later transaction failure can restore it. */
export async function captureMediaFile(
filePath: string,
): Promise<MediaFileSnapshot> {
try {
return { exists: true, bytes: await readFile(filePath) };
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT')
return { exists: false, bytes: null };
throw error;
}
}
/** Restores a snapshot captured by captureMediaFile. */
export async function restoreMediaFile(
filePath: string,
snapshot: MediaFileSnapshot,
): Promise<void> {
if (snapshot.exists) {
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, snapshot.bytes ?? Buffer.alloc(0), {
flag: 'w',
mode: 0o600,
});
return;
}
await unlink(filePath).catch((error: NodeJS.ErrnoException) => {
if (error.code !== 'ENOENT') throw error;
});
}
type Compensation = () => void | Promise<void>;
export type ReleaseMediaPathLock = () => void;
/**
* A process-local queue for each storage path. The queue promise represents
* the next holder, so releasing an earlier holder wakes only the next waiter.
*/
const mediaPathQueues = new Map<string, Promise<void>>();
export async function acquireMediaPathLock(
filePath: string,
): Promise<ReleaseMediaPathLock> {
const key = path.resolve(filePath);
const previous = mediaPathQueues.get(key) ?? Promise.resolve();
let releaseNext!: () => void;
const next = new Promise<void>((resolve) => {
releaseNext = resolve;
});
mediaPathQueues.set(key, next);
await previous;
let released = false;
return () => {
if (released) return;
released = true;
if (mediaPathQueues.get(key) === next) mediaPathQueues.delete(key);
releaseNext();
};
}
/**
* Per-operation LIFO filesystem rollback state. A journal is deliberately
* passed through the save call graph rather than stored in ambient state.
*/
export class MediaMutationJournal {
private compensations: Compensation[] = [];
private committed = false;
private rolledBack = false;
private rollbackPromise: Promise<void> | null = null;
private pathLocks = new Map<string, Promise<ReleaseMediaPathLock>>();
private locksReleased = false;
/**
* Acquires a path for this journal. Repeated requests for the same path are
* idempotent, including while the first request is still waiting in queue.
*/
async lockPath(filePath: string): Promise<void> {
if (this.committed)
throw new Error('Cannot register media compensation after commit.');
if (this.rolledBack)
throw new Error('Cannot register media compensation after rollback.');
const key = path.resolve(filePath);
let lock = this.pathLocks.get(key);
if (!lock) {
lock = acquireMediaPathLock(key);
this.pathLocks.set(key, lock);
}
const release = await lock;
if (this.committed || this.rolledBack) {
release();
throw new Error(
this.committed
? 'Cannot register media compensation after commit.'
: 'Cannot register media compensation after rollback.',
);
}
}
register(compensation: Compensation): void {
if (this.committed)
throw new Error('Cannot register media compensation after commit.');
if (this.rolledBack)
throw new Error('Cannot register media compensation after rollback.');
this.compensations.push(compensation);
}
/** Releases all path locks after a successful operation. */
clear(): void {
if (this.rolledBack || this.committed) return;
this.compensations = [];
this.committed = true;
this.releasePathLocks();
}
commit(): void {
this.clear();
}
/**
* Runs each compensation once in reverse order. The original failure is
* included first when any compensation also fails, preserving both causes.
*/
async rollback(originalError?: unknown): Promise<void> {
if (this.committed) return;
if (this.rollbackPromise) return this.rollbackPromise;
this.rolledBack = true;
const compensations = this.compensations.splice(0).reverse();
this.rollbackPromise = this.runRollback(compensations, originalError);
return this.rollbackPromise;
}
private releasePathLocks(): void {
if (this.locksReleased) return;
this.locksReleased = true;
const locks = [...this.pathLocks.values()];
this.pathLocks.clear();
for (const lock of locks) void lock.then((release) => release());
}
private async runRollback(
compensations: Compensation[],
originalError: unknown,
): Promise<void> {
const failures: unknown[] = [];
try {
for (const compensation of compensations) {
try {
await compensation();
} catch (error) {
failures.push(error);
}
}
if (failures.length) {
const causes =
originalError === undefined ? failures : [originalError, ...failures];
throw new AggregateError(causes, 'Media rollback did not complete.');
}
} finally {
this.releasePathLocks();
}
}
}
+306
View File
@@ -0,0 +1,306 @@
import { connect, type Database } from '@tursodatabase/database';
import { readFile } from 'node:fs/promises';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { BuildMechanicRuntimes } from './build-mechanic-runtime';
import type { MechanicRuntimeSelection } from './mechanic-runtime-selection';
type HydratedCombatEffect = {
id: string;
definition: { id: string };
runtime: MechanicRuntimeSelection | null;
};
const mocks = vi.hoisted(() => ({
loadRuntimes: vi.fn(),
}));
vi.mock('./build-mechanic-runtime', () => ({
loadBuildMechanicRuntimes: mocks.loadRuntimes,
}));
import { loadSharedBuildSummaryCatalog } from './shared-build-summary';
const selected: MechanicRuntimeSelection = {
owner: { type: 'talent', id: 'source', scope: 'standard', hasTierII: true },
sourceId: 'source',
sourceFingerprint: 'a'.repeat(64),
revisionId: 'revision',
revisionNumber: 1,
simulationReadiness: 'requires_assumptions',
scenarioInputs: [
{
key: 'nearby-enemies',
label: 'Nearby enemies',
kind: 'number',
readiness: 'requires_assumption',
min: 1,
max: 4,
},
],
variants: [
{
key: 'base',
label: 'BASE',
program: {
id: 'mechanic-runtime:["source","base"]',
name: 'Selected',
scenarioInputs: [
{
key: 'nearby-enemies',
label: 'Nearby enemies',
kind: 'number',
readiness: 'requires_assumption',
min: 1,
max: 4,
},
],
clauses: [],
},
},
],
};
const runtimes: BuildMechanicRuntimes = {
talents: { 'talent-1': selected },
protocolTalents: { 'protocol-talent-1': selected },
skillChipTalents: { 'chip-talent-1': selected },
specializationAttachments: {
'attachment-1': selected,
'attachment-2': selected,
},
};
const applySchema = async (database: Database) => {
const schema = await readFile(
new URL('../../../database/schema.sql', import.meta.url),
'utf8',
);
for (const statement of schema
.split(';')
.map((value) => value.trim())
.filter(Boolean)) {
await database.exec(statement);
}
};
const seedSpecializationAttachments = async (database: Database) => {
await database.exec(`
INSERT INTO specializations(id,name,created_at,updated_at) VALUES
('specialization-parent','Parent specialization','now','now'),
('specialization-cross','Cross specialization','now','now');
INSERT INTO specialization_focuses(
id,specialization_id,name,primary_attribute,display_order,created_at,updated_at
) VALUES('focus-parent','specialization-parent','Parent focus','Firepower',0,'now','now');
INSERT INTO specialization_tree_nodes(
id,focus_id,name,description_template,row_number,column_number,max_rank,created_at,updated_at
) VALUES('node-parent','focus-parent','Parent node','',1,1,1,'now','now');
INSERT INTO specialization_tree_node_ranks(node_id,rank,description)
VALUES('node-parent',1,'');
INSERT INTO specialization_combat_effects(
id,specialization_id,name,description_template,created_at,updated_at
) VALUES
('effect-valid-first','specialization-parent','Valid first','', 'now','now'),
('effect-valid-second','specialization-parent','Valid second','', 'now','now'),
('effect-cross','specialization-cross','Cross specialization effect','', 'now','now');
INSERT INTO specialization_tree_rank_combat_effects(
id,node_id,rank,combat_effect_id,effect_order,trigger_type,trigger_count,
trigger_window_seconds,duration_seconds,max_stacks
) VALUES
('attachment-valid-first','node-parent',1,'effect-valid-first',1,
'enemy_hits_within_window',1,1,1,1),
('attachment-valid-second','node-parent',1,'effect-valid-second',2,
'enemy_hits_within_window',1,1,1,1),
('attachment-cross','node-parent',1,'effect-cross',3,
'enemy_hits_within_window',1,1,1,1);
`);
};
function catalogDatabase() {
const prepare = vi.fn(async (sql: string) => ({
all: vi.fn(async () => {
if (sql.includes('FROM talents '))
return [{ id: 'talent-1', name: 'Talent' }];
if (sql.includes('FROM skill_chip_sets'))
return [
{
id: 'chip-set',
name: 'Chip',
specialization_ability_id: 'ability',
},
];
if (sql.includes('FROM skill_chip_talents'))
return [
{
id: 'chip-talent-1',
skill_chip_set_id: 'chip-set',
name: 'Chip Talent',
},
];
if (sql.includes('FROM os_protocols'))
return [
{
id: 'protocol-1',
name: 'Protocol',
quality: 'High-end',
primary_attribute_type: 'Health',
},
];
if (sql.includes('FROM os_protocol_talents'))
return [
{
id: 'protocol-talent-1',
protocol_id: 'protocol-1',
name: 'Protocol Talent',
},
];
if (sql.includes('FROM specializations '))
return [{ id: 'specialization-1', name: 'Spec' }];
if (sql.includes('FROM specialization_focuses'))
return [
{
id: 'focus-1',
specialization_id: 'specialization-1',
display_order: 1,
name: 'Focus',
},
];
if (sql.includes('FROM specialization_tree_nodes'))
return [
{
id: 'node-1',
focus_id: 'focus-1',
row_number: 1,
column_number: 1,
},
];
if (sql.includes('FROM specialization_tree_node_ranks'))
return [{ node_id: 'node-1', rank: 1 }];
if (sql.includes('FROM specialization_tree_rank_combat_effects'))
return [
{
id: 'attachment-1',
node_id: 'node-1',
rank: 1,
combat_effect_id: 'reusable-effect',
effect_order: 1,
definition_name: 'First effect',
},
{
id: 'attachment-2',
node_id: 'node-1',
rank: 1,
combat_effect_id: 'reusable-effect',
effect_order: 2,
definition_name: 'Second effect',
},
];
return [];
}),
}));
return { prepare } as unknown as Database;
}
describe('shared Build summary mechanic hydration', () => {
beforeEach(() => {
mocks.loadRuntimes.mockReset().mockResolvedValue(runtimes);
});
it('uses the shared selected-runtime capability for all owners and active attachment order', async () => {
const database = catalogDatabase();
const catalog = await loadSharedBuildSummaryCatalog(database);
expect(catalog.mechanics).toBe(runtimes);
expect(mocks.loadRuntimes).toHaveBeenCalledWith(database, {
talentIds: ['talent-1'],
protocolTalentIds: ['protocol-talent-1'],
skillChipTalentIds: ['chip-talent-1'],
specializationAttachmentIds: ['attachment-1', 'attachment-2'],
});
expect(catalog.talents[0]?.runtime).toBe(selected);
expect(catalog.chipSets[0]?.talent?.runtime).toBe(selected);
expect(catalog.protocols[0]?.talent?.runtime).toBe(selected);
const effects = catalog.specializations[0]?.focuses[0]?.nodes[0]?.ranks[0]
?.combatEffects as
| Array<{
id: string;
runtime: MechanicRuntimeSelection | null;
definition: { id: string };
}>
| undefined;
expect(effects?.map((effect) => effect.id)).toEqual([
'attachment-1',
'attachment-2',
]);
expect(effects?.map((effect) => effect.runtime)).toEqual([
selected,
selected,
]);
expect(
effects?.every((effect) => effect.definition.id === 'reusable-effect'),
).toBe(true);
expect(
(database.prepare as ReturnType<typeof vi.fn>).mock.calls.some(([sql]) =>
String(sql).includes('ORDER BY specialization.id,focus.display_order'),
),
).toBe(true);
});
});
describe('shared Build specialization combat-effect ownership', () => {
let database: Database;
beforeEach(async () => {
database = await connect(':memory:');
await database.exec('PRAGMA foreign_keys=ON');
await applySchema(database);
await seedSpecializationAttachments(database);
});
afterEach(async () => {
await database.close();
});
it('hydrates only same-specialization effects in attachment order and does not select corrupt attachment runtimes', async () => {
const firstRuntime = { ...selected, sourceId: 'first-runtime' };
const secondRuntime = { ...selected, sourceId: 'second-runtime' };
mocks.loadRuntimes.mockReset().mockResolvedValue({
talents: {},
protocolTalents: {},
skillChipTalents: {},
specializationAttachments: {
'attachment-valid-first': firstRuntime,
'attachment-valid-second': secondRuntime,
},
} satisfies BuildMechanicRuntimes);
const catalog = await loadSharedBuildSummaryCatalog(database);
expect(mocks.loadRuntimes).toHaveBeenCalledWith(database, {
talentIds: [],
protocolTalentIds: [],
skillChipTalentIds: [],
specializationAttachmentIds: [
'attachment-valid-first',
'attachment-valid-second',
],
});
const parentSpecialization = catalog.specializations.find(
(specialization: { id: string }) =>
specialization.id === 'specialization-parent',
);
const combatEffects = (parentSpecialization?.focuses[0]?.nodes[0]?.ranks[0]
?.combatEffects ?? []) as HydratedCombatEffect[];
expect(combatEffects.map((effect) => effect.id)).toEqual([
'attachment-valid-first',
'attachment-valid-second',
]);
expect(combatEffects.map((effect) => effect.definition.id)).toEqual([
'effect-valid-first',
'effect-valid-second',
]);
expect(combatEffects.map((effect) => effect.runtime)).toEqual([
firstRuntime,
secondRuntime,
]);
});
});
+48
View File
@@ -1,6 +1,7 @@
import type { Database } from '@tursodatabase/database';
import { attachWeaponCategoryBonuses } from '$lib/weapons/category-bonuses';
import { hydrateSkillChipProfileRows } from '$lib/skill-chips/profiles';
import { loadBuildMechanicRuntimes } from './build-mechanic-runtime';
export async function loadSharedBuildSummaryCatalog(database: Database) {
const [
@@ -37,6 +38,7 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
specializationNodes,
specializationRanks,
specializationRankEffects,
specializationRankCombatEffects,
] = await Promise.all([
(
await database.prepare(
@@ -172,9 +174,35 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
'SELECT * FROM specialization_tree_rank_effects ORDER BY node_id,rank,effect_order',
)
).all(),
(
await database.prepare(`SELECT attachment.*,effect.name AS definition_name
FROM specialization_tree_rank_combat_effects attachment
JOIN specialization_tree_node_ranks rank
ON rank.node_id=attachment.node_id AND rank.rank=attachment.rank
JOIN specialization_tree_nodes node ON node.id=attachment.node_id AND node.active=1
JOIN specialization_focuses focus ON focus.id=node.focus_id AND focus.active=1
JOIN specializations specialization
ON specialization.id=focus.specialization_id AND specialization.active=1
JOIN specialization_combat_effects effect
ON effect.id=attachment.combat_effect_id
AND effect.specialization_id=focus.specialization_id
AND effect.active=1
ORDER BY specialization.id,focus.display_order,focus.name COLLATE NOCASE,
node.row_number,node.column_number,attachment.rank,
attachment.effect_order,attachment.id`)
).all(),
]);
const mechanics = await loadBuildMechanicRuntimes(database, {
talentIds: talents.map((talent) => String(talent.id)),
protocolTalentIds: protocolTalents.map((talent) => String(talent.id)),
skillChipTalentIds: chipTalents.map((talent) => String(talent.id)),
specializationAttachmentIds: specializationRankCombatEffects.map((effect) =>
String(effect.id),
),
});
return {
mechanics,
attributes,
gear,
gearAugments,
@@ -187,6 +215,7 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
weapons: attachWeaponCategoryBonuses(weapons, weaponBonuses),
talents: talents.map((talent) => ({
...talent,
runtime: mechanics.talents[String(talent.id)] ?? null,
template:
talentTemplates.find((item) => item.talent_id === talent.id)
?.template ?? '',
@@ -231,6 +260,7 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
talent: talent
? {
...talent,
runtime: mechanics.skillChipTalents[String(talent.id)] ?? null,
effects: chipTalentEffects.filter(
(effect) => effect.talent_id === talent.id,
),
@@ -260,6 +290,7 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
talent: talent
? {
...talent,
runtime: mechanics.protocolTalents[String(talent.id)] ?? null,
effects: protocolTalentEffects.filter(
(effect) => effect.talent_id === talent.id,
),
@@ -298,6 +329,23 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
effect.node_id === node.id &&
Number(effect.rank) === Number(rank.rank),
),
combatEffects: specializationRankCombatEffects
.filter(
(effect) =>
effect.node_id === node.id &&
Number(effect.rank) === Number(rank.rank),
)
.map((effect) => ({
...effect,
definition: {
id: String(effect.combat_effect_id),
name: String(effect.definition_name),
},
runtime:
mechanics.specializationAttachments[
String(effect.id)
] ?? null,
})),
})),
})),
})),
+253 -5
View File
@@ -4,6 +4,7 @@ import { createDraft } from '$lib/builder/loadout';
const mocks = vi.hoisted(() => ({
row: null as Record<string, unknown> | null,
loadCatalog: vi.fn(),
summarizeLoadout: vi.fn(),
}));
vi.mock('$lib/server/database', () => ({
@@ -14,12 +15,101 @@ vi.mock('$lib/server/database', () => ({
})),
})),
}));
vi.mock('$lib/builder/loadout-summary', () => ({
summarizeLoadout: mocks.summarizeLoadout,
}));
vi.mock('$lib/server/shared-build-summary', () => ({
loadSharedBuildSummaryCatalog: mocks.loadCatalog,
}));
import { loadSharedBuildProjection } from './shared-build';
const selectedRuntime = {
owner: { type: 'talent', id: 'talent-1', scope: 'standard' },
sourceId: 'talent-source',
sourceFingerprint: 'f'.repeat(64),
revisionId: 'talent-revision',
revisionNumber: 4,
simulationReadiness: 'ready',
scenarioInputs: [
{ key: 'nearby-enemies', label: 'Nearby enemies', kind: 'number' },
],
variants: [
{
key: 'I',
label: 'Tier I',
program: {
id: 'mechanic-runtime:["talent-source","I"]',
name: 'Selected mechanic',
scenarioInputs: [
{ key: 'nearby-enemies', label: 'Nearby enemies', kind: 'number' },
],
pools: [{ key: 'stacks', label: 'Stacks', min: 0, max: 10 }],
clauses: [
{
key: 'on-kill',
outcomes: [
{
key: 'damage',
variants: { I: { value: 15 } },
},
],
},
],
},
},
],
};
const mechanics = {
talents: { 'talent-1': selectedRuntime },
protocolTalents: { 'protocol-talent-1': selectedRuntime },
skillChipTalents: { 'chip-talent-1': selectedRuntime },
specializationAttachments: { 'attachment-1': selectedRuntime },
};
const mechanicInternalKeys = new Set([
'mechanics',
'runtime',
'sourceId',
'sourceFingerprint',
'revisionId',
'revisionNumber',
'simulationReadiness',
'variants',
'key',
'program',
'scenarioInputs',
'pools',
'clauses',
'outcomes',
'programHash',
'mechanicContractVersion',
'selectedRevisionId',
'source_id',
'source_fingerprint',
'revision_id',
'revision_number',
'simulation_readiness',
'scenario_inputs',
'program_hash',
'mechanic_contract_version',
'selected_revision_id',
]);
function serializedMechanicInternals(value: unknown, path = '$'): string[] {
if (Array.isArray(value))
return value.flatMap((entry, index) =>
serializedMechanicInternals(entry, `${path}[${index}]`),
);
if (!value || typeof value !== 'object') return [];
return Object.entries(value).flatMap(([key, entry]) => [
...(mechanicInternalKeys.has(key) ? [`${path}.${key}`] : []),
...serializedMechanicInternals(entry, `${path}.${key}`),
]);
}
function buildRow(visibility: 'private' | 'unlisted' | 'public') {
return {
id: 'build-id',
@@ -37,16 +127,101 @@ function buildRow(visibility: 'private' | 'unlisted' | 'public') {
describe('shared Build access policy', () => {
beforeEach(() => {
mocks.row = null;
mocks.summarizeLoadout.mockReset().mockReturnValue({
stats: [{ id: 'firepower', value: 42, sourceCount: 3 }],
abilityStats: [
{
abilityId: 'ability-1',
stats: [
{
id: 'skill-power',
value: 18,
sourceCount: 1,
sources: [
{
source: 'Skill Chip 1 primary',
chipIndex: 0,
chipSetId: 'chip-set-1',
value: 18,
},
],
},
],
},
],
activeBonuses: [
{
name: 'Medic',
pieces: 2,
count: 3,
statId: 'skill-power',
value: 10,
runtime: selectedRuntime,
},
],
equippedTalents: [
{
key: 'gear:Mask:talent-1',
id: 'talent-1',
runtime: selectedRuntime,
tier: 2,
tierLabel: 'TIER II',
source: 'Mask',
description: 'Restore 15% Armor.',
custom: {
name: 'Medic Protocol',
runtime: selectedRuntime,
},
},
],
runtime: selectedRuntime,
});
mocks.loadCatalog.mockReset().mockResolvedValue({
attributes: [],
attributes: [
{
id: 'firepower',
name: 'Firepower',
unit: '%',
category: 'Core',
runtime: selectedRuntime,
},
],
gear: [],
gearAugments: [],
brands: [],
weapons: [],
talents: [],
chipSets: [],
protocols: [],
specializations: [],
talents: [
{
id: 'talent-1',
name: 'Field Medic',
cooldown_seconds: 12,
runtime: selectedRuntime,
tiers: [{ runtime: selectedRuntime }],
},
],
chipSets: [
{ id: 'chip-set-1', talent: { runtime: selectedRuntime } },
],
protocols: [
{ id: 'protocol-1', talent: { runtime: selectedRuntime } },
],
specializations: [
{
id: 'specialization-1',
focuses: [
{
nodes: [
{
ranks: [
{ combatEffects: [{ runtime: selectedRuntime }] },
],
},
],
},
],
},
],
mechanics,
});
});
@@ -59,9 +234,82 @@ describe('shared Build access policy', () => {
});
expect(result?.build.visibility).toBe(visibility);
expect(result?.build.owner).toBe(false);
expect(result).not.toHaveProperty('mechanics');
},
);
it('serializes rendered Build fields without selected Mechanic Programs', async () => {
mocks.row = buildRow('public');
const result = await loadSharedBuildProjection('field-medic-12345678', {
kind: 'public-preview',
});
expect(mocks.summarizeLoadout).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
mechanics,
talents: [expect.objectContaining({ runtime: selectedRuntime })],
}),
);
expect(result?.summary).toEqual({
stats: [{ id: 'firepower', value: 42, sourceCount: 3 }],
abilityStats: [
{
abilityId: 'ability-1',
stats: [
{
id: 'skill-power',
value: 18,
sourceCount: 1,
sources: [
{
source: 'Skill Chip 1 primary',
chipIndex: 0,
chipSetId: 'chip-set-1',
value: 18,
},
],
},
],
},
],
activeBonuses: [
{
name: 'Medic',
pieces: 2,
count: 3,
statId: 'skill-power',
value: 10,
},
],
equippedTalents: [
{
id: 'talent-1',
tier: 2,
tierLabel: 'TIER II',
source: 'Mask',
description: 'Restore 15% Armor.',
custom: { name: 'Medic Protocol' },
},
],
});
expect(result?.attributes).toEqual([
{
id: 'firepower',
name: 'Firepower',
unit: '%',
category: 'Core',
},
]);
expect(result?.talents).toEqual([
{ id: 'talent-1', name: 'Field Medic', cooldown_seconds: 12 },
]);
expect(serializedMechanicInternals(JSON.parse(JSON.stringify(result)))).toEqual(
[],
);
});
it('does not expose private Builds to public previews or other viewers', async () => {
mocks.row = buildRow('private');
await expect(
+86 -9
View File
@@ -1,6 +1,9 @@
import { avatarUrl } from '$lib/avatar';
import { parseLoadoutDraft } from '$lib/builder/loadout';
import { summarizeLoadout } from '$lib/builder/loadout-summary';
import {
summarizeLoadout,
type LoadoutSummary,
} from '$lib/builder/loadout-summary';
import { getDatabase } from '$lib/server/database';
import { loadSharedBuildSummaryCatalog } from '$lib/server/shared-build-summary';
@@ -8,6 +11,63 @@ export type SharedBuildAccessPolicy =
{ kind: 'viewer-page'; viewerId: string | null } | { kind: 'public-preview' };
type BuildVisibility = 'public' | 'unlisted' | 'private';
function publicFields<
Value extends Record<string, unknown>,
Field extends keyof Value,
>(value: Value, fields: readonly Field[]): Value {
return Object.fromEntries(
fields.map((field) => [field, value[field]]),
) as Value;
}
/**
* Builds are calculated with mechanic runtimes, but shared pages render only
* known values and materialized descriptions. Keep this as a whitelist so a
* future catalog or summary field cannot accidentally serialize a program.
*/
function publicLoadoutSummary(summary: LoadoutSummary): LoadoutSummary {
return {
stats: summary.stats.map((stat) =>
publicFields(stat, ['id', 'value', 'sourceCount']),
),
abilityStats: summary.abilityStats.map((ability) => ({
abilityId: ability.abilityId,
stats: ability.stats.map((stat) => ({
id: stat.id,
value: stat.value,
sourceCount: stat.sourceCount,
sources: stat.sources.map((source) => ({
source: source.source,
chipIndex: source.chipIndex,
chipSetId: source.chipSetId,
value: source.value,
})),
})),
})),
activeBonuses: summary.activeBonuses.map((bonus) =>
publicFields(bonus, ['name', 'pieces', 'count', 'statId', 'value']),
),
equippedTalents: summary.equippedTalents.map((talent) => ({
id: talent.id,
tier: talent.tier,
...(talent.tierLabel === undefined
? {}
: { tierLabel: talent.tierLabel }),
...(talent.isExotic === undefined
? {}
: { isExotic: talent.isExotic }),
...(talent.abilityId === undefined
? {}
: { abilityId: talent.abilityId }),
source: talent.source,
description: talent.description,
...(talent.custom?.name === undefined
? {}
: { custom: { name: talent.custom.name } }),
})),
} as LoadoutSummary;
}
export async function loadSharedBuildProjection(
slug: string,
access: SharedBuildAccessPolicy,
@@ -92,13 +152,30 @@ export async function loadSharedBuildProjection(
updatedAt: String(build.updated_at),
},
loadout,
gear,
weapons,
chips,
protocols,
brands,
summary,
attributes: summaryCatalog.attributes,
talents: summaryCatalog.talents,
gear: gear.map((item) =>
publicFields(item, ['id', 'name', 'slot', 'tier', 'base_armor_min']),
),
weapons: weapons.map((item) =>
publicFields(item, [
'id',
'name',
'category',
'rpm',
'magazine',
'reload_speed',
'damage_type',
'exotic_talent_id',
]),
),
chips: chips.map((item) => publicFields(item, ['id', 'name'])),
protocols: protocols.map((item) => publicFields(item, ['id', 'name'])),
brands: brands.map((item) => publicFields(item, ['id', 'name'])),
summary: publicLoadoutSummary(summary),
attributes: summaryCatalog.attributes.map((item) =>
publicFields(item, ['id', 'name', 'unit', 'category']),
),
talents: summaryCatalog.talents.map((item) =>
publicFields(item, ['id', 'name', 'cooldown_seconds']),
),
};
}

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