From 65a4987cadb2b9dfe542e8e92f5761aadb7e5665 Mon Sep 17 00:00:00 2001 From: JB Date: Wed, 29 Jul 2026 20:56:22 -0400 Subject: [PATCH] Adding skills & isac talent updates --- .agents/skills/create-plan/SKILL.md | 74 ++ .agents/skills/frontend-design/LICENSE | 21 + .agents/skills/frontend-design/SKILL.md | 49 ++ .../assets/vanilla-starter/index.html | 131 ++++ .../assets/vanilla-starter/script.js | 13 + .../assets/vanilla-starter/style.css | 425 ++++++++++++ .../references/aesthetic-playbook.md | 43 ++ .../references/implementation-patterns.md | 50 ++ .../skills/import-talent-screenshot/SKILL.md | 42 ++ .../agents/openai.yaml | 4 + .../references/shd-talent-entry.md | 58 ++ .../skills/import-weapon-screenshot/SKILL.md | 47 ++ .../agents/openai.yaml | 4 + .../references/shd-weapon-entry.md | 48 ++ .agents/skills/stop-slop/CHANGELOG.md | 24 + .agents/skills/stop-slop/LICENSE | 21 + .agents/skills/stop-slop/README.md | 62 ++ .agents/skills/stop-slop/SKILL.md | 68 ++ .../skills/stop-slop/references/examples.md | 59 ++ .../skills/stop-slop/references/phrases.md | 128 ++++ .../skills/stop-slop/references/structures.md | 134 ++++ .agents/skills/webapp-testing/LICENSE.txt | 202 ++++++ .agents/skills/webapp-testing/SKILL.md | 96 +++ .../examples/console_logging.py | 35 + .../examples/element_discovery.py | 40 ++ .../examples/static_html_automation.py | 33 + .../webapp-testing/scripts/with_server.py | 106 +++ .gitignore | 3 +- AGENTS.md | 12 + database/schema.sql | 1 + docs/DESIGN_SYSTEM.md | 62 +- docs/TALENT_MECHANIC_RULES.md | 114 ++++ docs/architecture.md | 48 +- scripts/db-migrate.mjs | 230 ++++++- src/app.css | 41 +- src/lib/builder/loadout-combat.test.ts | 460 +++++++++++++ src/lib/builder/loadout-combat.ts | 434 ++++++++---- src/lib/calculations/mechanic-editor.test.ts | 163 +++++ src/lib/calculations/mechanic-editor.ts | 198 +++++- .../calculations/mechanic-persistence.test.ts | 54 ++ src/lib/calculations/mechanic-persistence.ts | 58 +- src/lib/calculations/mechanic-recipes.test.ts | 11 +- src/lib/calculations/mechanic-recipes.ts | 23 +- src/lib/calculations/mechanic.test.ts | 635 +++++++++++++++++- src/lib/calculations/mechanic.ts | 321 ++++++++- src/lib/calculations/skill.ts | 4 +- .../calculations/talent-description.test.ts | 40 ++ src/lib/calculations/talent-description.ts | 4 +- .../components/SearchableAssignment.svelte | 17 +- .../components/builder/LoadoutMatrix.svelte | 29 +- .../builder/SkillCalculationResult.svelte | 22 +- .../builder/WeaponCalculationResult.svelte | 26 +- .../isac/MechanicProgramEditor.svelte | 269 +++++++- .../ai/mechanic-authoring-prompt.test.ts | 31 +- .../server/ai/mechanic-authoring-prompt.ts | 16 +- src/lib/server/conditional-effects.test.ts | 43 +- src/lib/server/conditional-effects.ts | 59 +- src/lib/server/request-origin.test.ts | 34 + src/lib/server/request-origin.ts | 17 + .../api/isac/calculation-assistant/+server.ts | 3 +- .../api/isac/mechanic-assistant/+server.ts | 3 +- .../api/isac/mechanic-gap-report/+server.ts | 3 +- src/routes/builder/+page.svelte | 19 +- .../isac/mechanic-reference/+page.svelte | 11 +- src/routes/isac/talents/+page.server.ts | 153 ++++- src/routes/isac/talents/+page.svelte | 76 ++- src/routes/isac/weapons/+page.server.ts | 211 ++---- src/routes/isac/weapons/+page.svelte | 80 +-- 68 files changed, 5486 insertions(+), 569 deletions(-) create mode 100644 .agents/skills/create-plan/SKILL.md create mode 100644 .agents/skills/frontend-design/LICENSE create mode 100644 .agents/skills/frontend-design/SKILL.md create mode 100644 .agents/skills/frontend-design/assets/vanilla-starter/index.html create mode 100644 .agents/skills/frontend-design/assets/vanilla-starter/script.js create mode 100644 .agents/skills/frontend-design/assets/vanilla-starter/style.css create mode 100644 .agents/skills/frontend-design/references/aesthetic-playbook.md create mode 100644 .agents/skills/frontend-design/references/implementation-patterns.md create mode 100644 .agents/skills/import-talent-screenshot/SKILL.md create mode 100644 .agents/skills/import-talent-screenshot/agents/openai.yaml create mode 100644 .agents/skills/import-talent-screenshot/references/shd-talent-entry.md create mode 100644 .agents/skills/import-weapon-screenshot/SKILL.md create mode 100644 .agents/skills/import-weapon-screenshot/agents/openai.yaml create mode 100644 .agents/skills/import-weapon-screenshot/references/shd-weapon-entry.md create mode 100644 .agents/skills/stop-slop/CHANGELOG.md create mode 100644 .agents/skills/stop-slop/LICENSE create mode 100644 .agents/skills/stop-slop/README.md create mode 100644 .agents/skills/stop-slop/SKILL.md create mode 100644 .agents/skills/stop-slop/references/examples.md create mode 100644 .agents/skills/stop-slop/references/phrases.md create mode 100644 .agents/skills/stop-slop/references/structures.md create mode 100644 .agents/skills/webapp-testing/LICENSE.txt create mode 100644 .agents/skills/webapp-testing/SKILL.md create mode 100644 .agents/skills/webapp-testing/examples/console_logging.py create mode 100644 .agents/skills/webapp-testing/examples/element_discovery.py create mode 100644 .agents/skills/webapp-testing/examples/static_html_automation.py create mode 100644 .agents/skills/webapp-testing/scripts/with_server.py create mode 100644 docs/TALENT_MECHANIC_RULES.md create mode 100644 src/lib/server/request-origin.test.ts create mode 100644 src/lib/server/request-origin.ts diff --git a/.agents/skills/create-plan/SKILL.md b/.agents/skills/create-plan/SKILL.md new file mode 100644 index 0000000..59a659d --- /dev/null +++ b/.agents/skills/create-plan/SKILL.md @@ -0,0 +1,74 @@ +--- +name: create-plan +description: Create a concise plan. Use when a user explicitly asks for a plan related to a coding task. +metadata: + short-description: Create a plan +--- + +# Create Plan + +## Goal + +Turn a user prompt into a **single, actionable plan** delivered in the final assistant message. + +## Minimal workflow + +Throughout the entire workflow, operate in read-only mode. Do not write or update files. + +1. **Scan context quickly** + - Read `README.md` and any obvious docs (`docs/`, `CONTRIBUTING.md`, `ARCHITECTURE.md`). + - Skim relevant files (the ones most likely touched). + - Identify constraints (language, frameworks, CI/test commands, deployment shape). + +2. **Ask follow-ups only if blocking** + - Ask **at most 1–2 questions**. + - Only ask if you cannot responsibly plan without the answer; prefer multiple-choice. + - If unsure but not blocked, make a reasonable assumption and proceed. + +3. **Create a plan using the template below** + - Start with **1 short paragraph** describing the intent and approach. + - Clearly call out what is **in scope** and what is **not in scope** in short. + - Then provide a **small checklist** of action items (default 6–10 items). + - Each checklist item should be a concrete action and, when helpful, mention files/commands. + - **Make items atomic and ordered**: discovery → changes → tests → rollout. + - **Verb-first**: “Add…”, “Refactor…”, “Verify…”, “Ship…”. + - Include at least one item for **tests/validation** and one for **edge cases/risk** when applicable. + - If there are unknowns, include a tiny **Open questions** section (max 3). + +4. **Do not preface the plan with meta explanations; output only the plan as per template** + +## Plan template (follow exactly) + +```markdown +# Plan + +<1–3 sentences: what we’re doing, why, and the high-level approach.> + +## Scope +- In: +- Out: + +## Action items +[ ] +[ ] +[ ] +[ ] +[ ] +[ ] + +## Open questions +- +- +- +``` + +## Checklist item guidance +Good checklist items: +- Point to likely files/modules: src/..., app/..., services/... +- Name concrete validation: “Run npm test”, “Add unit tests for X” +- Include safe rollout when relevant: feature flag, migration plan, rollback note + +Avoid: +- Vague steps (“handle backend”, “do auth”) +- Too many micro-steps +- Writing code snippets (keep the plan implementation-agnostic) diff --git a/.agents/skills/frontend-design/LICENSE b/.agents/skills/frontend-design/LICENSE new file mode 100644 index 0000000..5d7d47b --- /dev/null +++ b/.agents/skills/frontend-design/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Vipul Gupta + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/.agents/skills/frontend-design/SKILL.md b/.agents/skills/frontend-design/SKILL.md new file mode 100644 index 0000000..1c175da --- /dev/null +++ b/.agents/skills/frontend-design/SKILL.md @@ -0,0 +1,49 @@ +--- +name: frontend-design +description: Create distinctive, production-grade frontend interfaces with high design quality. Use when building or refining web components, pages, or apps (HTML/CSS/JS/React) with bold aesthetics, purposeful layouts, motion, and accessibility. Avoid generic AI-looking output. +--- + +# Frontend Design + +## Overview + +Build memorable, functional interfaces with a clear aesthetic direction (no generic templates). Focus on bold typography, cohesive palettes, purposeful motion, semantic structure, and accessibility. + +## Quick Start +- Collect intent: purpose, audience, platform constraints, framework, delivery format (HTML/React/Vue). +- Choose an aesthetic direction and signature move (stripe, glow, grain, magazine stack). See `references/aesthetic-playbook.md`. +- Define tokens: fonts, palette, shadows, radii, spacing scale. Load fonts early. +- Plan layout: hero + supporting sections, data/timeline rails, cards; pick grid system and motion plan. +- Build semantic markup, wire CSS variables, add motion with staggered reveals, then run accessibility and responsive checks. + +## Workflow + +### 1) Define intent and aesthetic +- Ask for: target users, tone (brutalist, editorial, neon, soft craft, industrial, etc.), content types (cards, forms, charts), performance constraints. +- Pick a single memorable gesture and stick to it. Avoid mixed-font chaos unless the style calls for it. +- If the user is vague, propose 2–3 aesthetic directions from `references/aesthetic-playbook.md` and confirm. + +### 2) Plan structure and tokens +- Establish CSS variables for palette, shadows, radii, spacing, and motion curve. Keep accent count to 1–2. +- Choose font pairing (display + body, optional mono). Provide fallbacks after custom fonts. +- Map sections and micro-interactions. For vanilla builds, start from semantic HTML; for React, break into composable pieces (Hero, FeatureList, Timeline, CTA Rail). +- Reference `references/implementation-patterns.md` for layout, motion, accessibility, and responsive patterns. + +### 3) Build and animate +- Markup: semantic headings, skip links, ARIA labels for interactive elements, descriptive alt text. +- Layout: prefer CSS Grid for macro and Flexbox for clusters. Break the grid deliberately (asymmetry, overlaps) while preserving readability. +- Styling: use custom backgrounds (gradient meshes, grain, hatch) to avoid flat color; design buttons and cards with intentional borders/shadows. +- Motion: orchestrate a few meaningful animations (page-load stagger, hover magnetic lift, scroll reveals). Guard with `prefers-reduced-motion`. +- Data-driven UI: define arrays of content (cards, metrics, steps) to keep components DRY and easy to restyle. + +### 4) Polish and QA +- Responsive: collapse grids, maintain padding, ensure CTAs remain prominent on mobile, swap hover cues for focus/active styles. +- Accessibility: one `h1`, logical heading order, focus styles, contrast AA+, labeled inputs, `aria-live` where status changes. +- Performance: avoid heavy shadows on mobile, compress/limit assets, use font-display swap, prefer CSS animations over JS-heavy effects. + +## References +- `references/aesthetic-playbook.md`: Pick strong aesthetic directions with fonts, colors, layout, and motion cues. +- `references/implementation-patterns.md`: HTML/React patterns, tokens, motion recipes, responsive and accessibility guidance. + +## Assets +- `assets/vanilla-starter/`: Ready-to-run vanilla HTML/CSS/JS concept (avant studio theme) with gradients, grain, staggered reveals, skip link, and accessible structure. Copy and retheme by updating CSS variables, fonts, and content. diff --git a/.agents/skills/frontend-design/assets/vanilla-starter/index.html b/.agents/skills/frontend-design/assets/vanilla-starter/index.html new file mode 100644 index 0000000..953fb4a --- /dev/null +++ b/.agents/skills/frontend-design/assets/vanilla-starter/index.html @@ -0,0 +1,131 @@ + + + + + + Avant Studio + + + + + + + +
+
+ + + +
+ +
+
+
+

Design lab

+

Striking, production-ready frontends without the generic glaze.

+

We craft visceral digital spaces that move fast, feel tactile, and stay memorable. Built with intent, tuned for launch.

+
+ + +
+
+
42 shipped sites
+
95% lighthouse perf
+
0 generic templates
+
+
+
+
Now launching
+
+

Neon Observatory

+

A retro-futurist control room with live telemetry, aurora gradients, and glitch hover states.

+
CSS Grid
+
Framer Motion
+
ARIA-first
+
+
+ ETA: 2 weeks + Visual QA locked +
+
+
+
+ +
+
+

01 / Intent

+

Pick a bold narrative, define the signature move, lock the palette.

+

We interrogate purpose, audience, and constraints. Each build commits to one unmistakable gesture—stripe, glow, or grain—and sticks to it.

+
+
+

02 / Build

+

Real code: semantic HTML, layered CSS, purposeful motion.

+

CSS variables for theming, responsive grids, progressive enhancement. Animations gated by prefers-reduced-motion.

+
+
+

03 / Polish

+

Accessibility, performance, and tactile interactions.

+

Contrast checks, keyboard navigation, tuned shadows, intentional hover states. We avoid boilerplate aesthetics at all costs.

+
+
+ +
+
+

Field notes

+

Recent drops

+
+
+
+
+
+
+
+
+

2024

+

Aerial atlas

+

Editorial map portal with split-screen narrative and scroll-synced chapters.

+
+
+

2023

+

Glassfoundry

+

Industrial dashboard with translucent panels, border glows, and live system vitals.

+
+
+

2022

+

Islands

+

Pastel travel companion with hand-drawn dividers, slow float animations, and tactile cards.

+
+
+
+
+ +
+ + +
+ + + + diff --git a/.agents/skills/frontend-design/assets/vanilla-starter/script.js b/.agents/skills/frontend-design/assets/vanilla-starter/script.js new file mode 100644 index 0000000..1c6affb --- /dev/null +++ b/.agents/skills/frontend-design/assets/vanilla-starter/script.js @@ -0,0 +1,13 @@ +const observer = new IntersectionObserver( + entries => { + entries.forEach(entry => { + if (entry.isIntersecting) { + entry.target.classList.add('visible'); + observer.unobserve(entry.target); + } + }); + }, + { threshold: 0.2 } +); + +document.querySelectorAll('.reveal').forEach(el => observer.observe(el)); diff --git a/.agents/skills/frontend-design/assets/vanilla-starter/style.css b/.agents/skills/frontend-design/assets/vanilla-starter/style.css new file mode 100644 index 0000000..ec83815 --- /dev/null +++ b/.agents/skills/frontend-design/assets/vanilla-starter/style.css @@ -0,0 +1,425 @@ +@import url('https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@500;700&family=Newsreader:opsz,wght@6..72,400;600&display=swap'); + +:root { + --bg: #0b0c10; + --fg: #f6f1e6; + --muted: #a7acba; + --accent: #ff6b4a; + --accent-2: #6b9bff; + --card: rgba(255, 255, 255, 0.03); + --stroke: rgba(255, 255, 255, 0.12); + --radius: 18px; + --shadow: 0 18px 44px rgba(0, 0, 0, 0.35); + --ease: cubic-bezier(0.22, 1, 0.36, 1); +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: 'Newsreader', 'Times New Roman', serif; + color: var(--fg); + background: var(--bg); + min-height: 100vh; + line-height: 1.7; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + background-image: + radial-gradient(1200px at 15% 20%, rgba(107, 155, 255, 0.25), transparent 50%), + radial-gradient(1400px at 80% 0%, rgba(255, 107, 74, 0.2), transparent 50%), + linear-gradient(135deg, rgba(255, 255, 255, 0.03), transparent); +} + +.grain { + position: fixed; + inset: 0; + pointer-events: none; + background: + radial-gradient(circle at 20% 20%, rgba(255, 255, 255, 0.04), transparent 35%), + repeating-linear-gradient(90deg, rgba(255, 255, 255, 0.02), rgba(255, 255, 255, 0.02) 1px, transparent 1px, transparent 3px); + mix-blend-mode: soft-light; + opacity: 0.7; + z-index: 1; +} + +a { + color: inherit; + text-decoration: none; +} + +.skip-link { + position: absolute; + left: -999px; + top: 0; + padding: 12px 16px; + background: var(--accent); + color: #0b0c10; + border-radius: 8px; + font-weight: 700; + z-index: 999; +} + +.skip-link:focus { + left: 16px; + top: 16px; +} + +.top-bar { + position: sticky; + top: 0; + z-index: 10; + display: flex; + align-items: center; + justify-content: space-between; + padding: 18px 36px; + backdrop-filter: blur(10px); + background: rgba(11, 12, 16, 0.75); + border-bottom: 1px solid var(--stroke); +} + +.logo { + font-family: 'Bricolage Grotesque', 'Arial Black', sans-serif; + font-weight: 700; + letter-spacing: -0.02em; +} + +nav { + display: flex; + gap: 18px; + font-size: 0.95rem; +} + +nav a { + padding: 8px 12px; + border-radius: 999px; + border: 1px solid transparent; + transition: border-color 220ms var(--ease), color 220ms var(--ease); +} + +nav a:hover, +nav a:focus-visible { + border-color: var(--stroke); + color: var(--accent); +} + +button { + font-family: 'Bricolage Grotesque', sans-serif; + font-weight: 700; + border: none; + cursor: pointer; +} + +.cta { + padding: 12px 18px; + border-radius: 999px; + background: linear-gradient(120deg, var(--accent), var(--accent-2)); + color: #0b0c10; + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.25); + transition: transform 200ms var(--ease), box-shadow 200ms var(--ease); +} + +.cta:hover { + transform: translateY(-3px); + box-shadow: 0 16px 44px rgba(0, 0, 0, 0.35); +} + +.hero { + display: grid; + grid-template-columns: 1.2fr 0.8fr; + gap: 32px; + padding: 96px 36px 48px; + position: relative; +} + +.hero-copy h1 { + font-family: 'Bricolage Grotesque', sans-serif; + font-size: clamp(2.4rem, 5vw, 3.6rem); + line-height: 1.1; + letter-spacing: -0.03em; + margin: 12px 0; +} + +.hero .lede { + max-width: 56ch; + color: var(--muted); + font-size: 1.05rem; +} + +.eyebrow { + text-transform: uppercase; + letter-spacing: 0.18em; + font-size: 0.78rem; + color: var(--accent); + margin: 0 0 8px; +} + +.actions { + display: flex; + gap: 12px; + margin: 24px 0 18px; +} + +.ghost { + padding: 12px 16px; + border-radius: 999px; + background: transparent; + border: 1.5px solid var(--stroke); + color: var(--fg); + transition: border-color 220ms var(--ease), color 220ms var(--ease), transform 220ms var(--ease); +} + +.ghost:hover { + border-color: var(--accent); + color: var(--accent); + transform: translateY(-2px); +} + +.stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 12px; + margin-top: 20px; + color: var(--muted); + font-size: 0.95rem; +} + +.stats span { + display: block; + font-family: 'Bricolage Grotesque', sans-serif; + font-size: 1.5rem; + color: var(--fg); +} + +.hero-card { + background: var(--card); + border: 1px solid var(--stroke); + border-radius: 22px; + padding: 24px; + box-shadow: var(--shadow); + position: relative; + overflow: hidden; +} + +.hero-card::after { + content: ''; + position: absolute; + inset: -30%; + background: radial-gradient(circle at 30% 30%, rgba(255, 255, 255, 0.04), transparent 45%); + transform: rotate(8deg); + opacity: 0.8; +} + +.hero-card .tag { + display: inline-block; + padding: 8px 12px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.07); + color: var(--accent); + font-size: 0.85rem; + position: relative; + z-index: 2; +} + +.hero-card .card-body { + position: relative; + z-index: 2; +} + +.hero-card h2 { + font-family: 'Bricolage Grotesque', sans-serif; + margin: 12px 0 8px; +} + +.hero-card p { + color: var(--muted); + margin-bottom: 12px; +} + +.pill { + display: inline-block; + padding: 8px 12px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid var(--stroke); + margin: 4px 6px 4px 0; + font-size: 0.85rem; +} + +.line { + margin: 16px 0; + height: 1px; + background: linear-gradient(90deg, transparent, var(--accent), transparent); +} + +.meta { + display: flex; + justify-content: space-between; + color: var(--muted); + font-size: 0.9rem; +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 18px; + padding: 48px 36px; +} + +.panel { + border: 1px solid var(--stroke); + border-radius: var(--radius); + padding: 22px; + background: rgba(255, 255, 255, 0.02); + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.22); +} + +.panel h3 { + margin: 6px 0 10px; + font-family: 'Bricolage Grotesque', sans-serif; +} + +.panel p { + color: var(--muted); +} + +.timeline { + padding: 24px 36px 80px; + border-top: 1px solid var(--stroke); +} + +.timeline-head h3 { + font-family: 'Bricolage Grotesque', sans-serif; + margin: 8px 0 16px; +} + +.timeline-rail { + position: relative; + height: 3px; + background: var(--stroke); + border-radius: 999px; + margin: 24px 0 32px; +} + +.timeline-rail .dot { + position: absolute; + top: -6px; + width: 15px; + height: 15px; + background: var(--accent); + border-radius: 50%; + box-shadow: 0 0 0 6px rgba(255, 107, 74, 0.15); +} + +.timeline-rail .dot:nth-child(1) { left: 8%; } +.timeline-rail .dot:nth-child(2) { left: 50%; } +.timeline-rail .dot:nth-child(3) { left: 88%; } + +.timeline-items { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 16px; +} + +.timeline-card { + border: 1px solid var(--stroke); + border-radius: var(--radius); + padding: 18px; + background: rgba(255, 255, 255, 0.02); + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.2); +} + +.timeline-card .label { + font-size: 0.9rem; + letter-spacing: 0.12em; + color: var(--accent); + text-transform: uppercase; + margin-bottom: 6px; +} + +.timeline-card h4 { + margin: 4px 0 8px; + font-family: 'Bricolage Grotesque', sans-serif; +} + +.timeline-card p { + color: var(--muted); +} + +.footer { + border-top: 1px solid var(--stroke); + padding: 28px 36px 48px; + position: relative; + overflow: hidden; +} + +.marquee { + display: grid; + grid-auto-flow: column; + gap: 16px; + font-family: 'Bricolage Grotesque', sans-serif; + text-transform: uppercase; + letter-spacing: 0.14em; + color: var(--muted); + animation: scroll 18s linear infinite; +} + +@keyframes scroll { + from { transform: translateX(0); } + to { transform: translateX(-50%); } +} + +.footer-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px; + margin-top: 24px; +} + +.footer-grid a { + display: block; + color: var(--fg); + text-decoration: underline; + margin-bottom: 4px; +} + +.reveal { + opacity: 0; + transform: translateY(22px); + transition: transform 320ms var(--ease), opacity 320ms var(--ease); +} + +.reveal.visible { + opacity: 1; + transform: translateY(0); +} + +@media (max-width: 960px) { + .hero { + grid-template-columns: 1fr; + padding-top: 72px; + } + + .top-bar { + position: sticky; + } + + nav { + display: none; + } + + .actions { + flex-direction: column; + align-items: flex-start; + } +} + +@media (prefers-reduced-motion: reduce) { + * { + animation: none !important; + transition: none !important; + } +} diff --git a/.agents/skills/frontend-design/references/aesthetic-playbook.md b/.agents/skills/frontend-design/references/aesthetic-playbook.md new file mode 100644 index 0000000..4cc48b1 --- /dev/null +++ b/.agents/skills/frontend-design/references/aesthetic-playbook.md @@ -0,0 +1,43 @@ +# Aesthetic Playbook + +Use these ready-to-go directions to avoid generic looks. Pick one and commit; swap fonts/colors as needed but keep the intent. + +## Brutalist Raw +- Fonts: Display `Bricolage Grotesque` or `Migra`; Body `IBM Plex Sans` or `Atkinson Hyperlegible`. +- Colors: Harsh contrast (charcoal/ivory) with one acidic accent (#ff5c39 or #f2ff49). Keep surfaces flat with thick borders. +- Layout: Grid-breaking blocks, oversized headings, visible rules, obvious gutters. Use all-caps labels and numeric tags. +- Motion: Snappy entrance (0.35s) with slight overshoot; hover states that invert colors or reveal underlines. +- Backgrounds: Checkerboard, dotted patterns, or single bold stripe angled across the hero. + +## Editorial / Magazine +- Fonts: Display `Canela`, `Fraunces`, or `Cormorant Garamond`; Body `Newsreader` or `Source Serif 4`; Accents `DM Mono`. +- Colors: Muted palettes (deep ink, warm off-white, burnt orange accents). Let photos bleed to the edge. +- Layout: Tall columns, asymmetrical whitespace, floating pull-quotes, generous line-height. Use hairline rules and small caps. +- Motion: Slow fades (0.6s) and subtle parallax on hero imagery. Stagger text reveals by 80–120ms. +- Backgrounds: Fine grain or paper texture overlay with low opacity. + +## Retro-Futuristic Neon +- Fonts: Display `Space Mono` or `Bungee`; Body `Sora` or `DM Sans`. +- Colors: Dark base (#05060e) with neon accents (#61f4de, #ff6ad5, #8af5ff). High glow shadows. +- Layout: Angled cards, pill buttons, floating ribbons, console-style stats. Use thin outlines and scanlines. +- Motion: Glitch keyframes, sweep highlights on hover, marquee strips for metadata. +- Backgrounds: Gradient mesh with cyan/magenta, layered with transparent grid lines or starfield dots. + +## Soft Craft / Pastel +- Fonts: Display `Clash Display` or `Epilogue`; Body `Manrope` or `Sen`. +- Colors: Warm pastel base (#f6ede3) with clay/terracotta (#d96b52) and sage (#8ca28f) accents. +- Layout: Rounded blobs, gentle shadows (20–30px blur), stacked cards with stitched borders, hand-drawn icons if available. +- Motion: Ease-in-out float (2–4s) on background shapes; hover lifts with shadow color shifts. +- Backgrounds: Irregular gradient blobs, subtle noise at 8–12% opacity to avoid flatness. + +## Industrial / Utilitarian +- Fonts: Display `Compacta` or `Archivo Black`; Body `Barlow` or `Work Sans`; Numerics `JetBrains Mono`. +- Colors: Cold neutrals (#101010, #e6e6e6) with hazard accents (#ffb400 or #2de1c2). Use muted teal for secondary. +- Layout: Rigid grids, labeled panels, visible coordinates and data tags, boxed callouts with corner ticks. +- Motion: Linear 0.25–0.35s transitions; sliding panels; progress bars with stripes. +- Backgrounds: Carbon fiber or diagonal hatch via CSS repeating-linear-gradients; add soft inner glow for depth. + +## How to Choose +1. Identify the emotional goal (edgy, luxe, calm, industrial, playful). +2. Pick fonts first, then colors that suit the fonts’ character. +3. Decide on a single signature move (checkerboard stripe, neon glow, magazine pull-quote). Everything else supports it. diff --git a/.agents/skills/frontend-design/references/implementation-patterns.md b/.agents/skills/frontend-design/references/implementation-patterns.md new file mode 100644 index 0000000..3f01e9c --- /dev/null +++ b/.agents/skills/frontend-design/references/implementation-patterns.md @@ -0,0 +1,50 @@ +# Implementation Patterns + +Use these patterns to move quickly while keeping the aesthetic sharp. Load only sections you need. + +## Vanilla HTML/CSS/JS Workflow +- Start with semantic structure: `header`, `main`, `section`, `article`, `footer`. Keep nav skip-links (`a.skip-nav` to `#main`). +- Define theme tokens up top: + ```css + :root { --bg: #0b0c10; --fg: #f7f2e9; --accent: #ff6b4a; --muted: #9aa1b5; --radius: 18px; --shadow: 0 18px 40px rgba(0,0,0,0.35); } + ``` +- Load fonts via `` or `@import`; set base styles on `body` (smooth scrolling, background texture, max width handling). +- Layout: use CSS Grid for macro layout, Flexbox for inline clusters. Mix asymmetry: stagger column widths (e.g., `grid-template-columns: 1.1fr 0.9fr`). +- Motion: prefer CSS keyframes and `transition: transform 320ms var(--ease)`; orchestrate with delays on child elements. Use `prefers-reduced-motion` guards. +- Interactivity: lean on small JS to toggle classes, e.g., IntersectionObserver for reveal animations. + +## React Patterns +- Create a `Theme` object for tokens and pass via context or props. Keep components focused (Hero, FeatureList, TestimonialRail). +- Use CSS Modules, styled-components, or Tailwind? If Tailwind is mandated, extend the config with custom fonts, gradients, and shadows; avoid defaults-only look. +- Animations: prefer Framer Motion for page-load + hover effects; stagger `variants` and keep durations <0.5s. +- Data-driven UI: define arrays for cards, metrics, timelines; map to components to keep markup DRY and easily themeable. +- Accessibility: forward refs for focusable elements; use `aria-live` for status, `aria-pressed` for toggles, and proper `alt` text for imagery. + +## Layout & Spacing +- Hero patterns: split-screen with oversized headline and vertical nav stripe; magazine stack with overlapping cards; timeline rail with labeled markers. +- Micro-layout: use 4/8 spacing scale; keep heading-to-body ratio ~1.6–1.8; limit max-width of prose to 68–76ch. +- Negative space vs density: if maximalist, constrain chaos inside frames; if minimal, increase margins and align to a visible grid. + +## Color, Texture, and Backgrounds +- Always define CSS variables for palette. Keep 1–2 primary + 1 accent. Add noise overlays to avoid sterile flatness: + ```css + .grain { position: fixed; inset: 0; pointer-events: none; background: radial-gradient(circle at 20% 20%, rgba(255,255,255,.04), transparent 40%), repeating-linear-gradient(90deg, rgba(255,255,255,.018), rgba(255,255,255,.018) 1px, transparent 1px, transparent 3px); mix-blend-mode: soft-light; opacity: 0.7; } + ``` +- Gradient mesh idea: layer multiple radial gradients with different sizes and opacities; rotate a pseudo-element slowly. +- Borders: try 1.5–2px strokes with `border-image` gradients for subtle sheen. + +## Motion Recipes +- Staggered reveal: apply `transform: translateY(20px); opacity: 0;` then add `.visible` to animate to neutral. +- Magnetic hover: on buttons/cards, use `transition: transform 220ms ease, box-shadow 220ms ease;` and slightly rotate/translate toward cursor. +- Hero load: fade-in background first, then text with 80ms steps, then accent lines sliding in from edges. + +## Responsive Guidance +- Collapse grids to single column below 900px; center align key text; maintain comfortable padding (min 18–22px). +- Replace hover-dependent cues with clear focus/active styles. Increase tap targets to 44px minimum. +- Test with viewport meta tag, clamp() for font sizes, and avoid fixed heights; use `minmax` grids. + +## QA Checklist +- Color contrast passes WCAG AA for text/background. Verify focus order and skip link. +- Assets load with sensible fallbacks (system font stack declared after customs). +- Animations respect `prefers-reduced-motion: reduce`. +- Verify semantics: one `h1`, landmarks defined, inputs labeled, buttons not masquerading as links. diff --git a/.agents/skills/import-talent-screenshot/SKILL.md b/.agents/skills/import-talent-screenshot/SKILL.md new file mode 100644 index 0000000..5637c41 --- /dev/null +++ b/.agents/skills/import-talent-screenshot/SKILL.md @@ -0,0 +1,42 @@ +--- +name: import-talent-screenshot +description: Extract a Division Resurgence talent from one or more screenshots, isolate its icon, reconcile Tier I and Tier II wording and values, create any missing mechanic prerequisites, and enter the reviewed talent through SHD.build ISAC. Use when the user supplies talent screenshots or asks to import, create, model, or enter a pictured talent. +--- + +# Import Talent Screenshot + +Create a complete, modeled talent from screenshots. Submit mutations to the authenticated ISAC server action so validation, permissions, relationships, icon handling, and audit logging remain intact. Prefer direct requests over operating the editor UI. + +## Workflow + +1. Inspect every supplied screenshot at original detail. +2. Extract the displayed title, description, highlighted values, thresholds, durations, stacks, and other mechanically meaningful wording. +3. Classify a title ending in `II` as Tier II. Classify the matching title without `II` as Tier I. Store one talent under the base title without the tier suffix. Do not strip a numeral that is genuinely part of the base name; ask when the screenshots do not establish the distinction. +4. Reconcile both tiers into one shared description template. Replace tier-varying values with numbered placeholders in reading order. Use `{duration}` and `{max_stacks}` only when the mechanic defines them. +5. Ask the user for exactly one required choice before entry: `Weapon Talent 1`, `Weapon Talent 2`, `Backpack`, or `Body Armor`. Do not infer this choice from the icon or talent wording. +6. Ask other questions only when missing evidence would materially change stored data or combat behavior. State the unresolved alternatives precisely. +7. Search the Talent registry for the same base name and category. Update only when the user explicitly requests an update; otherwise stop on a duplicate. +8. Search active attributes, supported mechanic recipes, runtime events, outcomes, scopes, resources, and state behavior before creating prerequisites. Reuse semantically identical records. +9. Implement missing mechanic functionality when the screenshot establishes behavior that the runtime cannot yet represent. Extend the shared mechanic types, validation, persistence, ISAC editor, runtime execution, pseudo-script, reference UI, schema migration, and focused tests as required. Preserve generic semantics; do not add talent-name checks or a one-off recipe when a reusable event, outcome, scope, resource, or state rule fits. Follow the repository's database-preservation procedure before applying a schema migration. +10. Create a missing attribute or other data requirement through its authenticated ISAC server action only when its name, unit, type, category, and calculation semantics are established by the screenshots or project data. Ask for any material field that cannot be derived safely. +11. Model the mechanic with the narrowest supported recipe. Prefer health-threshold bands for wording such as “while below N% Health.” Treat those bands as mutually exclusive strongest-matching ranges unless the source explicitly says effects stack. +12. Set each numbered outcome's Tier I and Tier II values, phase, scope, basis, and attribute. Mark the mechanic `READY` only after the full behavior is executable and tested. Use `ASSUMPTIONS` or `CAPTURE ONLY` only when source evidence is incomplete, not merely because runtime functionality is missing. +13. Isolate the icon from the screenshot. Preserve the pictured silhouette rather than redesigning it. Remove text and UI background, center it on a square transparent canvas, retain antialiased edges, and validate it at small display size. Use the image-generation editing workflow only when a faithful crop and local cleanup are insufficient. +14. Build one `multipart/form-data` payload matching the current talent form contract, including the prepared icon and serialized mechanic program. Submit it directly to the authenticated `/isac/talents?/saveTalent` server action. Use browser-side `fetch` when the browser holds the authenticated session. Do not open, populate, click, or submit the talent editor. Do not use the editor's file input to stage the icon. Make the icon available to the browser-side request without interacting with the editor, then attach it to the `FormData` as a `File`. Inspect the current route source before relying on a field name or action path. +15. Read the saved registry row back. Verify the name, category, icon, both rendered tier descriptions, modeled state, thresholds, values, and any newly implemented runtime behavior. Confirm the successful `game_data` audit event when audit access is available. + +## Guardrails + +- Never invent values hidden by cropping, blur, overlays, or missing tiers. +- Preserve strict wording: “below 25%” means `< 25`, not `<= 25`. +- Do not add tier values together. For ordered health thresholds, select the value for the current non-overlapping band unless the source states that effects accumulate. +- Do not create a near-duplicate attribute because of capitalization, spacing, or a synonymous label. +- Do not expose credentials, session data, or personal data in screenshots or audit checks. +- Never write talent, tier, effect, mechanic, icon, or audit rows directly to the database. Never call lower-level persistence helpers to bypass the route action. +- Do not manually create an audit event. A successful server action must create it. +- Never use the talent editor UI for an import. If direct submission cannot preserve the authenticated session, file upload, or current form contract, stop and report the blocker. +- Preview extracted fields and planned mechanics before the first external mutation when the user has not already approved entry. + +## Project Reference + +Read [references/shd-talent-entry.md](references/shd-talent-entry.md) before entering a talent. Re-check the referenced source files when the application schema or editor changes. diff --git a/.agents/skills/import-talent-screenshot/agents/openai.yaml b/.agents/skills/import-talent-screenshot/agents/openai.yaml new file mode 100644 index 0000000..6a9b29a --- /dev/null +++ b/.agents/skills/import-talent-screenshot/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Import Talent Screenshot" + short_description: "Create modeled talents from game screenshots" + default_prompt: "Use $import-talent-screenshot to extract and enter a talent from my screenshots." diff --git a/.agents/skills/import-talent-screenshot/references/shd-talent-entry.md b/.agents/skills/import-talent-screenshot/references/shd-talent-entry.md new file mode 100644 index 0000000..83a0d76 --- /dev/null +++ b/.agents/skills/import-talent-screenshot/references/shd-talent-entry.md @@ -0,0 +1,58 @@ +# SHD Talent Entry Reference + +## Canonical surfaces + +- Talent editor: `src/routes/isac/talents/+page.svelte` +- Talent action and validation: `src/routes/isac/talents/+page.server.ts` +- Mechanic editor: `src/lib/components/isac/MechanicProgramEditor.svelte` +- Mechanic draft types: `src/lib/calculations/mechanic-editor.ts` +- Mechanic runtime and validation: `src/lib/calculations/mechanic.ts` +- Mechanic persistence: `src/lib/server/conditional-effects.ts` +- Supported recipes: `src/lib/calculations/mechanic-recipes.ts` +- Attribute registry and actions: `src/routes/isac/attributes/` +- Icon validation and persistence: `src/lib/server/game-data-icons.ts` + +Treat these files as the current authority. Inspect them before acting when fields or behavior may have changed. + +When a pictured mechanic is not representable, add the smallest reusable runtime capability and its validation, persistence, editor controls, migration, reference entry, and tests before entering the talent. A missing runtime feature is an implementation task, not a reason by itself to leave a talent under assumptions. + +## Request path + +Prefer a direct authenticated request to the SvelteKit talent action over editor interaction: + +- Load or inspect `/isac/talents` to obtain current registry data and confirm the authenticated session. +- Construct `FormData` from the current contract in `+page.svelte` and `+page.server.ts`. +- Include the icon as the `icon` file part and the complete mechanic program as serialized `conditionalRule`. +- Submit one `POST` request to `/isac/talents?/saveTalent` with an accepted SvelteKit action response type. +- Parse the action result, then query the registry and audit surfaces for readback. + +Do not treat the action path as a public, version-stable REST API. Re-check the route implementation when it changes. Do not insert into the embedded database or call persistence utilities directly. + +## Talent form + +The user-facing categories are: + +- `Weapon Talent 1` +- `Weapon Talent 2` +- `Backpack` +- `Body Armor` + +Talent descriptions use one shared template. Number tier-varying outcomes as `{1}`, `{2}`, and so on in reading order. The action derives Tier I and Tier II descriptions from each outcome's variant values. + +## Mechanic entry + +Use the `health_threshold_bands` recipe for non-stacking current-health ranges. Enter thresholds from strongest to weakest in the editor and assign one outcome to each band. Verify exact boundary behavior after saving. + +For the Spirited example: + +- Base name: `Spirited` +- Tier I: 9% below 75% Health; 12% below 50%; 15% below 25% +- Tier II: 12.60% below 75% Health; 16.80% below 50%; 21% below 25% +- Shared template: `Grants +{1} Damage Reduction while below 75% Health. Grants +{2} Damage Reduction while below 50% Health. Grants +{3} Damage Reduction while below 25% Health.` +- Attribute: `Damage Reduction`, unit `%` +- Scope: self +- Phase: while active +- Basis: flat percentage points +- Bands: `[50,75)`, `[25,50)`, and `[0,25)`; no effect at or above 75% + +Confirm that `Damage Reduction` already exists before creating it. Confirm the category/slot with the user before saving. diff --git a/.agents/skills/import-weapon-screenshot/SKILL.md b/.agents/skills/import-weapon-screenshot/SKILL.md new file mode 100644 index 0000000..3ca6814 --- /dev/null +++ b/.agents/skills/import-weapon-screenshot/SKILL.md @@ -0,0 +1,47 @@ +--- +name: import-weapon-screenshot +description: Extract a Division Resurgence weapon from screenshots, match pictured talent icons to existing SHD.build talents, prepare its weapon image, collect missing tier and augmentation values, and enter the reviewed weapon through ISAC. Use when the user supplies weapon-detail, blueprint, crafting, talent-grid, or weapon-stat screenshots and asks to import, create, model, or enter the weapon. +--- + +# Import Weapon Screenshot + +Create a complete weapon record from screenshots without guessing hidden configuration. Submit mutations to the authenticated ISAC server action so validation, relationships, and audit logging remain intact. Prefer direct requests over operating the editor UI. + +## Workflow + +1. Inspect every screenshot at original detail. Extract the weapon name, category, native tier, damage type, fixed handling values, visible damage range, talent icons, and augmentation evidence. +2. Search the Weapon registry by normalized name. Stop on a duplicate unless the user explicitly requests an update. Treat named variants such as `First Wave` as distinct weapons. +3. Reconcile screenshot labels with project vocabulary: + - `Piercing Damage` → `Pierce` + - `Blast Damage` → `Blast` + - `Shredding Damage` → `Shredding` + - Preserve the displayed native tier. +4. Ask directly for missing damage-range numbers for every tier at or above the native tier. Do not request extra screenshots when typed minimum and maximum values are faster. +5. Read each Talent 1 and Talent 2 grid as a complete, unordered set. These weapon screenshots show every available talent twice: once for Tier I and once for Tier II. Group identical icons across the entire grid, resolve each two-icon group against active talents in the corresponding ISAC slot, and store the matching talent once. Compare silhouettes and internal details only. Tier variants may appear in different rows, separated by other talents, or in any order; never use adjacency, sequence, or grid position to pair them. +6. For an ambiguous or unmatched talent icon, identify its slot and screenshot position and request a close-up showing that talent's icon, name, and description. After the user supplies it, invoke `import-talent-screenshot` to create and model the missing talent, then resume the weapon import. +7. Never infer talents that are cropped below the screenshot. Ask for another talent-grid screenshot when the visible grid may continue. +8. Ask the user to type Tactical Augmentation 01 as one or two `Attribute = value` pairs. Reuse exact active attributes and stop on ambiguous names. +9. Treat weapon mod slots as unknown unless the user supplies explicit evidence. Save the weapon with no mod-slot configuration; expect ISAC to display `CONFIG REQUIRED`. Do not borrow slots from a related weapon. +10. Extract the weapon render from the screenshot. Preserve the pictured model, attachments, materials, and proportions. Remove UI, cursor, text, and background; center the weapon on a transparent canvas with useful padding. Use the image-generation editing workflow only when a faithful crop and local cleanup are insufficient. +11. Search for every prerequisite before creating it. If the current schema or runtime cannot represent established weapon behavior, implement the smallest reusable capability, including validation, persistence, editor support, migrations, reference material, and tests. Follow database-preservation rules before applying schema changes. +12. Preview the extracted fields, resolved talents, unresolved evidence, and planned ISAC payload before the first mutation unless the user has already approved entry. +13. Build one `multipart/form-data` payload matching the current weapon form contract and submit it directly to the authenticated `/isac/weapons?/save` server action. Use browser-side `fetch` when the authenticated session is held by the browser; do not click through the editor merely to populate fields. Inspect the current route source before relying on a stale field name or action path. +14. Read the saved registry row back. Verify identity, category, native tier, damage type, fixed attributes, all required tier ranges, talent pools, augmentation effects, image, and expected configuration state. Confirm the `game_data` audit event when audit access is available. + +## Guardrails + +- Do not infer tier damage ranges by scaling another tier or related weapon. +- Do not match a talent from resemblance alone when more than one active icon is plausible. +- Every talent in these weapon talent grids appears twice, once per tier. Deduplicate identical icons across the complete slot grid into one weapon talent option, regardless of their order or position. +- Do not assume the screenshot shows the complete talent list when the panel continues beyond the frame. +- Do not infer weapon mod slots. +- Do not create a near-duplicate talent or attribute because of spelling, capitalization, or synonymous labels. +- Preserve numeric precision exactly as displayed or typed. +- Never write weapon, tier, talent-option, augmentation, mod-slot, image, or audit rows directly to the database. Never call lower-level persistence helpers to bypass the route action. +- Do not manually create an audit event. A successful server action must create it. +- Use the UI only when direct submission cannot preserve an authenticated session, file upload, or current form contract. +- Keep authored database data intact and preserve unrelated worktree changes. + +## Project Reference + +Read [references/shd-weapon-entry.md](references/shd-weapon-entry.md) before entering a weapon. Re-check the referenced source files when the editor or schema changes. diff --git a/.agents/skills/import-weapon-screenshot/agents/openai.yaml b/.agents/skills/import-weapon-screenshot/agents/openai.yaml new file mode 100644 index 0000000..a607da9 --- /dev/null +++ b/.agents/skills/import-weapon-screenshot/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Import Weapon Screenshot" + short_description: "Import pictured weapons into SHD.build" + default_prompt: "Use $import-weapon-screenshot to extract and enter this pictured weapon." diff --git a/.agents/skills/import-weapon-screenshot/references/shd-weapon-entry.md b/.agents/skills/import-weapon-screenshot/references/shd-weapon-entry.md new file mode 100644 index 0000000..61f3a5e --- /dev/null +++ b/.agents/skills/import-weapon-screenshot/references/shd-weapon-entry.md @@ -0,0 +1,48 @@ +# SHD Weapon Entry Reference + +## Canonical surfaces + +- Weapon editor and client payload: `src/routes/isac/weapons/+page.svelte` +- Weapon action, validation, persistence, and audit snapshot: `src/routes/isac/weapons/+page.server.ts` +- Weapon categories: `src/lib/weapons/category-bonuses.ts` +- Weapon mod-slot rules: `src/lib/weapons/mod-slots.ts` +- Talent editor and classification: `src/routes/isac/talents/` +- Talent screenshot workflow: `../../import-talent-screenshot/SKILL.md` +- Icon validation and persistence: `src/lib/server/game-data-icons.ts` + +Inspect these files before entry when fields or behavior may have changed. + +## Request path + +Prefer a direct authenticated request to the SvelteKit weapon action over editor interaction: + +- Load or inspect `/isac/weapons` to obtain current talent, attribute, category, and registry data and confirm the authenticated session. +- Construct `FormData` from the current contract in `+page.svelte` and `+page.server.ts`. +- Preserve repeated relationship fields such as talent IDs and serialize structured fields such as mod slots exactly as the action expects. +- Submit one `POST` request to `/isac/weapons?/save` with an accepted SvelteKit action response type. +- Parse the action result, then query the registry and audit surfaces for readback. + +Do not treat the action path as a public, version-stable REST API. Re-check the route implementation when it changes. Do not insert into the embedded database or call persistence utilities directly. + +## Required standard-weapon data + +- Unique name +- Category: `Shotgun`, `Rifle`, `MMR`, `AR`, `LMG`, or `SMG` +- Native tier: `T0`, `T1`, or `T2` +- Damage type: `Blast`, `Pierce`, or `Shredding` +- RPM, magazine size, reload seconds, accuracy, and stability +- Damage minimum and maximum for every tier at or above the native tier +- At least one existing Talent 1 and Talent 2 option +- One or two unique Tactical Augmentation 01 attribute/value pairs + +Weapon mod slots are optional evidence. An otherwise valid weapon with unknown slots may be saved and will remain `CONFIG REQUIRED`. + +## Talent icon matching + +Query the current weapon or talent registry data to obtain active Talent 1 and Talent 2 icon sets. Match within the correct slot only. Use a visual contact sheet when the set is large; opening the weapon editor is unnecessary. + +These weapon talent grids display every talent twice: once for Tier I and once for Tier II. Treat each slot grid as an unordered set, group identical icons across the complete grid, and store each matching talent ID once. Tier variants may occupy different rows, have other talents between them, or appear in either order. Pair by icon artwork only. If a two-icon group has no unique registry match, request a close-up with its name and description and run `import-talent-screenshot` before continuing. + +## Image preparation + +Use the supplied weapon render as the source of truth. Remove the surrounding game interface and background without redesigning the weapon. Save the final asset in the workspace, include it in the server-action payload when the current weapon contract supports an image, and verify the resulting image URL. diff --git a/.agents/skills/stop-slop/CHANGELOG.md b/.agents/skills/stop-slop/CHANGELOG.md new file mode 100644 index 0000000..64d9f54 --- /dev/null +++ b/.agents/skills/stop-slop/CHANGELOG.md @@ -0,0 +1,24 @@ +# Changelog + +## 2026-01-13 + +### Added + +**Phrases (references/phrases.md)** +- Throat-clearing: "Here's what I find interesting", "Here's the problem though" +- Performative emphasis: "creeps in", "I promise", "They exist, I promise" +- Telling instead of showing: "This is genuinely hard", "This is what leadership actually looks like" + +**Structures (references/structures.md)** +- Binary contrasts: "Not X. But Y.", "It's not this. It's that.", "stops being X and starts being Y" +- Rhythm patterns: staccato fragmentation, dashes for dramatic pause, hedging as reassurance +- Word patterns: absolute words (always, never, everyone, etc.), AI-overused intensifiers (deeply, truly, fundamentally, inherently, simply, literally, inevitably) + +## 2026-01-12 + +- Restructured skill following Claude Code best practices (PR #1) +- Split into SKILL.md and references/ folder + +## 2025-01-12 + +- Initial release diff --git a/.agents/skills/stop-slop/LICENSE b/.agents/skills/stop-slop/LICENSE new file mode 100644 index 0000000..e0ede9b --- /dev/null +++ b/.agents/skills/stop-slop/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Hardik Pandya + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/.agents/skills/stop-slop/README.md b/.agents/skills/stop-slop/README.md new file mode 100644 index 0000000..3c98256 --- /dev/null +++ b/.agents/skills/stop-slop/README.md @@ -0,0 +1,62 @@ +# Stop Slop + +A skill for removing AI tells from prose. + +G-Yg4RVbIAAhVxW + +## What this is + +AI writing has patterns. Predictable phrases, structures, rhythms. This skill teaches Claude (or any LLM) to catch and remove them. + +## Skill Structure + +``` +stop-slop/ +├── SKILL.md # Core instructions +├── references/ +│ ├── phrases.md # Phrases to remove +│ ├── structures.md # Structural patterns to avoid +│ └── examples.md # Before/after transformations +├── README.md +└── LICENSE +``` + +## Quick start + +**Claude Code:** Add this folder as a skill. + +**Claude Projects:** Upload `SKILL.md` and reference files to project knowledge. + +**Custom instructions:** Copy core rules from `SKILL.md`. + +**API calls:** Include `SKILL.md` in your system prompt. Reference files load on demand. + +## What it catches + +**Banned phrases** - Throat-clearing openers, emphasis crutches, business jargon, all adverbs, vague declaratives, meta-commentary. See `references/phrases.md`. + +**Structural clichés** - Binary contrasts, negative listings, dramatic fragmentation, rhetorical setups, false agency, narrator-from-a-distance voice, passive voice. See `references/structures.md`. + +**Sentence-level rules** - No Wh- sentence starters, no em dashes, no staccato fragmentation, no lazy extremes, active voice required. + +## Scoring + +Rate 1-10 on each dimension: + +| Dimension | Question | +|-----------|----------| +| Directness | Statements or announcements? | +| Rhythm | Varied or metronomic? | +| Trust | Respects reader intelligence? | +| Authenticity | Sounds human? | +| Density | Anything cuttable? | + +Below 35/50: revise. + +## Author + +[Hardik Pandya](https://hvpandya.com) + +## License + +MIT. Use freely, share widely. diff --git a/.agents/skills/stop-slop/SKILL.md b/.agents/skills/stop-slop/SKILL.md new file mode 100644 index 0000000..83f2032 --- /dev/null +++ b/.agents/skills/stop-slop/SKILL.md @@ -0,0 +1,68 @@ +--- +name: stop-slop +description: Remove AI writing patterns from prose. Use when drafting, editing, or reviewing text to eliminate predictable AI tells. +metadata: + trigger: Writing prose, editing drafts, reviewing content for AI patterns + author: Hardik Pandya (https://hvpandya.com) +--- + +# Stop Slop + +Eliminate predictable AI writing patterns from prose. + +## Core Rules + +1. **Cut filler phrases.** Remove throat-clearing openers, emphasis crutches, and all adverbs. See [references/phrases.md](references/phrases.md). + +2. **Break formulaic structures.** Avoid binary contrasts, negative listings, dramatic fragmentation, rhetorical setups, false agency. See [references/structures.md](references/structures.md). + +3. **Use active voice.** Every sentence needs a human subject doing something. No passive constructions. No inanimate objects performing human actions ("the complaint becomes a fix"). + +4. **Be specific.** No vague declaratives ("The reasons are structural"). Name the specific thing. No lazy extremes ("every," "always," "never") doing vague work. + +5. **Put the reader in the room.** No narrator-from-a-distance voice. "You" beats "People." Specifics beat abstractions. + +6. **Vary rhythm.** Mix sentence lengths. Two items beat three. End paragraphs differently. No em dashes. + +7. **Trust readers.** State facts directly. Skip softening, justification, hand-holding. + +8. **Cut quotables.** If it sounds like a pull-quote, rewrite it. + +## Quick Checks + +Before delivering prose: + +- Any adverbs? Kill them. +- Any passive voice? Find the actor, make them the subject. +- Inanimate thing doing a human verb ("the decision emerges")? Name the person. +- Sentence starts with a Wh- word? Restructure it. +- Any "here's what/this/that" throat-clearing? Cut to the point. +- Any "not X, it's Y" contrasts? State Y directly. +- Three consecutive sentences match length? Break one. +- Paragraph ends with punchy one-liner? Vary it. +- Em-dash anywhere? Remove it. +- Vague declarative ("The implications are significant")? Name the specific implication. +- Narrator-from-a-distance ("Nobody designed this")? Put the reader in the scene. +- Meta-joiners ("The rest of this essay...")? Delete. Let the essay move. + +## Scoring + +Rate 1-10 on each dimension: + +| Dimension | Question | +|-----------|----------| +| Directness | Statements or announcements? | +| Rhythm | Varied or metronomic? | +| Trust | Respects reader intelligence? | +| Authenticity | Sounds human? | +| Density | Anything cuttable? | + +Below 35/50: revise. + +## Examples + +See [references/examples.md](references/examples.md) for before/after transformations. + +## License + +MIT diff --git a/.agents/skills/stop-slop/references/examples.md b/.agents/skills/stop-slop/references/examples.md new file mode 100644 index 0000000..0376b6d --- /dev/null +++ b/.agents/skills/stop-slop/references/examples.md @@ -0,0 +1,59 @@ +# Before/After Examples + +## Example 1: Throat-Clearing + Binary Contrast + +**Before:** +> "Here's the thing: building products is hard. Not because the technology is complex. Because people are complex. Let that sink in." + +**After:** +> "Building products is hard. Technology is manageable. People aren't." + +**Changes:** Removed opener, binary contrast structure, and emphasis crutch. Direct statements. + +--- + +## Example 2: Filler + Unnecessary Reassurance + +**Before:** +> "It turns out that most teams struggle with alignment. The uncomfortable truth is that nobody wants to admit they're confused. And that's okay." + +**After:** +> "Teams struggle with alignment. Nobody admits confusion." + +**Changes:** Cut hedging ("most"), removed throat-clearing phrases, deleted permission-granting ending. + +--- + +## Example 3: Business Jargon Stack + +**Before:** +> "In today's fast-paced landscape, we need to lean into discomfort and navigate uncertainty with clarity. This matters because your competition isn't waiting." + +**After:** +> "Move faster. Your competition is." + +**Changes:** Eliminated jargon entirely. Core message in six words. + +--- + +## Example 4: Dramatic Fragmentation + +**Before:** +> "Speed. Quality. Cost. You can only pick two. That's it. That's the tradeoff." + +**After:** +> "Speed, quality, cost—pick two." + +**Changes:** Single sentence. No performative emphasis. + +--- + +## Example 5: Rhetorical Setup + +**Before:** +> "What if I told you that the best teams don't optimize for productivity? Here's what I mean: they optimize for learning. Think about it." + +**After:** +> "The best teams optimize for learning, not productivity." + +**Changes:** Direct claim. No rhetorical scaffolding. diff --git a/.agents/skills/stop-slop/references/phrases.md b/.agents/skills/stop-slop/references/phrases.md new file mode 100644 index 0000000..f9234fe --- /dev/null +++ b/.agents/skills/stop-slop/references/phrases.md @@ -0,0 +1,128 @@ +# Phrases to Remove + +## Throat-Clearing Openers + +Remove these announcement phrases. State the content directly. + +- "Here's the thing:" +- "Here's what [X]" +- "Here's this [X]" +- "Here's that [X]" +- "Here's why [X]" +- "The uncomfortable truth is" +- "It turns out" +- "The real [X] is" +- "Let me be clear" +- "The truth is," +- "I'll say it again:" +- "I'm going to be honest" +- "Can we talk about" +- "Here's what I find interesting" +- "Here's the problem though" + +Any "here's what/this/that" construction is throat-clearing before the point. Cut it and state the point. + +## Emphasis Crutches + +These add no meaning. Delete them. + +- "Full stop." / "Period." +- "Let that sink in." +- "This matters because" +- "Make no mistake" +- "Here's why that matters" + +## Business Jargon + +Replace with plain language. + +| Avoid | Use instead | +|-------|-------------| +| Navigate (challenges) | Handle, address | +| Unpack (analysis) | Explain, examine | +| Lean into | Accept, embrace | +| Landscape (context) | Situation, field | +| Game-changer | Significant, important | +| Double down | Commit, increase | +| Deep dive | Analysis, examination | +| Take a step back | Reconsider | +| Moving forward | Next, from now | +| Circle back | Return to, revisit | +| On the same page | Aligned, agreed | + +## Adverbs + +Kill all adverbs. No -ly words. No softeners, no intensifiers, no hedges. + +Specific offenders: + +- "really" +- "just" +- "literally" +- "genuinely" +- "honestly" +- "simply" +- "actually" +- "deeply" +- "truly" +- "fundamentally" +- "inherently" +- "inevitably" +- "interestingly" +- "importantly" +- "crucially" + +Also cut these filler phrases: + +- "At its core" +- "In today's [X]" +- "It's worth noting" +- "At the end of the day" +- "When it comes to" +- "In a world where" +- "The reality is" + +## Meta-Commentary + +Remove self-referential asides. The essay should move, not announce its own structure. + +- "Hint:" +- "Plot twist:" / "Spoiler:" +- "You already know this, but" +- "But that's another post" +- "X is a feature, not a bug" +- "Dressed up as" +- "The rest of this essay explains..." +- "Let me walk you through..." +- "In this section, we'll..." +- "As we'll see..." +- "I want to explore..." + +## Performative Emphasis + +False intimacy or manufactured sincerity: + +- "creeps in" +- "I promise" +- "They exist, I promise" + +## Telling Instead of Showing + +Announcing difficulty or significance rather than demonstrating it: + +- "This is genuinely hard" +- "This is what leadership actually looks like" +- "This is what X actually looks like" +- "actually matters" + +## Vague Declaratives + +Sentences that announce importance without naming the specific thing. Kill these. + +- "The reasons are structural" +- "The implications are significant" +- "This is the deepest problem" +- "The stakes are high" +- "The consequences are real" + +If a sentence says something is important/deep/structural without showing the specific thing, cut it or replace it with the specific thing. diff --git a/.agents/skills/stop-slop/references/structures.md b/.agents/skills/stop-slop/references/structures.md new file mode 100644 index 0000000..bbcc359 --- /dev/null +++ b/.agents/skills/stop-slop/references/structures.md @@ -0,0 +1,134 @@ +# Structures to Avoid + +## Binary Contrasts + +These create false drama. State the point directly. + +| Pattern | Problem | +|---------|---------| +| "Not because X. Because Y." / "Not because X, but because Y." | Telegraphed reversal | +| "[X] isn't the problem. [Y] is." | Formulaic reframe | +| "The answer isn't X. It's Y." | Predictable pivot | +| "It feels like X. It's actually Y." | Setup/reveal cliche | +| "The question isn't X. It's Y." | Rhetorical misdirection | +| "Not X. But Y." / "not X, it's Y" / "isn't X, it's Y" | Mechanical contrast | +| "It's not this. It's that." | Same formula, different words | +| "stops being X and starts being Y" | False transformation arc | +| "doesn't mean X, but actually Y" | Negation-then-assertion crutch | +| "is about X but not Y" | False distinction | +| "not just X but also Y" | Additive hedge | + +**Instead:** State Y directly. "The problem is Y." "Y matters here." Drop the negation entirely. + +## Negative Listing + +Listing what something is *not* before revealing what it *is*. A rhetorical striptease. + +| Pattern | Problem | +|---------|---------| +| "Not a X... Not a Y... A Z." | Dramatic buildup through negation | +| "It wasn't X. It wasn't Y. It was Z." | Same structure, past tense | + +**Instead:** State Z. The reader doesn't need the runway. + +## Dramatic Fragmentation + +Sentence fragments for emphasis read as manufactured profundity. + +| Pattern | Problem | +|---------|---------| +| "[Noun]. That's it. That's the [thing]." | Performative simplicity | +| "X. And Y. And Z." | Staccato drama | +| "This unlocks something. [Word]." | Artificial revelation | + +**Instead:** Complete sentences. Trust content over presentation. + +## Rhetorical Setups + +These announce insight rather than deliver it. + +| Pattern | Problem | +|---------|---------| +| "What if [reframe]?" | Socratic posturing | +| "Here's what I mean:" | Redundant preview | +| "Think about it:" | Condescending prompt | +| "And that's okay." | Unnecessary permission | + +**Instead:** Make the point. Let readers draw conclusions. + +## Formulaic Constructions + +| Pattern | Problem | +|---------|---------| +| "By the time X, I was Y." | Narrative template | +| "X that isn't Y" | Indirect. Say "X is broken" | + +## False Agency + +Giving inanimate things human verbs. Complaints don't "become" fixes. Bets don't "live or die." Decisions don't "emerge." A person does something to make those things happen. AI loves this because it avoids naming the actor. + +| Pattern | Problem | +|---------|---------| +| "a complaint becomes a fix" | The complaint did nothing. Someone fixed it. | +| "a bet lives or dies in days" | Bets don't have lifespans. Someone kills the project or ships it. | +| "the decision emerges" | Decisions don't emerge. Someone decides. | +| "the culture shifts" | Cultures don't shift on their own. People change behavior. | +| "the conversation moves toward" | Conversations don't move. Someone steers. | +| "the data tells us" | Data sits there. Someone reads it and draws a conclusion. | +| "the market rewards" | Markets don't reward. Buyers pay for things. | + +**Instead:** Name the human. "The team fixed it that week" beats "the complaint becomes a fix." If no specific person fits, use "you" to put the reader in the seat. + +## Narrator-from-a-Distance + +Floating above the scene instead of putting the reader in it. + +| Pattern | Problem | +|---------|---------| +| "Nobody designed this." | Disembodied observation | +| "This happens because..." | Lecturer voice | +| "This is why..." | Same | +| "People tend to..." | Armchair sociologist | + +**Instead:** Put the reader in the room. "You don't sit down one day and decide to..." beats "Nobody designed this." + +## Passive Voice + +Every sentence needs a subject doing something. Passive voice hides the actor and drains energy. + +| Pattern | Fix | +|---------|-----| +| "X was created" | Name who created it | +| "It is believed that" | Name who believes it | +| "Mistakes were made" | Name who made them | +| "The decision was reached" | Name who decided | + +**Instead:** Find the actor. Put them at the front of the sentence. + +## Sentence Starters to Avoid + +| Pattern | Fix | +|---------|-----| +| Sentences starting with What, When, Where, Which, Who, Why, How | Restructure. Lead with the subject or the verb. | +| Paragraphs starting with "So" | Start with content | +| Sentences starting with "Look," | Remove | + +Wh- openers become a crutch. "What makes this hard is..." becomes "The constraint is..." or better, name the specific constraint. + +## Rhythm Patterns + +| Pattern | Fix | +|---------|-----| +| Three-item lists | Use two items or one | +| Questions answered immediately | Let questions breathe or cut them | +| Every paragraph ends punchily | Vary endings | +| Em-dashes | Remove. Use commas or periods. No em dashes at all. | +| Staccato fragmentation | Don't stack short punchy sentences | +| "Not always. Not perfectly." | Hedging disguised as reassurance | + +## Word Patterns + +| Pattern | Problem | +|---------|---------| +| Lazy extremes (every, always, never, everyone, everybody, nobody) | False authority. Use specifics instead of sweeping claims. | +| All adverbs (-ly words, "really," "just," "literally," "genuinely," "honestly," "simply," "actually") | Empty emphasis. See phrases.md for full list. | diff --git a/.agents/skills/webapp-testing/LICENSE.txt b/.agents/skills/webapp-testing/LICENSE.txt new file mode 100644 index 0000000..7a4a3ea --- /dev/null +++ b/.agents/skills/webapp-testing/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/.agents/skills/webapp-testing/SKILL.md b/.agents/skills/webapp-testing/SKILL.md new file mode 100644 index 0000000..4726215 --- /dev/null +++ b/.agents/skills/webapp-testing/SKILL.md @@ -0,0 +1,96 @@ +--- +name: webapp-testing +description: Toolkit for interacting with and testing local web applications using Playwright. Supports verifying frontend functionality, debugging UI behavior, capturing browser screenshots, and viewing browser logs. +license: Complete terms in LICENSE.txt +--- + +# Web Application Testing + +To test local web applications, write native Python Playwright scripts. + +**Helper Scripts Available**: +- `scripts/with_server.py` - Manages server lifecycle (supports multiple servers) + +**Always run scripts with `--help` first** to see usage. DO NOT read the source until you try running the script first and find that a customized solution is abslutely necessary. These scripts can be very large and thus pollute your context window. They exist to be called directly as black-box scripts rather than ingested into your context window. + +## Decision Tree: Choosing Your Approach + +``` +User task → Is it static HTML? + ├─ Yes → Read HTML file directly to identify selectors + │ ├─ Success → Write Playwright script using selectors + │ └─ Fails/Incomplete → Treat as dynamic (below) + │ + └─ No (dynamic webapp) → Is the server already running? + ├─ No → Run: python scripts/with_server.py --help + │ Then use the helper + write simplified Playwright script + │ + └─ Yes → Reconnaissance-then-action: + 1. Navigate and wait for networkidle + 2. Take screenshot or inspect DOM + 3. Identify selectors from rendered state + 4. Execute actions with discovered selectors +``` + +## Example: Using with_server.py + +To start a server, run `--help` first, then use the helper: + +**Single server:** +```bash +python scripts/with_server.py --server "npm run dev" --port 5173 -- python your_automation.py +``` + +**Multiple servers (e.g., backend + frontend):** +```bash +python scripts/with_server.py \ + --server "cd backend && python server.py" --port 3000 \ + --server "cd frontend && npm run dev" --port 5173 \ + -- python your_automation.py +``` + +To create an automation script, include only Playwright logic (servers are managed automatically): +```python +from playwright.sync_api import sync_playwright + +with sync_playwright() as p: + browser = p.chromium.launch(headless=True) # Always launch chromium in headless mode + page = browser.new_page() + page.goto('http://localhost:5173') # Server already running and ready + page.wait_for_load_state('networkidle') # CRITICAL: Wait for JS to execute + # ... your automation logic + browser.close() +``` + +## Reconnaissance-Then-Action Pattern + +1. **Inspect rendered DOM**: + ```python + page.screenshot(path='/tmp/inspect.png', full_page=True) + content = page.content() + page.locator('button').all() + ``` + +2. **Identify selectors** from inspection results + +3. **Execute actions** using discovered selectors + +## Common Pitfall + +❌ **Don't** inspect the DOM before waiting for `networkidle` on dynamic apps +✅ **Do** wait for `page.wait_for_load_state('networkidle')` before inspection + +## Best Practices + +- **Use bundled scripts as black boxes** - To accomplish a task, consider whether one of the scripts available in `scripts/` can help. These scripts handle common, complex workflows reliably without cluttering the context window. Use `--help` to see usage, then invoke directly. +- Use `sync_playwright()` for synchronous scripts +- Always close the browser when done +- Use descriptive selectors: `text=`, `role=`, CSS selectors, or IDs +- Add appropriate waits: `page.wait_for_selector()` or `page.wait_for_timeout()` + +## Reference Files + +- **examples/** - Examples showing common patterns: + - `element_discovery.py` - Discovering buttons, links, and inputs on a page + - `static_html_automation.py` - Using file:// URLs for local HTML + - `console_logging.py` - Capturing console logs during automation \ No newline at end of file diff --git a/.agents/skills/webapp-testing/examples/console_logging.py b/.agents/skills/webapp-testing/examples/console_logging.py new file mode 100644 index 0000000..9329b5e --- /dev/null +++ b/.agents/skills/webapp-testing/examples/console_logging.py @@ -0,0 +1,35 @@ +from playwright.sync_api import sync_playwright + +# Example: Capturing console logs during browser automation + +url = 'http://localhost:5173' # Replace with your URL + +console_logs = [] + +with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + page = browser.new_page(viewport={'width': 1920, 'height': 1080}) + + # Set up console log capture + def handle_console_message(msg): + console_logs.append(f"[{msg.type}] {msg.text}") + print(f"Console: [{msg.type}] {msg.text}") + + page.on("console", handle_console_message) + + # Navigate to page + page.goto(url) + page.wait_for_load_state('networkidle') + + # Interact with the page (triggers console logs) + page.click('text=Dashboard') + page.wait_for_timeout(1000) + + browser.close() + +# Save console logs to file +with open('/mnt/user-data/outputs/console.log', 'w') as f: + f.write('\n'.join(console_logs)) + +print(f"\nCaptured {len(console_logs)} console messages") +print(f"Logs saved to: /mnt/user-data/outputs/console.log") \ No newline at end of file diff --git a/.agents/skills/webapp-testing/examples/element_discovery.py b/.agents/skills/webapp-testing/examples/element_discovery.py new file mode 100644 index 0000000..917ba72 --- /dev/null +++ b/.agents/skills/webapp-testing/examples/element_discovery.py @@ -0,0 +1,40 @@ +from playwright.sync_api import sync_playwright + +# Example: Discovering buttons and other elements on a page + +with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + page = browser.new_page() + + # Navigate to page and wait for it to fully load + page.goto('http://localhost:5173') + page.wait_for_load_state('networkidle') + + # Discover all buttons on the page + buttons = page.locator('button').all() + print(f"Found {len(buttons)} buttons:") + for i, button in enumerate(buttons): + text = button.inner_text() if button.is_visible() else "[hidden]" + print(f" [{i}] {text}") + + # Discover links + links = page.locator('a[href]').all() + print(f"\nFound {len(links)} links:") + for link in links[:5]: # Show first 5 + text = link.inner_text().strip() + href = link.get_attribute('href') + print(f" - {text} -> {href}") + + # Discover input fields + inputs = page.locator('input, textarea, select').all() + print(f"\nFound {len(inputs)} input fields:") + for input_elem in inputs: + name = input_elem.get_attribute('name') or input_elem.get_attribute('id') or "[unnamed]" + input_type = input_elem.get_attribute('type') or 'text' + print(f" - {name} ({input_type})") + + # Take screenshot for visual reference + page.screenshot(path='/tmp/page_discovery.png', full_page=True) + print("\nScreenshot saved to /tmp/page_discovery.png") + + browser.close() \ No newline at end of file diff --git a/.agents/skills/webapp-testing/examples/static_html_automation.py b/.agents/skills/webapp-testing/examples/static_html_automation.py new file mode 100644 index 0000000..90bbedc --- /dev/null +++ b/.agents/skills/webapp-testing/examples/static_html_automation.py @@ -0,0 +1,33 @@ +from playwright.sync_api import sync_playwright +import os + +# Example: Automating interaction with static HTML files using file:// URLs + +html_file_path = os.path.abspath('path/to/your/file.html') +file_url = f'file://{html_file_path}' + +with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + page = browser.new_page(viewport={'width': 1920, 'height': 1080}) + + # Navigate to local HTML file + page.goto(file_url) + + # Take screenshot + page.screenshot(path='/mnt/user-data/outputs/static_page.png', full_page=True) + + # Interact with elements + page.click('text=Click Me') + page.fill('#name', 'John Doe') + page.fill('#email', 'john@example.com') + + # Submit form + page.click('button[type="submit"]') + page.wait_for_timeout(500) + + # Take final screenshot + page.screenshot(path='/mnt/user-data/outputs/after_submit.png', full_page=True) + + browser.close() + +print("Static HTML automation completed!") \ No newline at end of file diff --git a/.agents/skills/webapp-testing/scripts/with_server.py b/.agents/skills/webapp-testing/scripts/with_server.py new file mode 100644 index 0000000..431f2eb --- /dev/null +++ b/.agents/skills/webapp-testing/scripts/with_server.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +""" +Start one or more servers, wait for them to be ready, run a command, then clean up. + +Usage: + # Single server + python scripts/with_server.py --server "npm run dev" --port 5173 -- python automation.py + python scripts/with_server.py --server "npm start" --port 3000 -- python test.py + + # Multiple servers + python scripts/with_server.py \ + --server "cd backend && python server.py" --port 3000 \ + --server "cd frontend && npm run dev" --port 5173 \ + -- python test.py +""" + +import subprocess +import socket +import time +import sys +import argparse + +def is_server_ready(port, timeout=30): + """Wait for server to be ready by polling the port.""" + start_time = time.time() + while time.time() - start_time < timeout: + try: + with socket.create_connection(('localhost', port), timeout=1): + return True + except (socket.error, ConnectionRefusedError): + time.sleep(0.5) + return False + + +def main(): + parser = argparse.ArgumentParser(description='Run command with one or more servers') + parser.add_argument('--server', action='append', dest='servers', required=True, help='Server command (can be repeated)') + parser.add_argument('--port', action='append', dest='ports', type=int, required=True, help='Port for each server (must match --server count)') + parser.add_argument('--timeout', type=int, default=30, help='Timeout in seconds per server (default: 30)') + parser.add_argument('command', nargs=argparse.REMAINDER, help='Command to run after server(s) ready') + + args = parser.parse_args() + + # Remove the '--' separator if present + if args.command and args.command[0] == '--': + args.command = args.command[1:] + + if not args.command: + print("Error: No command specified to run") + sys.exit(1) + + # Parse server configurations + if len(args.servers) != len(args.ports): + print("Error: Number of --server and --port arguments must match") + sys.exit(1) + + servers = [] + for cmd, port in zip(args.servers, args.ports): + servers.append({'cmd': cmd, 'port': port}) + + server_processes = [] + + try: + # Start all servers + for i, server in enumerate(servers): + print(f"Starting server {i+1}/{len(servers)}: {server['cmd']}") + + # Use shell=True to support commands with cd and && + process = subprocess.Popen( + server['cmd'], + shell=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE + ) + server_processes.append(process) + + # Wait for this server to be ready + print(f"Waiting for server on port {server['port']}...") + if not is_server_ready(server['port'], timeout=args.timeout): + raise RuntimeError(f"Server failed to start on port {server['port']} within {args.timeout}s") + + print(f"Server ready on port {server['port']}") + + print(f"\nAll {len(servers)} server(s) ready") + + # Run the command + print(f"Running: {' '.join(args.command)}\n") + result = subprocess.run(args.command) + sys.exit(result.returncode) + + finally: + # Clean up all servers + print(f"\nStopping {len(server_processes)} server(s)...") + for i, process in enumerate(server_processes): + try: + process.terminate() + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + print(f"Server {i+1} stopped") + print("All servers stopped") + + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/.gitignore b/.gitignore index 3dce7dc..3b59b69 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,5 @@ Thumbs.db vite.config.js.timestamp-* vite.config.ts.timestamp-* -/_img \ No newline at end of file +_img/ +tmp/ \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index da577cd..298aaf2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -119,6 +119,18 @@ project owner declares a production-stable format. During the prototype phase: 5. Add compatibility support only when the project owner requires it for a production-stable format. +## Talent mechanic conventions + +Before importing a Talent or changing its mechanic program, read +`docs/TALENT_MECHANIC_RULES.md`. Treat the confirmed lifecycle, trigger, +cooldown, and targeting rules in that document as authoritative game behavior. +Do not mark a mechanic `requires_assumptions` for behavior that the document +resolves. Do not extend a rule to Talents outside its stated scope. + +When the project owner clarifies another Talent mechanic, record the answer in +`docs/TALENT_MECHANIC_RULES.md`. Keep unresolved questions separate from +confirmed behavior. + ## ISAC editor lifecycle All ISAC data-management editors must follow the same successful-save behavior: diff --git a/database/schema.sql b/database/schema.sql index d7e7d8e..43befe0 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -685,6 +685,7 @@ CREATE TABLE IF NOT EXISTS conditional_effect_outcomes ( resource_kind TEXT CHECK(resource_kind IS NULL OR resource_kind IN ('health','armor','ammo')), delivery_mode TEXT NOT NULL DEFAULT 'instant' CHECK(delivery_mode IN ('instant','continuous')), delivery_duration_seconds REAL CHECK(delivery_duration_seconds IS NULL OR delivery_duration_seconds>0), + targeting_json TEXT, min_state_value REAL, max_state_value REAL, description_slot INTEGER CHECK(description_slot IS NULL OR description_slot>=1), diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index 0289935..1edf4ea 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -347,6 +347,22 @@ Selecting equipment opens the searchable real-data catalog and its compatible configuration controls. Do not display a prototype option that the current loadout schema cannot save. +Calculation readiness belongs at the point of resolution. Mark an incomplete +matrix cell with a compact red text indicator and status rail, then let the +cell open the editor that resolves it. The live calculation rail summarizes +blocked output in one compact row. Do not repeat every missing slot in a large +error panel. Keep system-owned blockers, such as an unpublished calculation +profile, visible in that compact summary because the player cannot resolve +them from a loadout cell. + +The builder uses three workbench columns on wide screens, moves the live +summary below the matrix and editor at intermediate widths, and stacks all +three regions on narrow screens. Every grid track and editor child must accept +content shrinkage. At 560px and below, stack attribute picker/value pairs and +section headings; keep the command actions in two equal columns. Validation +markers use short visible labels while their full messages remain available +to assistive technology and pointer users. + Use `--shd-builder-canvas` for recessed equipment fields. Use `--shd-builder-selection` with `--shd-builder-selection-border` for the selected cell. Preserve button semantics, `aria-pressed`, focus-visible @@ -922,12 +938,18 @@ attribute/value row pattern. Exotic is a status layered onto a weapon family, never a replacement for the family classification. Exotic definitions add a dedicated exotic-only talent after the two standard talent pools; their sixth augmentation cell communicates that this talent advances to Tier II. -The exotic talent is owned by exactly one exotic weapon and is authored inline -inside that weapon editor, never selected from a shared global pool. Its -engineered sub-panel includes the talent name, shared description template, -Tier I/II previews, and the shared source-owned mechanic program editor. The Talent -Registry may display an owned exotic talent for discovery, but routes editing -back to its weapon rather than opening the shared-talent editor. +The exotic talent is authored in the Talent Registry under the `Exotic Weapon` +category. The weapon editor uses the shared searchable assignment pattern to +reference one registered exotic talent. Each option states whether another +weapon owns it and whether contributors have documented Tier II. Assigned +talents remain visible but unavailable to other weapon records. The selected +talent stays removable from its current weapon. + +The Talent Registry owns the exotic talent name, icon, description template, +Tier previews, and mechanic program. Its explicit Tier II documentation choice +uses `DOCUMENTED` and `NOT YET DOCUMENTED`. Tier I remains required in both +states. The registry shows `NOT YET DOCUMENTED` in the Tier II column instead +of a zero value or synthesized description. Place shared weapon-category bonuses above the weapon registry in one collapsed profile console. Its six category buttons show the three inherited Attribute @@ -2118,7 +2140,9 @@ control, or shared Trigger lifecycle. Each source supplies the value columns required by its progression: -- Standard and exotic Talents show Tier I and Tier II. +- Standard Talents show Tier I and Tier II. Exotic Talents show Tier I and an + explicit Tier II documentation state; the editor adds the Tier II value + column only when contributors select `DOCUMENTED`. - OS Protocol Talents show a base value and may scale an outcome through the protocol's zero-to-five upgrade expression. - Skill Chip Talents show minimum and maximum values. @@ -2143,9 +2167,16 @@ technology support; mobile targets remain at least 44 pixels tall. The direct-event picker reads the 19 normalized events from the code-defined event registry. Use searchable single-assignment because the vocabulary exceeds the compact segmented-choice limit. Each branch presents its event, match -policy and parameters, optional cooldown, state mutations, and outcomes. -Recipe-generated predicates and weights remain visible in the generated -script. State rows expose label, agent or target scope, initial value, bounds, +policy and parameters, optional cooldown, condition, state mutations, and +outcomes. The branch condition offers `ALWAYS`, `HEALTH BAND`, `EVENT FIELD`, +and `STATE THRESHOLD` for manual authoring. Event-field conditions expose the +field path, comparison operator, scalar type, and typed value. State thresholds +compare an agent-scoped pool with a numeric value. Health bands expose an +optional inclusive lower Health percentage and a required exclusive upper +percentage; helper text states how exact thresholds resolve. Predicates outside +these manual patterns remain preserved, 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 @@ -2192,10 +2223,13 @@ its state to assistive technology. Focus remains in the active field when the generated script refreshes, and reduced-motion preferences remove expansion and replacement transitions. -The recipe starter opens with no recipe selected. Keep its count, duration, and -distance parameters hidden until the author chooses a recipe. Disable `ADD -RECIPE BRANCH` while the selection is empty, and show `REPLACE PROGRAM` only -after the author selects a recipe and the current program contains a branch. +The recipe starter opens with no recipe selected. Keep its count, duration, +distance, and Health-band parameters hidden until the author chooses a recipe. +Health-band recipes expose ordered strict upper thresholds and editable band +labels. Stack each band row on narrow screens and keep its remove action at +least 44 pixels tall. Disable `ADD RECIPE BRANCH` while the selection is empty, +and show `REPLACE PROGRAM` only after the author selects a recipe and the +current program contains a branch. #### AI mechanic drafting diff --git a/docs/TALENT_MECHANIC_RULES.md b/docs/TALENT_MECHANIC_RULES.md new file mode 100644 index 0000000..3b0a92e --- /dev/null +++ b/docs/TALENT_MECHANIC_RULES.md @@ -0,0 +1,114 @@ +# Talent Mechanic Rules + +The project owner confirmed these game rules on July 29, 2026. Apply them when +importing Talents, editing mechanic programs, evaluating combat events, or +deciding whether a Talent still requires assumptions. + +## Shared timed-stack lifecycle + +Timed stack pools use one shared duration: + +- Gaining a stack refreshes the duration for the full pool. +- The pool loses all stacks when the duration expires. +- A qualifying activation at maximum stacks refreshes the duration. The stack + count remains at its maximum. + +This rule covers Combat Inspiration, Simple Pleasures, Explosive Fury, Injured +Taunt, Defense Enhancement, Explosive Burst, Unstoppable, Painkillers, Injured +Concealment, Hypervigilant, Suppressive Fire, Defensive Charge, and Battlelust. + +## Trigger definitions + +Use these event boundaries: + +- **Hit taken:** Count any hit, including a hit absorbed by Extra Health. + Friendly Skill healing also counts as a hit. +- **Healing:** Count each healing event. A heal-over-time effect counts each + tick as a separate event. +- **Extra Health gained:** Count every Extra Health grant, including a grant + received while the Agent already has Extra Health. +- **Reloading:** Trigger on reload completion when the weapon had at least one + round to load. A canceled reload does not count. A weapon that loads one + round at a time produces one completed-reload event for each round. +- **Taking damage:** Require Health loss. A hit absorbed by Extra Health does + not count as taking damage. + +## Talent cooldowns + +A Talent starts its cooldown when it activates. A failed chance roll does not +start the cooldown. + +Battlelust can grant no more than one stack per second because a successful +activation starts its one-second cooldown. Its five-second duration and +three-stack maximum still allow the Agent to reach three stacks. A successful +activation at maximum stacks refreshes the five-second duration. + +Apply the same activation-only cooldown rule to Explosive Fury and Explosive +Burst. A trigger that occurs during cooldown cannot activate the Talent. + +## Persistent deployed Skills + +Inspiration and Crossfire use a binary deployed-Skill state: + +- The benefit remains active while the Agent has at least one persistent + deployed Skill object. +- Destruction or timed expiration removes that object from the active count. + The benefit ends when no qualifying objects remain. +- Instant-use Skills do not qualify. +- Multiple deployed Skill objects do not multiply the benefit. + +Ability data must identify whether an ability leaves a persistent deployed +object. Use that flag instead of inferring deployment behavior from an ability +name or category. + +## Downed allies + +Suppression Rescue and Instant Triage activate while a downed ally is within 30 +meters: + +- Moving within 30 meters of an ally who is already downed qualifies. +- Each qualifying presence refreshes the ten-second duration. +- Leaving the radius or reviving the ally does not cancel the current + ten-second duration. +- The wearer does not count as an ally. + +## Emergency Retreat + +Emergency Retreat activates whenever at least one enemy is within 20 meters and +the Talent is off cooldown. The same enemy can activate it again when the +cooldown ends. Each activation lasts three seconds from its activation time. +Leaving the radius does not end that active three-second effect. + +## Second Wind + +Second Wind follows the shared reload-completion rule. Any completed reload +qualifies when the weapon had at least one round to load. This includes each +round inserted by a weapon that reloads one round at a time. A canceled reload +does not qualify. + +Resolve the Extra Health amount from the Agent's displayed Firepower when +Second Wind activates. Include all equipped-item and active-buff contributions +in that Firepower value: + +- Tier I grants Extra Health equal to 600% of displayed Firepower. +- Tier II grants Extra Health equal to 840% of displayed Firepower. + +Second Wind adds its Extra Health to Extra Health from other sources. Its +five-second duration and five-second cooldown start when it activates. Expiry +removes only the Second Wind contribution. At the five-second boundary, the +prior grant can expire as a qualifying reload completion grants a new amount. +If Second Wind supplies the Agent's only Extra Health, the old amount expires +and the new amount begins at that boundary. + +## Nothing to Lose + +Nothing to Lose uses a forward-looking deployment window: + +- Each Health-damage instance makes its own 50% activation roll. Damage + absorbed by Extra Health does not roll. +- A successful activation applies to every Skill deployed during the five + seconds after that damage instance. Several Skills can qualify during the + same window. +- Calculate each reduction from the Skill's total cooldown after equipment + modifiers. +- A Skill can receive no more than three reductions during one cooldown cycle. diff --git a/docs/architecture.md b/docs/architecture.md index 199f7aa..6989c19 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -275,19 +275,25 @@ remain fixed. Each successful save appends a `game_data/weapon_bonus_profile.updated` audit event with the complete previous and resulting profile. -# Weapon-owned exotic talents +# Referenced exotic weapon talents Standard weapon talent pools remain reusable across weapons and use Talent 1 / -Top or Talent 2 / Bottom. One exotic weapon owns each exotic talent through -`weapons.exotic_talent_id`. +Top or Talent 2 / Bottom. Contributors create dedicated talents in the Talent +Registry under `Exotic Weapon`. The database stores these records as Weapon +talents with Exotic scope and no standard slot. -The weapon editor creates and updates the underlying normalized `talents`, -`talent_tiers`, description template, and typed conditional rule together with -the weapon. Server validation rejects an exotic talent ID owned by another -weapon and rejects any shared Talent. Converting a weapon back to standard -clears the relationship and archives its now-unowned exotic Talent. The shared -Talent Registry rejects exotic Talent creation and editing so contributors -cannot bypass weapon ownership. +An exotic weapon references one active exotic talent through +`weapons.exotic_talent_id`. The weapon editor lists registered exotic talents +and identifies talents assigned to another weapon. Server validation rejects +standard talents, inactive talents, and talents already assigned elsewhere. +Changing or clearing a weapon relationship leaves the previous talent active +and available for another assignment. + +Tier I and its modeled mechanic are required when a contributor saves an +exotic talent. Tier II may remain undocumented. An undocumented Tier II has no +`talent_tiers` row or conditional-effect value variant. A later Talent Registry +edit adds the Tier II projection and mechanic values without changing the +weapon reference. # OS Protocol progression @@ -395,9 +401,20 @@ Talent, OS Protocol Talent, Skill Chip Talent, or Specialization tree rank. The `source_type` and `source_id` pair identifies that owner. No standalone Trigger record or shared Trigger relationship participates in execution. +The builder supplies normalized timeline events to each equipped mechanic. +`movement_snapshot.values.state` is `immobile`, `moving`, or `sprinting`; the +mechanic's moving and sprinting scenario controls select that state, with +sprinting taking precedence. Enabled Skill activation profiles emit +`skill_deployed` events on the same cadence as Skill calculations and identify +the deployed ability. An `on_event` Skill modifier applies to that activation +until the ability's next deployment or the encounter ends. + Each source supplies a fixed value-variant contract: -- Standard and exotic Talents require Tier I and Tier II values. +- Standard Talents require Tier I and Tier II values. Exotic Talents require + Tier I and may omit Tier II until contributors document its game values. + Builder calculations use Tier I while an equipped exotic Talent has no Tier + II mechanic variant. - OS Protocol Talents store a base value. An outcome may use an expression that reads `equipment.protocolTalentUpgrades`, from zero through five, so selected values can scale to twice the base at upgrade five. @@ -484,6 +501,15 @@ coefficient to a percentage divides the Attribute value by 100. Evaluation fails when the loadout does not supply a referenced Attribute; the runtime does not substitute zero. +The read-only `combat.currentHealthPercent` reference divides resolved current +Health by resolved maximum Health and returns a value from 0 through 100. The +runtime derives it when a predicate is evaluated instead of storing a second +Health value that could become stale. Current timing observes deterministic +resource changes from earlier branches at the same event. Health-band mechanics +use strict upper bounds and inclusive lower bounds, so bands do not overlap and +an exact threshold enters the next weaker band. Maximum-potential evaluation +applies the same predicates before emitting passive outcomes. + Continuous restoration resolves the outcome's total when the branch activates, then delivers that total at a constant rate over the configured duration. Activations create independent streams, overlapping streams add, and restoration diff --git a/scripts/db-migrate.mjs b/scripts/db-migrate.mjs index 1722691..ccf9cfc 100644 --- a/scripts/db-migrate.mjs +++ b/scripts/db-migrate.mjs @@ -644,7 +644,8 @@ if (conditionalRuleColumns.some((column) => column.name === 'trigger_id')) { variant_key TEXT NOT NULL DEFAULT 'base', outcome_key TEXT NOT NULL DEFAULT 'active', application_kind TEXT NOT NULL DEFAULT 'active_modifier' CHECK(application_kind IN ( - 'active_modifier','direct_damage','restore_health','restore_armor','restore_ammo','resource_cost' + 'active_modifier','direct_damage','restore_health','restore_armor','restore_ammo', + 'restore_signature_ability_charge','resource_cost' )), effect_scope TEXT NOT NULL DEFAULT 'self' CHECK(effect_scope IN ( 'self','equipped_weapon','target','allies','self_and_allies','assigned_ability','all_skills' @@ -674,7 +675,9 @@ if (conditionalRuleColumns.some((column) => column.name === 'trigger_id')) { CHECK( (application_kind='active_modifier' AND stat_attribute_id IS NOT NULL AND damage_basis IS NULL) OR (application_kind='direct_damage' AND damage_basis IS NOT NULL) - OR application_kind IN ('restore_health','restore_armor','restore_ammo','resource_cost') + OR application_kind IN ( + 'restore_health','restore_armor','restore_ammo','restore_signature_ability_charge','resource_cost' + ) ) )`); await database.exec(`INSERT INTO conditional_effect_rules @@ -1205,7 +1208,8 @@ if (sourceOwnedClauseColumns.some((column) => column.name === 'trigger_id')) { variant_key TEXT NOT NULL DEFAULT 'base', outcome_key TEXT NOT NULL DEFAULT 'active', application_kind TEXT NOT NULL DEFAULT 'active_modifier' CHECK(application_kind IN ( - 'active_modifier','direct_damage','restore_health','restore_armor','restore_ammo','resource_cost','marker' + 'active_modifier','direct_damage','restore_health','restore_armor','restore_ammo', + 'restore_signature_ability_charge','resource_cost','marker' )), effect_scope TEXT NOT NULL DEFAULT 'self' CHECK(effect_scope IN ( 'self','equipped_weapon','target','allies','self_and_allies','assigned_ability','all_skills' @@ -1223,7 +1227,9 @@ if (sourceOwnedClauseColumns.some((column) => column.name === 'trigger_id')) { 'while_active','on_event','after_event','on_enter','on_exit' )), combat_timing TEXT NOT NULL DEFAULT 'current' CHECK(combat_timing IN ('pre_event','current')), - resource_kind TEXT CHECK(resource_kind IS NULL OR resource_kind IN ('health','armor','ammo')), + resource_kind TEXT CHECK(resource_kind IS NULL OR resource_kind IN ( + 'health','armor','ammo','signature_ability_charge' + )), min_state_value REAL, max_state_value REAL, description_slot INTEGER CHECK(description_slot IS NULL OR description_slot>=1), @@ -1237,7 +1243,10 @@ if (sourceOwnedClauseColumns.some((column) => column.name === 'trigger_id')) { CHECK( (application_kind='active_modifier' AND stat_attribute_id IS NOT NULL AND damage_basis IS NULL) OR (application_kind='direct_damage' AND damage_basis IS NOT NULL) - OR application_kind IN ('restore_health','restore_armor','restore_ammo','resource_cost','marker') + OR application_kind IN ( + 'restore_health','restore_armor','restore_ammo','restore_signature_ability_charge', + 'resource_cost','marker' + ) ) )`); await database.exec(`INSERT INTO conditional_effect_outcomes @@ -1253,6 +1262,7 @@ if (sourceOwnedClauseColumns.some((column) => column.name === 'trigger_id')) { WHEN 'restore_health' THEN 'health' WHEN 'restore_armor' THEN 'armor' WHEN 'restore_ammo' THEN 'ammo' + WHEN 'restore_signature_ability_charge' THEN 'signature_ability_charge' ELSE NULL END, min_state_value,max_state_value,description_slot,sort_order,created_at,updated_at FROM conditional_effect_outcomes_triggered`); @@ -2127,6 +2137,216 @@ if (skillChipAbilityColumn && Number(skillChipAbilityColumn.notnull) !== 1) { await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS skill_chip_sets_name_nocase_idx ON skill_chip_sets(name COLLATE NOCASE)'); await database.exec('CREATE INDEX IF NOT EXISTS skill_chip_sets_ability_idx ON skill_chip_sets(specialization_ability_id,active)'); +const conditionalOutcomeSchema = await (await database.prepare( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='conditional_effect_outcomes'" +)).get(); +if ( + conditionalOutcomeSchema + && !String(conditionalOutcomeSchema.sql ?? '').includes('signature_ability_charge') +) { + console.log('Adding Signature Ability Charge as a conditional-mechanic resource.'); + await database.exec('DROP INDEX IF EXISTS conditional_effect_outcomes_rule_idx'); + await database.exec('PRAGMA foreign_keys=OFF'); + await database.exec('BEGIN'); + try { + await database.exec('DROP TABLE IF EXISTS conditional_effect_outcomes_signature_charge'); + await database.exec(`CREATE TABLE conditional_effect_outcomes_signature_charge ( + id TEXT PRIMARY KEY, + rule_id TEXT NOT NULL REFERENCES conditional_effect_rules(id) ON DELETE CASCADE, + clause_id TEXT REFERENCES conditional_effect_clauses(id) ON DELETE CASCADE, + state_pool_id TEXT REFERENCES conditional_effect_state_pools(id) ON DELETE SET NULL, + effect_key TEXT NOT NULL, + variant_key TEXT NOT NULL DEFAULT 'base', + outcome_key TEXT NOT NULL DEFAULT 'active', + application_kind TEXT NOT NULL DEFAULT 'active_modifier' CHECK(application_kind IN ( + 'active_modifier','direct_damage','restore_health','restore_armor','restore_ammo', + 'restore_signature_ability_charge','resource_cost','marker' + )), + effect_scope TEXT NOT NULL DEFAULT 'self' CHECK(effect_scope IN ( + 'self','equipped_weapon','target','allies','self_and_allies','assigned_ability','all_skills' + )), + stat_attribute_id TEXT REFERENCES stat_attributes(id), + value REAL NOT NULL, + value_expression_json TEXT, + value_basis TEXT NOT NULL DEFAULT 'flat' CHECK(value_basis IN ( + 'flat','max_health_percent','current_health_percent','weapon_base_percent' + )), + value_scaling TEXT NOT NULL DEFAULT 'per_state_unit' CHECK(value_scaling IN ('flat','per_state_unit')), + unit TEXT, + damage_basis TEXT CHECK(damage_basis IS NULL OR damage_basis IN ('flat','weapon_base_percent')), + application_phase TEXT NOT NULL DEFAULT 'while_active' CHECK(application_phase IN ( + 'while_active','on_event','after_event','on_enter','on_exit' + )), + combat_timing TEXT NOT NULL DEFAULT 'current' CHECK(combat_timing IN ('pre_event','current')), + 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, + min_state_value REAL, + max_state_value REAL, + description_slot INTEGER CHECK(description_slot IS NULL OR description_slot>=1), + sort_order INTEGER NOT NULL CHECK(sort_order>=1), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(rule_id,variant_key,outcome_key,sort_order), + CHECK(delivery_duration_seconds IS NULL OR delivery_duration_seconds>0), + CHECK(delivery_mode='instant' OR ( + application_kind IN ( + 'restore_health','restore_armor','restore_ammo','restore_signature_ability_charge' + ) + AND delivery_duration_seconds IS NOT NULL + )), + CHECK(min_state_value IS NULL OR min_state_value>=0), + CHECK(max_state_value IS NULL OR max_state_value>=0), + CHECK(min_state_value IS NULL OR max_state_value IS NULL OR max_state_value>=min_state_value), + CHECK( + (application_kind='active_modifier' AND stat_attribute_id IS NOT NULL AND damage_basis IS NULL) + OR (application_kind='direct_damage' AND damage_basis IS NOT NULL) + OR application_kind IN ( + 'restore_health','restore_armor','restore_ammo','restore_signature_ability_charge', + 'resource_cost','marker' + ) + ) + )`); + await database.exec(`INSERT INTO conditional_effect_outcomes_signature_charge ( + id,rule_id,clause_id,state_pool_id,effect_key,variant_key,outcome_key, + application_kind,effect_scope,stat_attribute_id,value,value_expression_json, + value_basis,value_scaling,unit,damage_basis,application_phase,combat_timing, + resource_kind,delivery_mode,delivery_duration_seconds,min_state_value,max_state_value, + description_slot,sort_order,created_at,updated_at + ) + SELECT + id,rule_id,clause_id,state_pool_id,effect_key,variant_key,outcome_key, + application_kind,effect_scope,stat_attribute_id,value,value_expression_json, + value_basis,value_scaling,unit,damage_basis,application_phase,combat_timing, + resource_kind,delivery_mode,delivery_duration_seconds,min_state_value,max_state_value, + description_slot,sort_order,created_at,updated_at + FROM conditional_effect_outcomes`); + await database.exec('DROP TABLE conditional_effect_outcomes'); + await database.exec( + 'ALTER TABLE conditional_effect_outcomes_signature_charge RENAME TO conditional_effect_outcomes' + ); + await database.exec('COMMIT'); + } catch (error) { + await database.exec('ROLLBACK'); + throw error; + } finally { + await database.exec('PRAGMA foreign_keys=ON'); + } + await database.exec(`CREATE INDEX IF NOT EXISTS conditional_effect_outcomes_rule_idx + ON conditional_effect_outcomes(rule_id,variant_key,outcome_key,sort_order)`); +} + +const reloadOutcomeSchema = await (await database.prepare( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='conditional_effect_outcomes'" +)).get(); +if ( + reloadOutcomeSchema + && !String(reloadOutcomeSchema.sql ?? '').includes("'weapon_reload'") +) { + console.log('Adding inactive-weapon reload actions to conditional mechanics.'); + await database.exec('DROP INDEX IF EXISTS conditional_effect_outcomes_rule_idx'); + await database.exec('PRAGMA foreign_keys=OFF'); + await database.exec('BEGIN'); + try { + await database.exec('DROP TABLE IF EXISTS conditional_effect_outcomes_weapon_reload'); + await database.exec(`CREATE TABLE conditional_effect_outcomes_weapon_reload ( + id TEXT PRIMARY KEY, + rule_id TEXT NOT NULL REFERENCES conditional_effect_rules(id) ON DELETE CASCADE, + clause_id TEXT REFERENCES conditional_effect_clauses(id) ON DELETE CASCADE, + state_pool_id TEXT REFERENCES conditional_effect_state_pools(id) ON DELETE SET NULL, + effect_key TEXT NOT NULL, + variant_key TEXT NOT NULL DEFAULT 'base', + outcome_key TEXT NOT NULL DEFAULT 'active', + application_kind TEXT NOT NULL DEFAULT 'active_modifier' CHECK(application_kind IN ( + 'active_modifier','direct_damage','restore_health','restore_armor','restore_ammo', + 'restore_signature_ability_charge','resource_cost','weapon_reload','marker' + )), + effect_scope TEXT NOT NULL DEFAULT 'self' CHECK(effect_scope IN ( + 'self','equipped_weapon','inactive_weapon','target','allies','self_and_allies', + 'assigned_ability','all_skills' + )), + stat_attribute_id TEXT REFERENCES stat_attributes(id), + value REAL NOT NULL, + value_expression_json TEXT, + value_basis TEXT NOT NULL DEFAULT 'flat' CHECK(value_basis IN ( + 'flat','max_health_percent','current_health_percent','weapon_base_percent' + )), + value_scaling TEXT NOT NULL DEFAULT 'per_state_unit' CHECK(value_scaling IN ('flat','per_state_unit')), + unit TEXT, + damage_basis TEXT CHECK(damage_basis IS NULL OR damage_basis IN ('flat','weapon_base_percent')), + application_phase TEXT NOT NULL DEFAULT 'while_active' CHECK(application_phase IN ( + 'while_active','on_event','after_event','on_enter','on_exit' + )), + combat_timing TEXT NOT NULL DEFAULT 'current' CHECK(combat_timing IN ('pre_event','current')), + 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, + min_state_value REAL, + max_state_value REAL, + description_slot INTEGER CHECK(description_slot IS NULL OR description_slot>=1), + sort_order INTEGER NOT NULL CHECK(sort_order>=1), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(rule_id,variant_key,outcome_key,sort_order), + CHECK(delivery_duration_seconds IS NULL OR delivery_duration_seconds>0), + CHECK(delivery_mode='instant' OR ( + application_kind IN ( + 'restore_health','restore_armor','restore_ammo','restore_signature_ability_charge' + ) + AND delivery_duration_seconds IS NOT NULL + )), + CHECK(min_state_value IS NULL OR min_state_value>=0), + CHECK(max_state_value IS NULL OR max_state_value>=0), + CHECK(min_state_value IS NULL OR max_state_value IS NULL OR max_state_value>=min_state_value), + CHECK( + (application_kind='active_modifier' AND stat_attribute_id IS NOT NULL AND damage_basis IS NULL) + OR (application_kind='direct_damage' AND damage_basis IS NOT NULL) + OR application_kind IN ( + 'restore_health','restore_armor','restore_ammo','restore_signature_ability_charge', + 'resource_cost','weapon_reload','marker' + ) + ) + )`); + await database.exec(`INSERT INTO conditional_effect_outcomes_weapon_reload ( + id,rule_id,clause_id,state_pool_id,effect_key,variant_key,outcome_key, + application_kind,effect_scope,stat_attribute_id,value,value_expression_json, + value_basis,value_scaling,unit,damage_basis,application_phase,combat_timing, + resource_kind,delivery_mode,delivery_duration_seconds,min_state_value,max_state_value, + description_slot,sort_order,created_at,updated_at + ) + SELECT + id,rule_id,clause_id,state_pool_id,effect_key,variant_key,outcome_key, + application_kind,effect_scope,stat_attribute_id,value,value_expression_json, + value_basis,value_scaling,unit,damage_basis,application_phase,combat_timing, + resource_kind,delivery_mode,delivery_duration_seconds,min_state_value,max_state_value, + description_slot,sort_order,created_at,updated_at + FROM conditional_effect_outcomes`); + await database.exec('DROP TABLE conditional_effect_outcomes'); + await database.exec( + 'ALTER TABLE conditional_effect_outcomes_weapon_reload RENAME TO conditional_effect_outcomes' + ); + await database.exec('COMMIT'); + } catch (error) { + await database.exec('ROLLBACK'); + throw error; + } finally { + await database.exec('PRAGMA foreign_keys=ON'); + } + await database.exec(`CREATE INDEX IF NOT EXISTS conditional_effect_outcomes_rule_idx + ON conditional_effect_outcomes(rule_id,variant_key,outcome_key,sort_order)`); +} + +const outcomeTargetingColumns = await ( + await database.prepare("PRAGMA table_info('conditional_effect_outcomes')") +).all(); +if (!outcomeTargetingColumns.some((column) => column.name === 'targeting_json')) { + await database.exec('ALTER TABLE conditional_effect_outcomes ADD COLUMN targeting_json TEXT'); +} + const calibrationCases = await (await database.prepare( 'SELECT id,loadout_snapshot_json FROM calculation_calibration_cases' )).all(); diff --git a/src/app.css b/src/app.css index 890a6c6..f6e8bf0 100644 --- a/src/app.css +++ b/src/app.css @@ -54,23 +54,23 @@ @media(min-width:2200px){:root{--shd-display-scale:1.15}html{zoom:var(--shd-display-scale)}} @media(min-width:2800px){:root{--shd-display-scale:1.3}} @media(min-width:3400px){:root{--shd-display-scale:1.5}} - body:before{content:"";position:fixed;inset:0;pointer-events:none;opacity:.08;background-image:linear-gradient(rgba(255,255,255,.08) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.08) 1px,transparent 1px);background-size:48px 48px} - header{height:76px;border-bottom:1px solid #292d2f;display:flex;align-items:center;padding:0 max(24px,5vw);position:relative;z-index:5;background:rgba(10,12,13,.93)} + body:before{content:"";position:fixed;inset:0;pointer-events:none;opacity:.08;background-image:linear-gradient(rgba(255,255,255,.08) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.08) 1px,transparent 1px);background-size:48px 48px} + header{height:76px;border-bottom:1px solid #292d2f;display:flex;align-items:center;padding:0 max(24px,5vw);position:relative;z-index:5;background:rgba(10,12,13,.93)} .brand{display:flex;align-items:center;color:#fff;text-decoration:none;font-weight:900}.accent{color:#f6a821} - nav{margin:auto;display:flex;height:100%}nav button{border:0;background:none;color:#868b8d;padding:0 22px;font-size:12px;font-weight:800;letter-spacing:1.5px;cursor:pointer;border-bottom:2px solid transparent}nav button.active,nav button:hover{color:#fff;border-color:#f6a821} - .sign-in{background:none;border:1px solid #45494a;color:#fff;padding:12px 18px;font-size:var(--shd-type-telemetry);font-weight:800;letter-spacing:1.4px;cursor:pointer}.sign-in span{color:#f6a821;margin-left:12px} - main{position:relative;z-index:1}.hero{min-height:660px;display:grid;grid-template-columns:1fr 1fr;max-width:1400px;margin:auto;padding:76px 5vw 40px;gap:40px;overflow:hidden}.hero-copy{padding-top:28px}.eyebrow{color:#9da1a2;font-size:var(--shd-type-telemetry);letter-spacing:2.4px;font-weight:800}.eyebrow span{display:inline-block;width:28px;height:2px;background:#f6a821;vertical-align:middle;margin-right:10px} - h1,h2,h3,p{margin-top:0}.hero h1{font-size:clamp(58px,7vw,105px);line-height:.85;letter-spacing:-5px;margin:28px 0}.hero h1 em{font-style:normal;color:#f6a821}.lede{color:#9da1a2;font-family:var(--shd-font-editorial);font-size:18px;line-height:1.65;max-width:620px} - .hero-actions{display:flex;gap:14px;margin:36px 0}.primary,.secondary{border:0;padding:16px 22px;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1.6px;cursor:pointer}.primary{background:#f6a821;color:#111}.primary:hover{background:#ffc45d}.primary span{margin-left:16px}.secondary{background:transparent;color:#ddd;border:1px solid #3a3e40} - .metrics{border-top:1px solid #292d2f;padding-top:28px;display:flex;gap:60px}.metrics div{display:flex;flex-direction:column}.metrics strong{font-size:25px}.metrics span{color:#666d70;font-size:var(--shd-type-telemetry);letter-spacing:1.7px;margin-top:6px} - .loadout-preview{min-height:520px;position:relative;border:1px solid #343738;background:radial-gradient(circle at 50% 46%,#30312c 0,#151819 33%,#0d0f10 68%);clip-path:polygon(5% 0,100% 0,100% 92%,95% 100%,0 100%,0 8%)}.scan-lines{position:absolute;inset:0;opacity:.15;background:repeating-linear-gradient(0deg,transparent,transparent 5px,#fff 6px)}.preview-top{padding:22px;display:flex;justify-content:space-between;font-size:var(--shd-type-telemetry);letter-spacing:2px;color:#8d9294}.preview-top b{color:#f6a821}.agent{height:385px;position:relative;display:grid;place-items:center}.agent-shape{width:145px;height:285px;background:linear-gradient(90deg,#1d2121,#737269 46%,#292c2b 53%,#101212);clip-path:polygon(33% 0,67% 0,78% 14%,72% 32%,89% 58%,78% 100%,22% 100%,12% 58%,28% 32%,22% 14%);filter:drop-shadow(0 10px 18px #000);display:grid;place-items:center}.agent-shape span{color:#f6a821;font-weight:900;opacity:.5;transform:rotate(-90deg);letter-spacing:6px}.orbit{position:absolute;border:1px solid #555;border-radius:50%}.orbit-one{width:330px;height:330px}.orbit-two{width:250px;height:250px;border-color:#f6a82144} - .gear-node{position:absolute;width:74px;background:#141718;border:1px solid #555;border-left:2px solid #f6a821;padding:8px;display:flex;flex-direction:column}.gear-node small{color:#777;font-size:var(--shd-type-telemetry);letter-spacing:1px}.gear-node b{font-size:12px;margin-top:3px}.n-0{left:8%;top:12%}.n-1{right:5%;top:24%}.n-2{left:4%;bottom:22%}.n-3{right:8%;bottom:12%}.preview-name{position:absolute;bottom:22px;left:25px;display:flex;flex-direction:column}.preview-name small{color:#f6a821;font-size:var(--shd-type-telemetry)}.preview-name strong{font-size:18px;margin:4px 0}.preview-name span{font-size:var(--shd-type-telemetry);color:#777} - .featured{background:#101314;padding:70px max(24px,5vw) 90px}.section-heading{max-width:1240px;margin:0 auto 28px;display:flex;align-items:end;justify-content:space-between}.section-heading h2{font-size:34px;margin:8px 0}.section-heading button{color:#aaa;background:none;border:0;font-size:var(--shd-type-telemetry);letter-spacing:1px}.build-grid{max-width:1240px;margin:auto;display:grid;grid-template-columns:repeat(3,1fr);gap:20px}.build-grid article{background:#16191a;border:1px solid #2d3132;border-top:2px solid var(--build-color)}.card-top{display:flex;justify-content:space-between;padding:13px 16px;font-size:var(--shd-type-telemetry);letter-spacing:1px}.card-top span:first-child{color:var(--build-color)}.weapon-art{height:135px;background:linear-gradient(135deg,#1e2324,#111);display:grid;place-items:center;position:relative;overflow:hidden}.weapon-art div{width:65%;height:14px;background:var(--build-color);clip-path:polygon(0 20%,65% 20%,75% 0,100% 25%,94% 65%,66% 60%,58% 100%,47% 100%,49% 62%,0 72%);opacity:.65}.weapon-art span{position:absolute;font-size:var(--shd-type-telemetry);color:#ffffff33;letter-spacing:4px}.card-body{padding:20px}.card-body>p{font-size:var(--shd-type-telemetry);color:var(--build-color);letter-spacing:1.5px}.card-body h3{text-transform:uppercase;font-size:19px}.tags{display:flex;gap:7px}.tags span{font-size:var(--shd-type-telemetry);border:1px solid #3b4041;padding:5px 7px;color:#aeb1b2}.author{display:flex;align-items:center;gap:8px;border-top:1px solid #2d3132;margin-top:18px;padding-top:15px;font-size:var(--shd-type-telemetry);color:#777}.author>span:last-child{margin-left:auto}.avatar{display:grid;place-items:center;width:25px;height:25px;border-radius:50%;background:#2f3435;color:#f6a821} + nav{margin:auto;display:flex;height:100%}nav button{border:0;background:none;color:#868b8d;padding:0 22px;font-size:12px;font-weight:800;letter-spacing:1.5px;cursor:pointer;border-bottom:2px solid transparent}nav button.active,nav button:hover{color:#fff;border-color:#f6a821} + .sign-in{background:none;border:1px solid #45494a;color:#fff;padding:12px 18px;font-size:var(--shd-type-telemetry);font-weight:800;letter-spacing:1.4px;cursor:pointer}.sign-in span{color:#f6a821;margin-left:12px} + main{position:relative;z-index:1}.hero{min-height:660px;display:grid;grid-template-columns:1fr 1fr;max-width:1400px;margin:auto;padding:76px 5vw 40px;gap:40px;overflow:hidden}.hero-copy{padding-top:28px}.eyebrow{color:#9da1a2;font-size:var(--shd-type-telemetry);letter-spacing:2.4px;font-weight:800}.eyebrow span{display:inline-block;width:28px;height:2px;background:#f6a821;vertical-align:middle;margin-right:10px} + h1,h2,h3,p{margin-top:0}.hero h1{font-size:clamp(58px,7vw,105px);line-height:.85;letter-spacing:-5px;margin:28px 0}.hero h1 em{font-style:normal;color:#f6a821}.lede{color:#9da1a2;font-family:var(--shd-font-editorial);font-size:18px;line-height:1.65;max-width:620px} + .hero-actions{display:flex;gap:14px;margin:36px 0}.primary,.secondary{border:0;padding:16px 22px;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1.6px;cursor:pointer}.primary{background:#f6a821;color:#111}.primary:hover{background:#ffc45d}.primary span{margin-left:16px}.secondary{background:transparent;color:#ddd;border:1px solid #3a3e40} + .metrics{border-top:1px solid #292d2f;padding-top:28px;display:flex;gap:60px}.metrics div{display:flex;flex-direction:column}.metrics strong{font-size:25px}.metrics span{color:#666d70;font-size:var(--shd-type-telemetry);letter-spacing:1.7px;margin-top:6px} + .loadout-preview{min-height:520px;position:relative;border:1px solid #343738;background:radial-gradient(circle at 50% 46%,#30312c 0,#151819 33%,#0d0f10 68%);clip-path:polygon(5% 0,100% 0,100% 92%,95% 100%,0 100%,0 8%)}.scan-lines{position:absolute;inset:0;opacity:.15;background:repeating-linear-gradient(0deg,transparent,transparent 5px,#fff 6px)}.preview-top{padding:22px;display:flex;justify-content:space-between;font-size:var(--shd-type-telemetry);letter-spacing:2px;color:#8d9294}.preview-top b{color:#f6a821}.agent{height:385px;position:relative;display:grid;place-items:center}.agent-shape{width:145px;height:285px;background:linear-gradient(90deg,#1d2121,#737269 46%,#292c2b 53%,#101212);clip-path:polygon(33% 0,67% 0,78% 14%,72% 32%,89% 58%,78% 100%,22% 100%,12% 58%,28% 32%,22% 14%);filter:drop-shadow(0 10px 18px #000);display:grid;place-items:center}.agent-shape span{color:#f6a821;font-weight:900;opacity:.5;transform:rotate(-90deg);letter-spacing:6px}.orbit{position:absolute;border:1px solid #555;border-radius:50%}.orbit-one{width:330px;height:330px}.orbit-two{width:250px;height:250px;border-color:#f6a82144} + .gear-node{position:absolute;width:74px;background:#141718;border:1px solid #555;border-left:2px solid #f6a821;padding:8px;display:flex;flex-direction:column}.gear-node small{color:#777;font-size:var(--shd-type-telemetry);letter-spacing:1px}.gear-node b{font-size:12px;margin-top:3px}.n-0{left:8%;top:12%}.n-1{right:5%;top:24%}.n-2{left:4%;bottom:22%}.n-3{right:8%;bottom:12%}.preview-name{position:absolute;bottom:22px;left:25px;display:flex;flex-direction:column}.preview-name small{color:#f6a821;font-size:var(--shd-type-telemetry)}.preview-name strong{font-size:18px;margin:4px 0}.preview-name span{font-size:var(--shd-type-telemetry);color:#777} + .featured{background:#101314;padding:70px max(24px,5vw) 90px}.section-heading{max-width:1240px;margin:0 auto 28px;display:flex;align-items:end;justify-content:space-between}.section-heading h2{font-size:34px;margin:8px 0}.section-heading button{color:#aaa;background:none;border:0;font-size:var(--shd-type-telemetry);letter-spacing:1px}.build-grid{max-width:1240px;margin:auto;display:grid;grid-template-columns:repeat(3,1fr);gap:20px}.build-grid article{background:#16191a;border:1px solid #2d3132;border-top:2px solid var(--build-color)}.card-top{display:flex;justify-content:space-between;padding:13px 16px;font-size:var(--shd-type-telemetry);letter-spacing:1px}.card-top span:first-child{color:var(--build-color)}.weapon-art{height:135px;background:linear-gradient(135deg,#1e2324,#111);display:grid;place-items:center;position:relative;overflow:hidden}.weapon-art div{width:65%;height:14px;background:var(--build-color);clip-path:polygon(0 20%,65% 20%,75% 0,100% 25%,94% 65%,66% 60%,58% 100%,47% 100%,49% 62%,0 72%);opacity:.65}.weapon-art span{position:absolute;font-size:var(--shd-type-telemetry);color:#ffffff33;letter-spacing:4px}.card-body{padding:20px}.card-body>p{font-size:var(--shd-type-telemetry);color:var(--build-color);letter-spacing:1.5px}.card-body h3{text-transform:uppercase;font-size:19px}.tags{display:flex;gap:7px}.tags span{font-size:var(--shd-type-telemetry);border:1px solid #3b4041;padding:5px 7px;color:#aeb1b2}.author{display:flex;align-items:center;gap:8px;border-top:1px solid #2d3132;margin-top:18px;padding-top:15px;font-size:var(--shd-type-telemetry);color:#777}.author>span:last-child{margin-left:auto}.avatar{display:grid;place-items:center;width:25px;height:25px;border-radius:50%;background:#2f3435;color:#f6a821} .app-page{max-width:1320px;margin:auto;padding:60px 5vw;min-height:calc(100vh - 76px)}.page-title{display:flex;justify-content:space-between;align-items:end;margin-bottom:35px}.page-title h1{font-size:50px;margin:6px 0}.page-title input{background:transparent;border:0;border-bottom:1px solid #333;color:#fff;font-size:42px;font-weight:800;max-width:600px}.actions{display:flex;align-items:center;gap:14px}.actions>span{font-size:var(--shd-type-telemetry);color:#6a6;opacity:0}.actions>span.visible{opacity:1}.builder-layout{display:grid;grid-template-columns:1fr 340px;gap:24px}.slot-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}.slot-card{text-align:left;min-height:160px;padding:20px;background:#141718;border:1px solid #303536;color:#eee;cursor:pointer;display:grid;grid-template-columns:1fr auto}.slot-card:hover{border-color:#f6a821}.slot-card .slot-label{font-size:var(--shd-type-telemetry);text-transform:uppercase;letter-spacing:1.5px;color:#777}.slot-icon{font-size:30px;color:#f6a821}.slot-card strong,.slot-card small,.slot-card b{grid-column:1/3}.slot-card strong{font-size:20px}.slot-card small{color:#888}.slot-card b{color:#f6a821}.summary,.data-panel{background:#141718;border:1px solid #303536;padding:25px}.summary h2{font-size:25px}.stat{display:grid;grid-template-columns:1fr auto;gap:8px;margin:20px 0}.stat span{display:flex;align-items:center;gap:7px;color:#999;font-size:var(--shd-type-telemetry)}.stat span .game-icon{width:21px;height:21px}.stat i{height:3px;background:#f6a821;grid-column:1/3}.summary hr{border:0;border-top:1px solid #333;margin:28px 0}.mini{display:flex;justify-content:space-between;font-size:var(--shd-type-telemetry);margin:14px 0}.mini span{color:#888}.notice{font-size:var(--shd-type-telemetry);line-height:1.5;color:#747a7c;border-left:2px solid #f6a821;padding-left:12px;margin-top:30px} - .admin-grid{display:grid;grid-template-columns:260px 1fr;gap:20px}.admin-nav{display:flex;flex-direction:column}.admin-nav button{display:flex;justify-content:space-between;background:#111415;color:#9ca0a1;border:0;border-bottom:1px solid #292d2f;padding:18px;text-align:left}.admin-nav button:first-child{color:#f6a821;border-left:2px solid #f6a821}.table-head{display:flex;justify-content:space-between}.table-head p{font-size:var(--shd-type-telemetry);color:#777}.table-head input{height:38px;background:#0e1011;color:white;border:1px solid #333;padding:0 12px}.data-row{display:grid;grid-template-columns:1.4fr 1.2fr .8fr .6fr .3fr;gap:15px;align-items:center;border-top:1px solid #2b3031;padding:18px 4px;font-size:var(--shd-type-telemetry)}.data-row.labels{font-size:var(--shd-type-telemetry);color:#6f7577}.data-row button{background:none;border:0;color:#f6a821;font-size:var(--shd-type-telemetry)}.status{color:#73bd8a;font-size:var(--shd-type-telemetry)} + .admin-grid{display:grid;grid-template-columns:260px 1fr;gap:20px}.admin-nav{display:flex;flex-direction:column}.admin-nav button{display:flex;justify-content:space-between;background:#111415;color:#9ca0a1;border:0;border-bottom:1px solid #292d2f;padding:18px;text-align:left}.admin-nav button:first-child{color:#f6a821;border-left:2px solid #f6a821}.table-head{display:flex;justify-content:space-between}.table-head p{font-size:var(--shd-type-telemetry);color:#777}.table-head input{height:38px;background:#0e1011;color:white;border:1px solid #333;padding:0 12px}.data-row{display:grid;grid-template-columns:1.4fr 1.2fr .8fr .6fr .3fr;gap:15px;align-items:center;border-top:1px solid #2b3031;padding:18px 4px;font-size:var(--shd-type-telemetry)}.data-row.labels{font-size:var(--shd-type-telemetry);color:#6f7577}.data-row button{background:none;border:0;color:#f6a821;font-size:var(--shd-type-telemetry)}.status{color:#73bd8a;font-size:var(--shd-type-telemetry)} .auth-page,.empty-page{min-height:calc(100vh - 76px);display:grid;place-items:center;padding:40px}.auth-page section{width:min(430px,100%);background:#141718;border:1px solid #343839;border-top:2px solid #f6a821;padding:38px}.auth-page h1,.empty-page h1{font-size:46px;margin-bottom:12px}.auth-page>section>p:nth-of-type(2){color:#929798;line-height:1.5}.discord{width:100%;padding:15px;background:#5865f2;color:#fff;border:0;font-size:var(--shd-type-telemetry);font-weight:bold;letter-spacing:1px}.divider{display:flex;align-items:center;gap:12px;margin:30px 0 20px;text-align:center}.divider:before,.divider:after{content:"";height:1px;flex:1;background:#333}.divider span{flex:0 0 auto;font-size:var(--shd-type-telemetry);color:#666}.auth-page label{display:block;font-size:var(--shd-type-telemetry);letter-spacing:1px;color:#999;margin:15px 0}.auth-page input{width:100%;display:block;margin-top:7px;background:#0e1011;border:1px solid #35393a;color:#fff;padding:13px}.full{width:100%;margin:8px 0 20px}.auth-page small{display:block;color:#676d6e;line-height:1.5}.empty-page{text-align:center}.empty-page p:not(.eyebrow){color:#888}.toast{position:fixed;z-index:20;right:25px;bottom:25px;background:#f6a821;color:#111;padding:14px 20px;font-size:var(--shd-type-telemetry);font-weight:bold} - @media(max-width:900px){header nav{display:none}.sign-in{margin-left:auto}.hero{grid-template-columns:1fr;padding-top:35px}.hero h1{font-size:62px}.loadout-preview{min-height:500px}.build-grid{grid-template-columns:1fr}.builder-layout,.admin-grid{grid-template-columns:1fr}.slot-grid{grid-template-columns:1fr}.summary{order:-1}.page-title{align-items:start;gap:25px}.page-title input{font-size:30px;width:100%}}@media(max-width:560px){.hero{padding-left:20px;padding-right:20px}.hero h1{font-size:48px;letter-spacing:-3px}.metrics{gap:25px}.hero-actions{flex-direction:column}.loadout-preview{display:none}.hero{min-height:auto;padding-bottom:60px}.page-title{flex-direction:column}.admin-grid{display:block}.data-panel{overflow:auto}.data-row{min-width:650px}.brand{font-size:13px}} - + @media(max-width:900px){header nav{display:none}.sign-in{margin-left:auto}.hero{grid-template-columns:1fr;padding-top:35px}.hero h1{font-size:62px}.loadout-preview{min-height:500px}.build-grid{grid-template-columns:1fr}.builder-layout,.admin-grid{grid-template-columns:1fr}.slot-grid{grid-template-columns:1fr}.summary{order:-1}.page-title{align-items:start;gap:25px}.page-title input{font-size:30px;width:100%}}@media(max-width:560px){.hero{padding-left:20px;padding-right:20px}.hero h1{font-size:48px;letter-spacing:-3px}.metrics{gap:25px}.hero-actions{flex-direction:column}.loadout-preview{display:none}.hero{min-height:auto;padding-bottom:60px}.page-title{flex-direction:column}.admin-grid{display:block}.data-panel{overflow:auto}.data-row{min-width:650px}.brand{font-size:13px}} + /* Routed application compatibility: preserve the original SHD visual system. */ .site-header{height:76px}.site-header nav a{display:grid;place-items:center;color:#868b8d;padding:0 22px;font-size:12px;font-weight:800;letter-spacing:1.5px;border-bottom:2px solid transparent}.site-header nav a.active,.site-header nav a:hover{color:#fff;border-color:#f6a821}.site-header .account{margin-left:auto;display:flex;align-items:center;gap:15px}.site-header .account>a{display:flex;flex-direction:column;font-size:var(--shd-type-telemetry);font-weight:800}.site-header .account small{font-size:var(--shd-type-telemetry);color:#6f7577}.site-header .account button{background:none;border:1px solid #45494a;color:#fff;padding:12px 18px;font-size:var(--shd-type-telemetry);font-weight:800;letter-spacing:1.2px} .page{width:100%;max-width:1640px;margin:auto;padding:52px clamp(18px,3vw,48px);min-height:calc(100vh - 76px)}.page.hero{max-width:1400px;padding:76px 5vw 40px}.page-head{display:flex;justify-content:space-between;align-items:end;margin-bottom:35px}.page-head h1{font-size:50px;margin:6px 0;text-transform:uppercase}.page-head h2{font-size:25px;margin:5px 0}.page-head>div>input{background:transparent;border:0;border-bottom:1px solid #333;color:#fff;font-size:42px;font-weight:800;max-width:600px}.muted{color:#858b8d;line-height:1.55} @@ -132,7 +132,7 @@ .isac-operation-tabs{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));margin:10px 0 0;border:1px solid var(--shd-border);background:#0d1011}.isac-operation-tabs button{height:40px;border:0;border-left:1px solid var(--shd-border);border-bottom:2px solid transparent;background:transparent;color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1px;cursor:pointer}.isac-operation-tabs button:first-child{border-left:0}.isac-operation-tabs button b{margin-left:5px;color:var(--shd-success)}.isac-operation-tabs button:hover,.isac-operation-tabs button:focus-visible{background:#171b1c;color:var(--shd-text)}.isac-operation-tabs button.active{border-bottom-color:var(--shd-orange);background:#211a0e;color:var(--shd-orange)}.isac-operation-tabs button:focus-visible{position:relative;z-index:1;outline:2px solid var(--shd-orange);outline-offset:2px}.user-console-grid{grid-template-columns:minmax(0,1fr)}.isac-panel-hidden{display:none!important}.user-history-registries{display:grid;gap:10px;margin-top:10px}.history-registry{min-width:0;border:1px solid var(--shd-border);background:var(--shd-surface)}.history-filter{display:grid;grid-template-columns:minmax(180px,1fr) 170px 80px;gap:6px;padding:8px;border-bottom:1px solid var(--shd-border-soft)}.history-filter input,.history-filter select{height:34px;margin:0;padding:0 9px;font-size:var(--shd-type-telemetry)}.history-filter button{height:34px;padding:0 9px;font-size:var(--shd-type-telemetry)}.history-registry:last-child .history-filter{grid-template-columns:minmax(180px,1fr) 80px}.history-registry .registry-pagination{margin:0;border-width:0 0 1px}.history-registry .history-list{max-height:none}.history-registry .comment-history>div{grid-template-columns:minmax(140px,.5fr) minmax(260px,1.5fr) auto}.history-registry .comment-history span{white-space:normal;line-height:1.4}@media(max-width:650px){.isac-operation-tabs,.history-filter,.history-registry:last-child .history-filter{grid-template-columns:1fr}.isac-operation-tabs button{border-left:0;border-top:1px solid var(--shd-border)}.isac-operation-tabs button:first-child{border-top:0}} .isac-modal-backdrop{position:fixed;z-index:50;inset:0;display:grid;place-items:center;padding:20px;background:#050607d9;backdrop-filter:blur(3px)}.isac-confirm-modal{width:min(680px,100%);max-height:calc(100vh - 40px);overflow:auto;padding:16px;border:1px solid #5a6061;border-top:2px solid var(--shd-orange);background:#131617;box-shadow:0 24px 80px #000}.access-confirm-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}.access-confirm-grid>div{min-height:120px;align-content:start;display:grid;gap:5px;padding:10px;border:1px solid var(--shd-border);background:#0d1011}.access-confirm-grid>div>span{margin-bottom:3px;color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);letter-spacing:1px}.access-confirm-grid b{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:6px 7px;border-left:2px solid #596164;background:#151819;color:#aeb3b4;font-size:var(--shd-type-telemetry)}.access-confirm-grid b span{color:inherit}.access-confirm-grid b em{font-style:normal;color:#747c7e;font-size:var(--shd-type-telemetry);letter-spacing:.8px}.access-confirm-grid b.access-add{border-left-color:var(--shd-success);background:#101713;color:#9bd4ac}.access-confirm-grid b.access-add em{color:var(--shd-success)}.access-confirm-grid b.access-remove{border-left-color:var(--shd-danger);background:#211414;color:#dca096;text-decoration:line-through}.access-confirm-grid b.access-remove em{color:var(--shd-danger);text-decoration:none}.access-confirm-grid small{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry)}@media(max-width:560px){.access-confirm-grid{grid-template-columns:1fr}} .login-investigation-list>div{grid-template-columns:1fr 1fr 1.2fr auto;align-items:center}.login-investigation-list>div>div{display:grid;gap:4px}.login-investigation-list small{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);letter-spacing:.4px}.deletion-purge-option{display:grid!important;grid-template-columns:20px 1fr;align-items:start;gap:8px;margin:14px 0!important;padding:10px;border:1px solid #70423b;background:#211414}.deletion-purge-option input{width:auto;margin:2px 0 0;accent-color:var(--shd-danger)}.deletion-purge-option span{display:grid;gap:4px}.deletion-purge-option b{color:var(--shd-danger);font-size:var(--shd-type-telemetry)}.deletion-purge-option small{margin:0;color:#b18c85;font-size:var(--shd-type-telemetry)} -.site-footer{position:relative;z-index:2;min-height:150px;display:grid;grid-template-columns:1fr auto minmax(300px,.9fr);align-items:center;gap:28px;padding:28px max(24px,5vw);border-top:1px solid var(--shd-border);background:#080a0b}.footer-brand{width:max-content}.site-footer>div>p{margin:8px 0 0;color:var(--shd-text-dim);font:11px var(--shd-font-editorial)}.footer-links{display:grid;gap:8px}.footer-links a{color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1px}.fan-disclaimer{margin:0;color:#555d5f;font-size:var(--shd-type-telemetry);line-height:1.6;letter-spacing:.55px}.legal-page{max-width:1320px;margin:auto;padding:55px clamp(18px,5vw,72px) 90px}.legal-hero{padding-bottom:30px;border-bottom:1px solid var(--shd-border)}.legal-hero h1{margin:18px 0 14px;font-size:clamp(48px,7vw,82px);line-height:.9;letter-spacing:-3px}.legal-hero h1 em{color:var(--shd-orange);font-style:normal}.legal-hero>p:last-child{max-width:720px;color:var(--shd-text-muted);font:15px/1.6 var(--shd-font-editorial)}.legal-layout{display:grid;grid-template-columns:210px minmax(0,1fr);gap:28px;margin-top:24px}.legal-index{position:sticky;top:95px;height:max-content;display:grid;margin:0;border:1px solid var(--shd-border)}.legal-index a{padding:11px;border-top:1px solid var(--shd-border-soft);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.legal-index a:first-child{border-top:0}.legal-index a:hover{color:var(--shd-orange);background:#171b1c}.legal-document{display:grid}.legal-document section{scroll-margin-top:90px;display:grid;grid-template-columns:42px 1fr;gap:14px;padding:24px 0;border-top:1px solid var(--shd-border)}.legal-document section:first-child{border-top:0;padding-top:0}.legal-document section>span{color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900}.legal-document h2{font-size:18px;letter-spacing:.6px}.legal-document p{color:#a1a7a8;font:13px/1.7 var(--shd-font-editorial)}.legal-document a{color:var(--shd-orange)}@media(max-width:800px){.site-footer{grid-template-columns:1fr}.legal-layout{grid-template-columns:1fr}.legal-index{position:static;grid-template-columns:1fr 1fr}.login-investigation-list>div{grid-template-columns:1fr 1fr}}@media(max-width:500px){.legal-index,.login-investigation-list>div{grid-template-columns:1fr}} +.site-footer{position:relative;z-index:2;min-height:150px;display:grid;grid-template-columns:1fr auto minmax(300px,.9fr);align-items:center;gap:28px;padding:28px max(24px,5vw);border-top:1px solid var(--shd-border);background:#080a0b}.footer-brand{width:max-content}.site-footer>div>p{margin:8px 0 0;color:var(--shd-text-dim);font:11px var(--shd-font-editorial)}.footer-links{display:grid;gap:8px}.footer-links a{color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1px}.fan-disclaimer{min-width:0;margin:0;color:#555d5f;font-size:var(--shd-type-telemetry);line-height:1.6;letter-spacing:.55px;overflow-wrap:anywhere}.legal-page{max-width:1320px;margin:auto;padding:55px clamp(18px,5vw,72px) 90px}.legal-hero{padding-bottom:30px;border-bottom:1px solid var(--shd-border)}.legal-hero h1{margin:18px 0 14px;font-size:clamp(48px,7vw,82px);line-height:.9;letter-spacing:-3px}.legal-hero h1 em{color:var(--shd-orange);font-style:normal}.legal-hero>p:last-child{max-width:720px;color:var(--shd-text-muted);font:15px/1.6 var(--shd-font-editorial)}.legal-layout{display:grid;grid-template-columns:210px minmax(0,1fr);gap:28px;margin-top:24px}.legal-index{position:sticky;top:95px;height:max-content;display:grid;margin:0;border:1px solid var(--shd-border)}.legal-index a{padding:11px;border-top:1px solid var(--shd-border-soft);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.legal-index a:first-child{border-top:0}.legal-index a:hover{color:var(--shd-orange);background:#171b1c}.legal-document{display:grid}.legal-document section{scroll-margin-top:90px;display:grid;grid-template-columns:42px 1fr;gap:14px;padding:24px 0;border-top:1px solid var(--shd-border)}.legal-document section:first-child{border-top:0;padding-top:0}.legal-document section>span{color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900}.legal-document h2{font-size:18px;letter-spacing:.6px}.legal-document p{color:#a1a7a8;font:13px/1.7 var(--shd-font-editorial)}.legal-document a{color:var(--shd-orange)}@media(max-width:1100px){.site-footer{grid-template-columns:1fr}.legal-layout{grid-template-columns:1fr}.legal-index{position:static;grid-template-columns:1fr 1fr}.login-investigation-list>div{grid-template-columns:1fr 1fr}}@media(max-width:500px){.legal-index,.login-investigation-list>div{grid-template-columns:1fr}} .legal-primary{width:max-content;color:#111!important;margin-top:8px}.contact-page{width:100%;max-width:1500px;margin:auto;padding:34px clamp(18px,4vw,58px) 52px}.contact-hero{position:relative;height:auto;min-height:230px;display:grid;grid-template-columns:minmax(0,1fr) 310px;align-items:center;gap:40px;padding:18px 0 30px;border-bottom:1px solid var(--shd-border);background:transparent}.contact-hero>div{min-width:0}.contact-hero h1{margin:18px 0 14px;font-size:clamp(52px,7vw,88px);line-height:.85;letter-spacing:-4px}.contact-hero h1 em{color:var(--shd-orange);font-style:normal}.contact-hero>div>p:last-child{max-width:690px;margin-bottom:0;color:var(--shd-text-muted);font:15px/1.6 var(--shd-font-editorial)}.discord-contact{min-height:82px;display:grid;grid-template-columns:1fr auto;align-content:center;gap:4px 10px;padding:15px 17px;border:1px solid #6571dc;background:#171a32;color:#aeb6ff}.discord-contact span{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1.2px}.discord-contact strong{font-size:var(--shd-type-telemetry);letter-spacing:.8px}.discord-contact b{grid-column:2;grid-row:1/3;align-self:center;font-size:18px}.discord-contact:hover,.discord-contact:focus-visible{border-color:#929bff;background:#202446;color:#fff}.contact-layout{display:grid;grid-template-columns:minmax(0,1fr) 320px;align-items:start;gap:12px;margin-top:12px}.contact-console,.contact-guidance{border:1px solid var(--shd-border);background:var(--shd-surface)}.contact-console{padding:16px}.contact-console-head{min-height:45px;display:flex;align-items:start;justify-content:space-between;padding-bottom:10px;margin-bottom:13px;border-bottom:1px solid var(--shd-border)}.contact-console-head>div{display:grid;gap:4px}.contact-console-head span{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);letter-spacing:1.2px}.contact-console-head strong{font-size:13px}.contact-console-head>b{color:var(--shd-success);font-size:var(--shd-type-telemetry);letter-spacing:1px}.contact-console .segmented-choice>div{height:40px}.contact-console .field{margin:10px 0}.contact-console textarea{line-height:1.5}.contact-identity{display:grid;grid-template-columns:1fr 1fr;gap:9px}.contact-honeypot{position:absolute;left:-10000px;width:1px;height:1px;overflow:hidden}.dmca-contact-fields{margin-top:15px;padding:12px;border:1px solid #74552a;border-left:2px solid var(--quality-high-end);background:#131108}.dmca-contact-fields>header{height:auto;display:flex;align-items:center;justify-content:space-between;padding:0 0 9px;border-bottom:1px solid #493a22;background:transparent}.dmca-contact-fields>header>div{display:grid;gap:4px}.dmca-contact-fields>header span{color:#82755f;font-size:var(--shd-type-telemetry);letter-spacing:1px}.dmca-contact-fields>header strong{color:var(--quality-high-end);font-size:var(--shd-type-telemetry)}.dmca-contact-fields>header a{color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900}.dmca-contact-fields>p{margin:10px 0;color:#9b927f;font:var(--shd-type-supporting)/1.55 var(--shd-font-editorial)}.legal-attestation{min-height:46px;display:grid;grid-template-columns:auto 1fr;align-items:center;gap:9px;margin-top:7px;padding:8px;border:1px solid #3c382e;background:#0d0f0d;color:#b5b0a3;font-size:var(--shd-type-telemetry);line-height:1.45;cursor:pointer}.legal-attestation input{width:auto;margin:0;accent-color:var(--shd-orange)}.legal-attestation:has(input:checked){border-color:#7c5c29;background:#1b170d}.contact-guidance{height:max-content;display:grid}.contact-guidance>div{position:relative;min-height:126px;padding:16px 16px 14px 50px;border-top:1px solid var(--shd-border)}.contact-guidance>div:first-child{border-top:0}.contact-guidance>div>span{position:absolute;left:15px;top:17px;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900}.contact-guidance h2{font-size:13px;letter-spacing:.8px}.contact-guidance p{margin:0;color:var(--shd-text-muted);font:var(--shd-type-supporting)/1.55 var(--shd-font-editorial)}.contact-guidance a{display:inline-block;margin-top:10px;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900}.contact-guidance>small{padding:14px;border-top:1px solid var(--shd-border);color:var(--shd-danger);font-size:var(--shd-type-telemetry);line-height:1.5;letter-spacing:.6px}.contact-success{min-height:300px;display:flex;align-items:center;justify-content:center;gap:16px;text-align:left}.contact-success>span{width:48px;height:48px;display:grid;place-items:center;border:1px solid var(--shd-success);color:var(--shd-success);font-size:18px}.contact-success strong{font-size:13px;letter-spacing:1px}.contact-success p{max-width:430px;margin:7px 0 0;color:var(--shd-text-muted);font:13px/1.55 var(--shd-font-editorial)}.contact-message-page{width:100%;max-width:1200px;min-height:calc(100vh - var(--shd-header-height));margin:auto;padding:36px clamp(18px,4vw,55px) 70px}.contact-back{display:inline-block;margin-bottom:12px;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.contact-message{border:1px solid var(--shd-border);border-top:2px solid var(--shd-orange);background:var(--shd-surface)}.contact-message>header{position:static;height:62px;display:flex;justify-content:space-between;padding:0 17px;border-bottom:1px solid var(--shd-border);background:#0d1011}.contact-message>header>div{display:grid;gap:4px}.contact-message>header span{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);letter-spacing:1px}.contact-message>header strong{font-size:var(--shd-type-telemetry)}.contact-message>header time{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry)}.contact-message-title{padding:28px clamp(20px,5vw,55px);border-bottom:1px solid var(--shd-border)}.contact-message-title h1{margin:12px 0;font-size:clamp(30px,5vw,48px)}.contact-message-title>p:last-child{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.contact-message-title a,.contact-message section a{color:var(--shd-orange)}.contact-message>section{padding:24px clamp(20px,5vw,55px);border-bottom:1px solid var(--shd-border)}.contact-message>section h2{font-size:var(--shd-type-telemetry);letter-spacing:1px}.contact-message>section>p{margin:12px 0 0;color:#c0c4c5;font:14px/1.7 var(--shd-font-editorial);white-space:pre-wrap}.contact-dmca-record dl{display:grid;gap:10px}.contact-dmca-record dl>div{display:grid;grid-template-columns:160px 1fr;gap:12px;padding-top:10px;border-top:1px solid var(--shd-border-soft)}.contact-dmca-record dt{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.contact-dmca-record dd{margin:0;color:#c0c4c5;font:12px/1.6 var(--shd-font-editorial);white-space:pre-wrap}.contact-message>footer{display:flex;justify-content:flex-end;padding:14px}.contact-message>footer .primary{padding:13px 16px}@media(max-width:900px){.contact-hero,.contact-layout{grid-template-columns:1fr}.contact-hero{min-height:0;align-items:start;gap:20px;padding-top:8px}.discord-contact{width:100%}}@media(max-width:600px){.contact-page{padding-top:24px;padding-bottom:36px}.contact-hero h1{letter-spacing:-2.5px}.contact-identity{grid-template-columns:1fr}.contact-console .segmented-choice>div{height:auto;grid-template-columns:1fr}.contact-console .segmented-choice span{min-height:44px}.dmca-contact-fields>header{align-items:flex-start;flex-direction:column;gap:8px}.contact-dmca-record dl>div{grid-template-columns:1fr}.contact-message>footer .primary{width:100%}} @media(max-width:1100px){.effect-editor{grid-template-columns:1fr}.selection-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:900px){.gear-identity,.gear-ranges{grid-template-columns:1fr 1fr}}@media(max-width:560px){.effect-band{grid-template-columns:48px 1fr}.effect-band .field:last-child{grid-column:2}.split-actions{align-items:stretch}.split-actions,.split-actions>div{width:100%}.split-actions>div:last-child button{flex:1}.gear-identity,.gear-ranges,.selection-grid{grid-template-columns:1fr}.range-band{grid-template-columns:48px 1fr}.range-band .field:last-child{grid-column:2}} .game-icon{display:block;width:24px;height:24px;object-fit:contain}.weapon-editor{border-top-color:#d07a2c}.weapon-identity-grid{display:grid;grid-template-columns:1.6fr repeat(5,minmax(0,1fr));gap:8px}.weapon-fixed-grid{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:8px}.damage-type-picker{min-width:0;margin:0;padding:0;border:0}.damage-type-picker legend{color:#858b8d;font-size:var(--shd-type-telemetry);font-weight:bold;letter-spacing:1px}.damage-type-picker>div{height:32px;display:grid;grid-template-columns:repeat(3,1fr);margin-top:5px;border:1px solid #363b3c;background:#0d0f10}.damage-type-picker label{min-width:0;border-left:1px solid #303536;cursor:pointer}.damage-type-picker label:first-child{border-left:0}.damage-type-picker input{position:absolute;opacity:0;pointer-events:none}.damage-type-picker span{height:100%;display:flex;align-items:center;justify-content:center;gap:3px;color:#737a7c}.damage-type-picker .game-icon{width:17px;height:17px;opacity:.62}.damage-type-picker b{overflow:hidden;font-size:var(--shd-type-telemetry);letter-spacing:.25px;text-overflow:ellipsis}.damage-type-picker label:hover span{background:#171b1c;color:#ecebe5}.damage-type-picker label:active span{transform:translateY(1px);filter:brightness(.88)}.damage-type-picker input:checked+span{background:#211a0e;color:#f6a821;box-shadow:inset 0 -2px #f6a821}.damage-type-picker input:checked+span .game-icon{opacity:1}.damage-type-picker input:focus-visible+span{outline:2px solid #f6a821;outline-offset:2px}.exotic-toggle>span{min-height:32px;margin-top:5px;padding:0 9px;display:flex;align-items:center;gap:7px;border:1px solid #363b3c;background:#0d0f10;color:#d3d5d1;font-size:var(--shd-type-telemetry)}.exotic-toggle input{width:auto;margin:0;accent-color:#f6a821}.weapon-tier-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin-top:9px}.weapon-tier-card{min-width:0;margin:0;padding:7px;border:1px solid #3b4142;background:#101314}.weapon-tier-card legend{padding:0 7px;color:#f6a821;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1.2px}.weapon-range-row{display:grid;grid-template-columns:65px 1fr 1fr;gap:5px;align-items:end;padding:5px;border-top:1px solid #282e2f}.weapon-range-row:first-of-type{border-top:0}.weapon-range-row>strong{align-self:center;display:flex;align-items:center;gap:4px;color:#d7d9d5;font-size:var(--shd-type-telemetry)}.weapon-range-row>strong .game-icon{width:20px;height:20px}.weapon-range-row>strong small{display:block;color:#596164;font-size:var(--shd-type-telemetry);letter-spacing:.5px}.weapon-range-row label{color:#737a7c;font-size:var(--shd-type-telemetry);letter-spacing:.7px}.weapon-range-row input{margin-top:3px;padding:7px;font-size:var(--shd-type-telemetry)}.weapon-talent-pools{display:grid;grid-template-columns:1fr 1fr;gap:9px}.compact-selection{grid-template-columns:repeat(2,minmax(0,1fr));max-height:210px;overflow:auto}.weapon-base-bonuses,.weapon-augment-effects{display:grid;gap:5px;margin-top:7px}.base-bonus-row,.augment-effect{display:grid;grid-template-columns:35px minmax(180px,1fr) 130px 30px;gap:6px;align-items:center;padding:5px;border:1px solid #303536;background:#101314}.base-bonus-row>strong,.augment-effect>strong{height:100%;display:grid;place-items:center;background:#171b1c;color:#f6a821;font-size:var(--shd-type-telemetry)}.base-bonus-row .field,.augment-effect .field{margin:0}.base-bonus-row button,.augment-effect button{height:30px;border:1px solid #563734;background:#1d1212;color:#da746c;cursor:pointer}.base-bonus-row button:disabled{opacity:.3;cursor:not-allowed}.augment-effect:not(:has(>button)){grid-template-columns:35px minmax(180px,1fr) 130px}.augment-sequence{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:5px;margin-top:7px}.augment-sequence>div{min-height:42px;display:grid;place-items:center;padding:5px;border:1px solid #303536;background:#0d1011}.augment-sequence b{color:#f6a821;font-size:var(--shd-type-telemetry)}.augment-sequence span{color:#727a7c;font-size:var(--shd-type-telemetry)}.augment-sequence .exotic-step{border-color:#875d25;background:#211a0e}.weapon-row{min-width:1180px;grid-template-columns:1.3fr .7fr 1.2fr .6fr .6fr .5fr .5fr .3fr}.weapon-row>span small{color:#596164}.weapon-damage-label{display:flex!important;align-items:center;gap:4px}.weapon-damage-label .game-icon{width:16px;height:16px}.weapon-dashboard-table{margin-top:10px} @@ -162,7 +162,7 @@ .pending-reference,.dashboard-row.pending-record{border-color:color-mix(in srgb,var(--shd-orange) 58%,#303536)!important;background:color-mix(in srgb,var(--shd-orange) 7%,#121516)!important}.pending-reference-label,.state-dot.pending{color:var(--shd-orange);font-family:var(--font-display);font-size:.62rem;letter-spacing:.08em;white-space:nowrap}.state-dot.pending:before{background:var(--shd-orange)} .data-access-banner{justify-content:space-between}.data-access-banner>div{display:grid;gap:3px}.data-access-banner form{margin:0}.mode-toggle{min-height:28px;display:inline-flex;align-items:center;padding:0 9px;border:1px solid #4b5152;background:#15191a;color:#c7cdce;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.65px;white-space:nowrap;cursor:pointer}.mode-toggle:hover,.mode-toggle:focus-visible{border-color:var(--shd-orange);color:var(--shd-orange)}.mode-toggle:active{transform:translateY(1px);filter:brightness(.86)} .contributor-guidelines-toolbar{align-items:flex-end}.guideline-version{min-width:120px;display:grid;gap:4px;padding:8px 10px;border:1px solid var(--shd-border);background:#111415}.guideline-version span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);letter-spacing:1px}.guideline-version strong{color:var(--shd-orange);font-size:var(--shd-type-telemetry)}.contributor-guidelines{display:grid;grid-template-columns:1fr 1fr;gap:7px;margin:10px 0}.contributor-guidelines section{min-height:122px;display:grid;grid-template-columns:34px 1fr;border:1px solid var(--shd-border);background:#111415}.contributor-guidelines section>span{display:grid;place-items:start center;padding-top:11px;border-right:1px solid var(--shd-border);color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900}.contributor-guidelines section>div{padding:11px}.contributor-guidelines h3{margin:0 0 6px;font-size:var(--shd-type-telemetry);letter-spacing:.7px}.contributor-guidelines p{margin:0;color:#858d8f;font-size:var(--shd-type-telemetry);line-height:1.55}.contributor-acceptance{display:flex;align-items:flex-start;gap:9px;padding:11px;border:1px solid #6f511c;background:#19150e;cursor:pointer}.contributor-acceptance input{width:auto;margin:2px 0 0;accent-color:var(--shd-orange)}.contributor-acceptance span{display:grid;gap:4px}.contributor-acceptance b{color:#e3c487;font-size:var(--shd-type-telemetry);letter-spacing:.7px}.contributor-acceptance small{color:#917d59;font-size:var(--shd-type-telemetry)}.form-actions a.button{display:inline-flex;align-items:center;justify-content:center;text-decoration:none}@media(max-width:760px){.contributor-guidelines{grid-template-columns:1fr}.data-access-banner{align-items:flex-start;flex-direction:column}.mode-toggle{width:100%;justify-content:center}} -.command-status .data-mode-status{border-left:2px solid #667174}.command-status .data-mode-status strong{color:#b8c0c1;white-space:nowrap}.command-status .data-mode-status.contributor{border-left-color:var(--shd-orange)}.command-status .data-mode-status.contributor strong{color:#f2b64d}.command-status .data-mode-status.approver{border-left-color:#68a9c4}.command-status .data-mode-status.approver strong{color:#8ecce5}.command-status .data-mode-status.data-manager,.command-status .data-mode-status.admin{border-left-color:#75c08c}.command-status .data-mode-status.data-manager strong,.command-status .data-mode-status.admin strong{color:#8bd49f} +.command-status .data-mode-status{border-left:2px solid #667174}.command-status .data-mode-status strong{color:#b8c0c1;white-space:nowrap}.command-status .data-mode-status.contributor{border-left-color:var(--shd-orange)}.command-status .data-mode-status.contributor strong{color:#f2b64d}.command-status .data-mode-status.approver{border-left-color:#68a9c4}.command-status .data-mode-status.approver strong{color:#8ecce5}.command-status .data-mode-status.data-manager,.command-status .data-mode-status.admin{border-left-color:#75c08c}.command-status .data-mode-status.data-manager strong,.command-status .data-mode-status.admin strong{color:#8bd49f} @media(max-width:900px){.admin-command{align-items:flex-start;gap:12px}.command-status{display:grid;grid-template-columns:repeat(2,minmax(105px,1fr))}}@media(max-width:560px){.admin-command{display:grid}.command-status{width:100%}.command-status>div{min-width:0}} .data-readonly .module-toolbar button,.data-readonly .dashboard-row:not(.table-heading)>button,.data-readonly .dashboard-editor,.data-readonly form[method=post]{display:none!important} .command-status .data-mode-status.oversight{border-left-color:#68a9c4}.command-status .data-mode-status.oversight strong{color:#8ecce5}.oversight-decision-tabs{grid-template-columns:repeat(3,1fr)}.proposal-correction-head{display:flex;align-items:center;justify-content:space-between;margin:10px 0 6px;padding:8px 9px;border:1px solid var(--shd-border);background:#0d1011}.proposal-correction-head>div{display:grid;gap:3px}.proposal-correction-head span{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.proposal-correction-head small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry)}.proposal-correction-head>b{color:var(--shd-orange);font-size:var(--shd-type-telemetry)}.proposal-correction-grid{max-height:340px;display:grid;grid-template-columns:1fr 1fr;gap:6px;overflow:auto;padding:7px;border:1px solid var(--shd-border);background:#0b0e0f}.proposal-correction-grid .field{margin:0}.proposal-correction-grid .field>span{display:flex;justify-content:space-between}.proposal-correction-grid .field>span small{color:#61696b;font-size:var(--shd-type-telemetry)}.proposal-correction-grid textarea{min-height:74px;resize:vertical}.immutable-proposal-file{display:grid;align-content:center;gap:4px;min-height:54px;padding:8px;border:1px dashed #394041;color:var(--shd-text-muted)}.immutable-proposal-file span,.immutable-proposal-file small{font-size:var(--shd-type-telemetry)}.immutable-proposal-file b{color:var(--shd-text);font-size:var(--shd-type-telemetry)}@media(max-width:700px){.oversight-decision-tabs,.proposal-correction-grid{grid-template-columns:1fr}} @@ -206,9 +206,11 @@ .profile-row>a{color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:700}.builder-save-form{display:inline-block}.builder-save-form fieldset{display:flex}.builder-persistence{display:grid;grid-template-columns:minmax(300px,520px) 1fr auto;gap:14px;align-items:end;padding:11px 2px;border-top:1px solid var(--shd-border)}.builder-persistence p{margin:0 0 9px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry)}.builder-share-link{align-self:center;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:700;letter-spacing:1px}.catalogue-empty{grid-column:1/-1;display:grid;justify-items:center;gap:12px;padding:60px;border:1px solid var(--shd-border);background:var(--shd-panel);text-align:center}.catalogue-empty>span{color:var(--shd-orange);font-size:34px}.catalogue-empty p{margin:0;color:var(--shd-text-muted)}.build-open{display:block;text-align:center}.shared-build-page{padding-top:28px}.shared-build-command{display:flex;justify-content:space-between;gap:24px;align-items:end;padding:22px;border:1px solid var(--shd-border);border-top:2px solid var(--shd-orange);background:var(--shd-panel)}.shared-build-command h1{margin:8px 0;font-size:clamp(30px,4vw,52px)}.shared-build-command p:last-child{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry)}.shared-build-command>div:last-child{display:flex;gap:8px}.shared-build-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin-top:10px}.shared-build-grid>section{border:1px solid var(--shd-border);background:var(--shd-panel)}.shared-build-grid>section>header{display:flex;justify-content:space-between;padding:10px 12px;border-bottom:1px solid var(--shd-border);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);letter-spacing:1.2px}.shared-build-grid article{display:grid;gap:4px;min-height:70px;padding:12px;border-bottom:1px solid var(--shd-border)}.shared-build-grid article:last-child{border-bottom:0}.shared-build-grid article small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);letter-spacing:1px}.shared-build-grid article strong{font-size:12px}.shared-build-grid article span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry)}@media(max-width:760px){.builder-command .page-title,.shared-build-command{align-items:stretch;flex-direction:column}.builder-command .actions,.shared-build-command>div:last-child{flex-wrap:wrap}.builder-persistence{grid-template-columns:1fr}.shared-build-grid{grid-template-columns:1fr}} /* Command Grid production builder: matrix navigation, contextual editor, and live telemetry rail. */ -.builder-command{border:1px solid var(--shd-border);background:var(--shd-surface)}.builder-command .page-title{padding:14px 16px;margin:0}.builder-command .page-title>div:first-child{min-width:min(620px,55vw)}.builder-command .page-title input{width:100%;max-width:none;font-size:clamp(27px,3.1vw,46px);letter-spacing:-.03em;text-transform:uppercase}.builder-command .actions{align-items:center;flex-wrap:wrap}.builder-command .actions>span{color:var(--shd-success);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.08em}.builder-command .builder-persistence{padding:10px 14px}.builder-status{padding:9px 14px;border-top:1px solid var(--shd-border);background:#0c0f10}.builder-notes{display:grid;grid-template-columns:minmax(260px,.65fr) minmax(420px,1.35fr);gap:8px;padding:10px 14px;border-top:1px solid var(--shd-border);background:var(--shd-builder-canvas)}.builder-notes>label{display:grid;align-content:start;gap:6px}.builder-notes>label>span{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.08em}.builder-notes>label small{color:var(--shd-text-muted)}.builder-notes textarea{width:100%;min-height:150px;padding:11px;border:1px solid var(--shd-border);border-radius:0;background:#0d0f10;color:var(--shd-text);font:var(--shd-type-body)/1.55 var(--shd-font-editorial);resize:vertical}.loadout-workspace{grid-template-columns:minmax(300px,350px) minmax(480px,1fr) minmax(290px,340px);gap:6px}.loadout-editor{border-top-color:var(--shd-orange);background:var(--shd-surface)}.loadout-editor-head{margin:-12px -12px 10px;padding:11px 12px;border-bottom-color:var(--shd-border);background:#0c0f10}.loadout-summary{background:var(--shd-surface)}.builder-ability-chip-panels.single{grid-template-columns:1fr}.builder-ability-chip-panels.single section>div{grid-template-columns:repeat(3,minmax(0,1fr))}.builder-specialization-identity.ability-identity{grid-template-columns:54px minmax(0,1fr) auto}.builder-specialization-identity.ability-identity>.item-icon{width:50px;height:50px}.shared-build-guide{margin-top:10px;border:1px solid var(--shd-border);border-top:2px solid var(--shd-orange);background:var(--shd-panel)}.shared-build-guide>header{display:flex;justify-content:space-between;padding:9px 12px;border-bottom:1px solid var(--shd-border);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.09em}.shared-build-guide>header b{color:var(--shd-orange)}.shared-build-summary{max-width:90ch;margin:0;padding:15px 18px;border-bottom:1px solid var(--shd-border);color:var(--shd-text);font:16px/1.6 var(--shd-font-editorial)}.shared-build-guide>div{padding:18px}.shared-build-guide h2{margin:0 0 10px;font-size:18px;letter-spacing:.04em} +.builder-command{min-width:0;border:1px solid var(--shd-border);background:var(--shd-surface)}.builder-command .page-title{min-width:0;padding:14px 16px;margin:0}.builder-command .page-title>div{min-width:0}.builder-command .page-title>div:first-child{flex:1 1 460px;min-width:min(460px,42vw)}.builder-command .page-title input{width:100%;max-width:none;font-size:clamp(27px,3.1vw,46px);letter-spacing:-.03em;text-transform:uppercase}.builder-command .actions{min-width:0;flex:0 0 auto;display:grid;grid-template-columns:repeat(3,minmax(0,auto));align-items:center;gap:7px}.builder-command .actions>span{grid-column:1/-1;color:var(--shd-success);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.08em;text-align:right}.builder-command .builder-persistence{padding:10px 14px}.builder-status{min-width:0;flex-wrap:wrap;gap:5px 20px;padding:9px 14px;border-top:1px solid var(--shd-border);background:#0c0f10}.builder-status>span{min-width:0;overflow-wrap:anywhere}.builder-notes{min-width:0;display:grid;grid-template-columns:minmax(260px,.65fr) minmax(420px,1.35fr);gap:8px;padding:10px 14px;border-top:1px solid var(--shd-border);background:var(--shd-builder-canvas)}.builder-notes>*{min-width:0}.builder-notes>label{display:grid;align-content:start;gap:6px}.builder-notes>label>span{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.08em}.builder-notes>label small{color:var(--shd-text-muted)}.builder-notes textarea{width:100%;min-height:150px;padding:11px;border:1px solid var(--shd-border);border-radius:0;background:#0d0f10;color:var(--shd-text);font:var(--shd-type-body)/1.55 var(--shd-font-editorial);resize:vertical}.loadout-workspace{min-width:0;grid-template-columns:minmax(300px,350px) minmax(480px,1fr) minmax(290px,340px);gap:6px}.loadout-workspace>*,.loadout-editor>*,.loadout-summary>*{min-width:0}.loadout-editor{min-width:0;border-top-color:var(--shd-orange);background:var(--shd-surface)}.loadout-editor-head{min-width:0;gap:10px;margin:-12px -12px 10px;padding:11px 12px;border-bottom-color:var(--shd-border);background:#0c0f10}.loadout-editor-head>div{min-width:0}.loadout-editor-head h2,.loadout-editor-head>b{overflow-wrap:anywhere}.loadout-editor-head>b{flex:0 1 auto;text-align:right}.loadout-summary{min-width:0;background:var(--shd-surface)}.builder-picker-current>*{min-width:0}.builder-picker-current span,.builder-picker-panel button span{min-width:0}.builder-section-title{min-width:0;gap:10px}.builder-section-title>div{min-width:0}.builder-section-title small{overflow-wrap:anywhere}.builder-section-title>b{flex:0 0 auto;text-align:right}.builder-ability-chip-panels.single{grid-template-columns:1fr}.builder-ability-chip-panels.single section>div{grid-template-columns:repeat(3,minmax(0,1fr))}.builder-specialization-identity.ability-identity{grid-template-columns:54px minmax(0,1fr) auto}.builder-specialization-identity.ability-identity>.item-icon{width:50px;height:50px}.shared-build-guide{margin-top:10px;border:1px solid var(--shd-border);border-top:2px solid var(--shd-orange);background:var(--shd-panel)}.shared-build-guide>header{display:flex;justify-content:space-between;padding:9px 12px;border-bottom:1px solid var(--shd-border);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.09em}.shared-build-guide>header b{color:var(--shd-orange)}.shared-build-summary{max-width:90ch;margin:0;padding:15px 18px;border-bottom:1px solid var(--shd-border);color:var(--shd-text);font:16px/1.6 var(--shd-font-editorial)}.shared-build-guide>div{padding:18px}.shared-build-guide h2{margin:0 0 10px;font-size:18px;letter-spacing:.04em} @media(max-width:1320px){.loadout-workspace{grid-template-columns:minmax(270px,320px) minmax(440px,1fr)}.loadout-summary{position:static;grid-column:1/3}.summary-scroll{max-height:none;display:grid;grid-template-columns:repeat(3,1fr)}} +@media(max-width:1050px){.builder-command .page-title{align-items:stretch;flex-direction:column}.builder-command .page-title>div:first-child{min-width:0}.builder-command .actions{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:7px}.builder-command .actions>span{grid-column:1/-1}.builder-command .actions>.secondary,.builder-command .actions>.primary,.builder-command .actions>a,.builder-command .builder-save-form,.builder-command .builder-save-form fieldset,.builder-command .builder-save-form button{width:100%}} @media(max-width:800px){.builder-command .page-title>div:first-child{min-width:0}.builder-notes{grid-template-columns:1fr}.loadout-workspace{grid-template-columns:1fr}.loadout-workspace>.loadout-matrix,.loadout-editor,.loadout-summary{grid-column:auto}.builder-ability-chip-panels.single section>div{grid-template-columns:1fr}.builder-specialization-identity.ability-identity{grid-template-columns:48px minmax(0,1fr)}.builder-specialization-identity.ability-identity>b{grid-column:1/-1}.summary-scroll{display:block}} +@media(max-width:560px){.builder-command .actions{width:100%;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px}.builder-command .actions>span,.builder-command .actions>.primary,.builder-command .actions>a.primary,.builder-command .builder-save-form{grid-column:1/-1}.builder-command .actions>.secondary,.builder-command .actions>.primary,.builder-command .actions>a,.builder-command .builder-save-form,.builder-command .builder-save-form fieldset,.builder-command .builder-save-form button{width:100%}.builder-status{display:grid;grid-template-columns:1fr 1fr}.builder-section-title{align-items:flex-start;flex-direction:column}.builder-section-title>b{align-self:flex-start;text-align:left}.builder-rolls.required>div{grid-template-columns:1fr}.loadout-editor-head{align-items:flex-start;flex-wrap:wrap}.loadout-editor-head>b{margin-left:auto}.summary-head{gap:10px}.summary-head>b{flex:0 0 auto}} .weapon-mod-slot-config{margin-top:7px;padding-top:6px;border-top:1px solid var(--shd-border)}.weapon-mod-slot-config .assignment-picker{margin-top:5px}.weapon-mod-editor{border-top-color:var(--shd-orange)}.weapon-mod-identity{display:grid;grid-template-columns:minmax(300px,1.4fr) 52px minmax(280px,1fr);align-items:end;gap:8px}.weapon-mod-identity>.field,.weapon-mod-identity>.segmented-choice{margin:0}.weapon-mod-identity>.segmented-choice{grid-column:1/-1}.weapon-mod-identity>.segmented-choice>div{height:38px}.weapon-mod-assignment-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:10px}.weapon-mod-secondary{display:grid;grid-template-columns:1fr 1fr;gap:7px}.weapon-mod-quality-section,.weapon-mod-effect-section{margin-top:10px;padding:9px;border:1px solid var(--shd-border);background:#0d1011}.weapon-mod-quality-section{border-left:2px solid var(--quality-high-end)}.weapon-mod-quality-table{display:grid;margin-top:7px;border:1px solid var(--shd-border)}.weapon-mod-quality-row{display:grid;grid-template-columns:110px repeat(4,minmax(180px,1fr));align-items:center;border-top:1px solid var(--shd-border)}.weapon-mod-quality-row:first-child{border-top:0}.weapon-mod-quality-row.heading{min-height:29px;background:#111415;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.weapon-mod-quality-row>span,.weapon-mod-quality-row>strong{padding:8px}.weapon-mod-quality-row>strong{font-size:var(--shd-type-telemetry);letter-spacing:.7px}.weapon-mod-quality-row>div{display:grid;grid-template-columns:1fr 14px 1fr;align-items:center;gap:4px;padding:5px;border-left:1px solid var(--shd-border)}.weapon-mod-quality-row input{height:30px;margin:0;padding:0 6px;font-size:var(--shd-type-telemetry)}.weapon-mod-quality-row i{color:#596164;font-size:var(--shd-type-telemetry);font-style:normal;text-align:center}.weapon-mod-preview{display:grid;grid-template-columns:minmax(360px,.8fr) minmax(420px,1.2fr);align-items:end;gap:8px;margin-top:8px}.weapon-mod-preview>div:first-child{display:grid;grid-template-columns:1fr 1.2fr;align-items:end;gap:8px}.weapon-mod-preview>div:first-child>label{display:grid;gap:5px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.weapon-mod-preview input[type=range]{height:32px;margin:0;padding:0;accent-color:var(--shd-orange)}.weapon-mod-preview-values{display:grid;grid-template-columns:1.2fr 1fr 1fr;border:1px solid var(--shd-border)}.weapon-mod-preview-values>span{min-height:48px;display:grid;grid-template-columns:auto 1fr;grid-template-rows:1fr 1fr;align-items:center;gap:2px 7px;padding:7px;border-left:1px solid var(--shd-border)}.weapon-mod-preview-values>span:first-child{border-left:0}.weapon-mod-preview-values .game-icon{grid-row:1/3;width:28px;height:28px}.weapon-mod-preview-values small{align-self:end;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry)}.weapon-mod-preview-values b{align-self:start;color:var(--shd-orange);font-size:var(--shd-type-telemetry)}.weapon-mod-effects{display:grid;gap:6px;margin-top:7px}.weapon-mod-effects>div{display:grid;grid-template-columns:30px minmax(230px,1fr) repeat(4,minmax(90px,.45fr)) 30px;align-items:end;gap:6px;padding:6px;border:1px solid var(--shd-border);background:#101314}.weapon-mod-effects>div>strong{height:100%;display:grid;place-items:center;background:#171b1c;color:var(--shd-orange);font-size:var(--shd-type-telemetry)}.weapon-mod-effects label{display:grid;gap:4px}.weapon-mod-effects label span{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.5px}.weapon-mod-effects input{height:32px;margin:0;padding:0 6px}.weapon-mod-effects>div>button{height:32px;border:1px solid #563734;background:#1d1212;color:var(--shd-danger);cursor:pointer}.weapon-mod-effects>div>button:disabled{opacity:.3;cursor:not-allowed}.weapon-mod-rule{padding:10px;border:1px solid #74552a;border-left:2px solid var(--quality-high-end);background:#18150d;color:#a89568;font-size:var(--shd-type-telemetry)}.weapon-mod-rule b{color:var(--quality-high-end)}.weapon-mod-row{min-width:1240px;grid-template-columns:1.15fr .55fr .65fr 1fr 1fr .55fr .5fr .3fr}.weapon-mod-primary{display:flex;align-items:center;gap:5px}.weapon-mod-primary .game-icon{width:22px;height:22px}.weapon-mod-row>span small{color:var(--shd-text-muted)}@media(max-width:1100px){.weapon-mod-identity{grid-template-columns:1fr 52px 1fr}.weapon-mod-assignment-grid,.weapon-mod-preview{grid-template-columns:1fr}.weapon-mod-quality-table{overflow-x:auto}.weapon-mod-quality-row{min-width:900px}.weapon-mod-effects>div{grid-template-columns:30px 1fr 1fr 1fr}.weapon-mod-effects label:nth-of-type(3),.weapon-mod-effects label:nth-of-type(4),.weapon-mod-effects>div>button{grid-column:auto}}@media(max-width:700px){.weapon-mod-identity,.weapon-mod-assignment-grid,.weapon-mod-secondary,.weapon-mod-preview>div:first-child{grid-template-columns:1fr}.weapon-mod-identity>.segmented-choice{grid-column:auto}.weapon-mod-identity>.segmented-choice>div{height:auto;grid-template-columns:repeat(2,1fr)}.weapon-mod-identity>.segmented-choice span{min-height:44px}.weapon-mod-effects>div{grid-template-columns:30px 1fr}.weapon-mod-effects label,.weapon-mod-effects>div>button{grid-column:2}.weapon-mod-preview-values{grid-template-columns:1fr}.weapon-mod-preview-values>span{border-left:0;border-top:1px solid var(--shd-border)}.weapon-mod-preview-values>span:first-child{border-top:0}} .weapon-mod-primary-types{min-height:58px;display:grid;align-content:center;gap:5px;padding:7px;border:1px solid var(--shd-border);background:#0d1011}.weapon-mod-primary-types>span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.weapon-mod-primary-types>div{display:flex;gap:8px}.weapon-mod-primary-types b{display:flex;align-items:center;gap:3px;color:#aab0b1;font-size:var(--shd-type-telemetry)}.weapon-mod-primary-types .game-icon{width:18px;height:18px}.weapon-mod-primary-types small{color:#596164;font-size:var(--shd-type-telemetry)}.weapon-mod-compatibility{margin-top:10px}.weapon-mod-quality-row{grid-template-columns:110px repeat(2,minmax(220px,1fr))}.weapon-mod-quality-row.heading{grid-template-columns:110px repeat(2,minmax(220px,1fr))}.weapon-mod-preview-values{grid-template-columns:1.3fr .7fr}.weapon-mod-preview-values .core-icon-stack{grid-row:1/3}.weapon-mod-preview-values .core-icon-stack .game-icon{grid-row:auto;width:22px;height:22px}.weapon-mod-secondary-section{margin-top:10px;padding:9px;border:1px solid var(--shd-border);border-left:2px solid var(--quality-specialized);background:#0d1011}.weapon-mod-secondary-options{display:grid;gap:6px;margin-top:7px}.weapon-mod-secondary-options>div{display:grid;grid-template-columns:30px minmax(230px,1fr) repeat(4,minmax(150px,.65fr)) 30px;align-items:end;gap:6px;padding:6px;border:1px solid var(--shd-border);background:#101314}.weapon-mod-secondary-options>div>strong{height:100%;display:grid;place-items:center;background:#171b1c;color:var(--shd-orange);font-size:var(--shd-type-telemetry)}.weapon-mod-secondary-options label{display:grid;gap:4px}.weapon-mod-secondary-options label>span{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.5px}.weapon-mod-secondary-options label>div{display:grid;grid-template-columns:1fr 12px 1fr;align-items:center}.weapon-mod-secondary-options input{height:32px;margin:0;padding:0 5px}.weapon-mod-secondary-options i{color:#596164;font-size:var(--shd-type-telemetry);font-style:normal;text-align:center}.weapon-mod-secondary-options>div>button{height:32px;border:1px solid #563734;background:#1d1212;color:var(--shd-danger);cursor:pointer}.weapon-mod-secondary-options>div>button:disabled{opacity:.3;cursor:not-allowed}@media(max-width:1100px){.weapon-mod-quality-row,.weapon-mod-quality-row.heading{min-width:580px}.weapon-mod-secondary-options>div{grid-template-columns:30px 1fr 1fr 1fr}.weapon-mod-secondary-options label:nth-of-type(3),.weapon-mod-secondary-options label:nth-of-type(4){grid-column:auto}}@media(max-width:700px){.weapon-mod-secondary-options>div{grid-template-columns:30px 1fr}.weapon-mod-secondary-options label,.weapon-mod-secondary-options>div>button{grid-column:2}} .weapon-mod-exotic-quality{min-height:58px;display:flex;align-items:center;gap:8px;padding:7px;border:1px solid #74552a;border-left:2px solid var(--quality-high-end);background:#18150d}.weapon-mod-exotic-quality .game-icon{width:34px;height:34px}.weapon-mod-exotic-quality span{display:grid;gap:3px}.weapon-mod-exotic-quality b{color:var(--quality-high-end);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.weapon-mod-exotic-quality small{color:#a89568;font-size:var(--shd-type-telemetry);letter-spacing:.35px} @@ -287,7 +289,7 @@ .combat-effect-definition-grid{grid-template-columns:minmax(220px,.7fr) minmax(360px,1.3fr)}.combat-effect-template textarea{min-height:86px} .specialization-attack-profile{display:grid;gap:var(--shd-gap-sm);padding:var(--shd-gap-sm);border:1px solid var(--shd-model-border);border-left:2px solid var(--shd-info);background:var(--shd-model-surface)}.specialization-attack-profile>.segmented-choice{margin:0}.specialization-attack-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--shd-gap-sm);align-items:end}.specialization-attack-grid>.field,.specialization-attack-grid>.segmented-choice{margin:0}.calculation-note{margin:0;padding:8px;border:1px solid var(--shd-border-soft);color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.45} .rank-combat-effects{display:grid;gap:var(--shd-gap-sm);margin-top:var(--shd-gap-md);padding-top:var(--shd-gap-md);border-top:1px solid var(--shd-border)}.rank-combat-effect{display:grid;gap:var(--shd-gap-sm);padding:var(--shd-gap-sm);border:1px solid var(--shd-model-border);border-left:2px solid var(--shd-info);background:var(--shd-model-surface)}.rank-combat-effect>header{display:flex;align-items:center;justify-content:space-between}.rank-combat-effect>header>div{display:grid;gap:2px}.rank-combat-effect>header span,.rank-combat-effect>header small{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.rank-combat-effect>header span{color:var(--shd-info)}.combat-trigger-grid{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:var(--shd-gap-sm)}.combat-lifecycle-facts{display:flex;flex-wrap:wrap;gap:var(--shd-gap-xs)}.combat-lifecycle-facts span{padding:5px 7px;border:1px solid var(--shd-border);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);letter-spacing:.6px}.combat-lifecycle-facts b{color:var(--shd-text)}.combat-modifier-list{display:grid;gap:var(--shd-gap-xs)}.combat-modifier-list>div{display:grid;grid-template-columns:minmax(220px,1fr) 150px 140px auto;gap:var(--shd-gap-sm);align-items:end;padding:6px;border-top:1px solid var(--shd-border-soft)}.combat-modifier-list>div>.field{margin:0} -.builder-remediation{display:grid;gap:3px;padding:9px;border:1px solid var(--shd-danger);border-left-width:2px;background:#1d1212}.builder-remediation strong{color:var(--shd-danger);font-size:var(--shd-type-telemetry);letter-spacing:.9px}.builder-remediation span{color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.builder-specialization-identity{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:var(--shd-gap-md);align-items:center;padding:var(--shd-gap-md);border:1px solid var(--shd-model-border);border-left:2px solid var(--shd-orange);background:var(--shd-model-surface)}.builder-specialization-identity h3,.builder-specialization-identity p{margin:0}.builder-specialization-identity small,.builder-specialization-identity>b{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.builder-specialization-identity p{margin-top:4px;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.builder-specialization-identity>b{padding:5px 7px;border:1px solid var(--shd-border);color:var(--shd-text-muted)}.builder-specialization-identity>b.configured{border-color:#365d44;color:var(--shd-success)}.builder-ability-chip-panels{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--shd-gap-sm)}.builder-ability-chip-panels>section{display:grid;grid-template-rows:auto minmax(48px,auto) auto;gap:var(--shd-gap-sm);padding:var(--shd-gap-sm);border:1px solid var(--shd-border);background:var(--shd-surface)}.builder-ability-chip-panels header{display:grid;grid-template-columns:auto 1fr auto;gap:var(--shd-gap-sm);align-items:center}.builder-ability-chip-panels header img{width:38px;height:38px}.builder-ability-chip-panels header div{display:grid}.builder-ability-chip-panels header small,.builder-ability-chip-panels header b{font-size:var(--shd-type-telemetry);letter-spacing:.7px}.builder-ability-chip-panels>section>p{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.builder-ability-chip-panels>section>div{display:grid;gap:4px}.builder-ability-chip-panels>section>div>button{min-height:54px;display:grid;grid-template-columns:70px 1fr;grid-template-rows:auto auto;gap:1px 8px;padding:7px;border:1px solid var(--shd-border);background:#0d1011;color:var(--shd-text);text-align:left;cursor:pointer}.builder-ability-chip-panels>section>div>button:hover,.builder-ability-chip-panels>section>div>button:focus-visible{border-color:var(--shd-orange)}.builder-ability-chip-panels button small{grid-row:1/3;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900}.builder-ability-chip-panels button span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry)}.builder-specialization-telemetry{display:flex;flex-wrap:wrap;gap:var(--shd-gap-xs)}.builder-specialization-telemetry span{padding:6px 8px;border:1px solid var(--shd-border);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.builder-specialization-telemetry b{color:var(--shd-orange)}.builder-focus-tree{display:grid;gap:var(--shd-gap-sm)}.builder-focus-tree>section{border:1px solid var(--shd-border);background:var(--shd-model-surface)}.builder-focus-tree>section>header{display:flex;justify-content:space-between;padding:6px 8px;border-bottom:1px solid var(--shd-border);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.builder-focus-tree>section>header span{color:var(--shd-orange)}.builder-focus-tree>section>div{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:var(--shd-gap-xs);padding:var(--shd-gap-xs)}.builder-focus-tree article{display:grid;grid-template-columns:auto 1fr;gap:var(--shd-gap-sm);padding:var(--shd-gap-sm);border:1px solid var(--shd-border);background:var(--shd-surface)}.builder-focus-tree article>img{width:42px;height:42px}.builder-focus-tree article>div{display:grid;gap:3px}.builder-focus-tree article small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);line-height:1.35}.builder-focus-tree article>.segmented-choice{grid-column:1/-1;margin:0}.segmented-choice span.unavailable{opacity:.38;cursor:not-allowed;filter:saturate(.25)}.builder-conditional-effects,.builder-combat-results{display:grid;gap:var(--shd-gap-sm)}.builder-combat-results{padding:var(--shd-gap-sm);border-bottom:1px solid var(--shd-border)}.builder-return-link{width:max-content;min-height:36px;padding:7px 9px;border:1px solid var(--shd-border);background:transparent;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px;cursor:pointer}.builder-return-link:hover,.builder-return-link:focus-visible{border-color:var(--shd-orange)} +.builder-remediation{display:grid;gap:3px;padding:9px;border:1px solid var(--shd-danger);border-left-width:2px;background:#1d1212}.builder-remediation strong{color:var(--shd-danger);font-size:var(--shd-type-telemetry);letter-spacing:.9px}.builder-remediation span{color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.builder-specialization-identity{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:var(--shd-gap-md);align-items:center;padding:var(--shd-gap-md);border:1px solid var(--shd-model-border);border-left:2px solid var(--shd-orange);background:var(--shd-model-surface)}.builder-specialization-identity h3,.builder-specialization-identity p{margin:0}.builder-specialization-identity small,.builder-specialization-identity>b{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.builder-specialization-identity p{margin-top:4px;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.builder-specialization-identity>b{padding:5px 7px;border:1px solid var(--shd-border);color:var(--shd-text-muted)}.builder-specialization-identity>b.configured{border-color:#365d44;color:var(--shd-success)}.builder-ability-chip-panels{min-width:0;display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--shd-gap-sm)}.builder-ability-chip-panels>section{min-width:0;display:grid;grid-template-rows:auto minmax(48px,auto) auto;gap:var(--shd-gap-sm);padding:var(--shd-gap-sm);border:1px solid var(--shd-border);background:var(--shd-surface)}.builder-ability-chip-panels header{min-width:0;display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:var(--shd-gap-sm);align-items:center}.builder-ability-chip-panels header img{width:38px;height:38px}.builder-ability-chip-panels header div{min-width:0;display:grid}.builder-ability-chip-panels header strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.builder-ability-chip-panels header small,.builder-ability-chip-panels header b{font-size:var(--shd-type-telemetry);letter-spacing:.7px}.builder-ability-chip-panels header b{white-space:nowrap}.builder-ability-chip-panels>section>p{min-width:0;margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);overflow-wrap:anywhere}.builder-ability-chip-panels>section>div{min-width:0;display:grid;gap:4px}.builder-ability-chip-panels>section>div>button{min-width:0;min-height:54px;display:grid;grid-template-columns:70px minmax(0,1fr);grid-template-rows:auto auto;gap:1px 8px;padding:7px;border:1px solid var(--shd-border);background:#0d1011;color:var(--shd-text);text-align:left;cursor:pointer}.builder-ability-chip-panels>section>div>button:hover,.builder-ability-chip-panels>section>div>button:focus-visible{border-color:var(--shd-orange)}.builder-ability-chip-panels button small{grid-row:1/3;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900}.builder-ability-chip-panels button strong,.builder-ability-chip-panels button span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.builder-ability-chip-panels button span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry)}.builder-specialization-telemetry{display:flex;flex-wrap:wrap;gap:var(--shd-gap-xs)}.builder-specialization-telemetry span{padding:6px 8px;border:1px solid var(--shd-border);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.builder-specialization-telemetry b{color:var(--shd-orange)}.builder-focus-tree{display:grid;gap:var(--shd-gap-sm)}.builder-focus-tree>section{border:1px solid var(--shd-border);background:var(--shd-model-surface)}.builder-focus-tree>section>header{display:flex;justify-content:space-between;padding:6px 8px;border-bottom:1px solid var(--shd-border);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.builder-focus-tree>section>header span{color:var(--shd-orange)}.builder-focus-tree>section>div{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:var(--shd-gap-xs);padding:var(--shd-gap-xs)}.builder-focus-tree article{display:grid;grid-template-columns:auto 1fr;gap:var(--shd-gap-sm);padding:var(--shd-gap-sm);border:1px solid var(--shd-border);background:var(--shd-surface)}.builder-focus-tree article>img{width:42px;height:42px}.builder-focus-tree article>div{display:grid;gap:3px}.builder-focus-tree article small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);line-height:1.35}.builder-focus-tree article>.segmented-choice{grid-column:1/-1;margin:0}.segmented-choice span.unavailable{opacity:.38;cursor:not-allowed;filter:saturate(.25)}.builder-conditional-effects,.builder-combat-results{display:grid;gap:var(--shd-gap-sm)}.builder-combat-results{padding:var(--shd-gap-sm);border-bottom:1px solid var(--shd-border)}.builder-return-link{width:max-content;min-height:36px;padding:7px 9px;border:1px solid var(--shd-border);background:transparent;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px;cursor:pointer}.builder-return-link:hover,.builder-return-link:focus-visible{border-color:var(--shd-orange)} .builder-skill-activation{display:grid;gap:var(--shd-gap-sm)}.builder-skill-activation>section{display:grid;grid-template-columns:minmax(180px,.7fr) minmax(340px,1.3fr);gap:var(--shd-gap-sm);align-items:end;padding:var(--shd-gap-sm);border:1px solid var(--shd-model-border);border-left:2px solid var(--shd-info);background:var(--shd-model-surface)}.builder-skill-activation>section>header{display:grid;grid-template-columns:auto 1fr;gap:var(--shd-gap-sm);align-items:center}.builder-skill-activation>section>header img{width:40px;height:40px}.builder-skill-activation>section>header div{display:grid;gap:2px}.builder-skill-activation>section>header small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.builder-skill-activation>section>.segmented-choice{margin:0}.builder-skill-assumptions{grid-column:1/-1;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--shd-gap-sm)} @media(max-width:1050px){.combat-trigger-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.combat-modifier-list>div{grid-template-columns:1fr 1fr}.builder-ability-chip-panels{grid-template-columns:1fr}.builder-specialization-identity{grid-template-columns:auto 1fr}.builder-specialization-identity>b{grid-column:1/-1;width:max-content}.builder-skill-activation>section{grid-template-columns:1fr}.specialization-attack-grid{grid-template-columns:repeat(2,minmax(0,1fr))}} @media(max-width:650px){.combat-effect-definition-grid,.combat-trigger-grid,.combat-modifier-list>div,.specialization-attack-grid{grid-template-columns:1fr}.rank-combat-effect button,.builder-return-link,.builder-ability-chip-panels button{min-height:44px}.builder-specialization-identity{grid-template-columns:1fr}.builder-specialization-identity .focus-composite-icon{justify-self:start}.builder-skill-assumptions{grid-template-columns:1fr}} @@ -1110,7 +1112,7 @@ .dashboard-row>.row-action:active, .dashboard-row>.table-action:active{transform:none} } - + .image-library-editor{margin:10px 0}.image-library-form{display:grid;grid-template-columns:minmax(330px,.8fr) minmax(440px,1.2fr);gap:12px;margin-top:12px}.image-library-upload .image-drop-input{height:100%;min-height:168px}.image-library-upload .image-upload-preview{width:112px;height:112px}.image-library-metadata{display:grid;grid-template-columns:1fr 1fr;align-content:start;gap:8px}.image-library-metadata .field{margin:0}.image-library-metadata .full,.image-processing-options{grid-column:1/-1}.image-processing-options{display:grid;grid-template-columns:1fr 1fr;border:1px solid var(--shd-border);background:#0d1011}.image-processing-options label{min-height:58px;display:grid;grid-template-columns:auto 1fr;align-items:center;gap:9px;padding:9px;cursor:pointer}.image-processing-options label+label{border-left:1px solid var(--shd-border)}.image-processing-options input{width:auto;margin:0;accent-color:var(--shd-orange)}.image-processing-options span{display:grid;gap:3px}.image-processing-options b{font-size:var(--shd-type-telemetry);letter-spacing:.8px}.image-processing-options small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);line-height:1.35}.image-processing-options label:has(input:checked){background:#1b170e;box-shadow:inset 0 -2px var(--shd-orange)}.image-processing-options label.disabled{opacity:.42;cursor:not-allowed}.image-metadata-edit{display:grid;grid-template-columns:72px 1fr 1fr 1.4fr;align-items:end;gap:9px;margin-top:10px}.image-metadata-edit>img{width:72px;height:72px;object-fit:contain;padding:5px;border:1px solid var(--shd-border);background:#090b0c}.image-metadata-edit .field{margin:0}.image-library-grid{display:grid;grid-template-columns:repeat(4,minmax(190px,1fr));gap:8px;margin-top:8px}.image-library-grid article{min-width:0;border:1px solid var(--shd-border);border-top:2px solid #4a5152;background:var(--shd-surface);transition:var(--shd-transition)}.image-library-grid article.icon{border-top-color:var(--shd-orange)}.image-library-grid article:hover{border-color:#596061;background:#15191a}.image-library-preview{height:150px;display:grid;place-items:center;padding:14px;border-bottom:1px solid var(--shd-border);background:linear-gradient(135deg,#0a0c0d 25%,#101314 25%,#101314 50%,#0a0c0d 50%,#0a0c0d 75%,#101314 75%);background-size:16px 16px}.image-library-preview img{display:block;max-width:100%;max-height:100%;object-fit:contain}.image-library-grid header{position:static;height:auto;min-height:54px;display:flex;align-items:center;justify-content:space-between;padding:9px 11px;border-bottom:1px solid var(--shd-border);background:transparent}.image-library-grid header>div{min-width:0;display:grid;gap:3px}.image-library-grid header strong{overflow:hidden;font-size:13px;text-overflow:ellipsis;white-space:nowrap}.image-library-grid header span,.image-library-grid header b{font-size:var(--shd-type-telemetry);letter-spacing:.8px}.image-library-grid header span{color:var(--shd-text-muted)}.image-library-grid header b{color:var(--shd-orange)}.image-library-tags{min-height:42px;display:flex;align-content:flex-start;flex-wrap:wrap;gap:4px;padding:8px 10px}.image-library-tags span{width:max-content;padding:3px 5px;border:1px solid #3c4243;color:#92999b;font-size:var(--shd-type-telemetry)}.image-library-tags small{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry)}.image-library-grid footer{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:7px 10px;border-top:1px solid var(--shd-border)}.image-library-grid footer span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry)}.image-library-grid footer button{border:0;background:transparent;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;cursor:pointer}.image-library-empty{grid-column:1/-1;min-height:180px;display:grid;place-content:center;gap:6px;border:1px dashed var(--shd-border);color:var(--shd-text-muted);text-align:center}.image-library-empty strong{color:var(--shd-text)}@media(max-width:1100px){.image-library-grid{grid-template-columns:repeat(3,minmax(180px,1fr))}}@media(max-width:800px){.image-library-form{grid-template-columns:1fr}.image-library-grid{grid-template-columns:repeat(2,minmax(150px,1fr))}.image-metadata-edit{grid-template-columns:72px 1fr}.image-metadata-edit .field:last-child{grid-column:1/-1}}@media(max-width:560px){.image-library-metadata,.image-processing-options,.image-library-grid{grid-template-columns:1fr}.image-library-metadata .full,.image-processing-options{grid-column:auto}.image-processing-options label+label{border-left:0;border-top:1px solid var(--shd-border)}.image-library-preview{height:132px}.image-metadata-edit{grid-template-columns:1fr}.image-metadata-edit>img{width:100%;height:96px}} /* Managed image library: keep previews contained and reserve clear drop-zone lanes. */ @@ -2424,6 +2426,7 @@ .assignment-results>button.with-icon{min-height:44px;display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:9px} .assignment-result-icon{width:32px;height:32px} .assignment-results>button.with-icon>span{min-width:0;overflow-wrap:anywhere} +.assignment-results>button:disabled,.assignment-results>button:disabled:hover{cursor:not-allowed;opacity:.58;background:var(--shd-bg);color:var(--shd-text-muted);box-shadow:none;transform:none;filter:none} .assignment-picker.visual-grid .assignment-results{display:grid;grid-template-columns:repeat(auto-fill,minmax(52px,1fr));grid-auto-rows:52px;gap:2px;max-height:none;padding:2px;overflow:visible;background:var(--shd-border-soft)} .assignment-picker.visual-grid .assignment-results>button.visual-grid-option{position:relative;min-width:0;min-height:44px;display:grid;place-items:center;padding:5px;border:0;background:var(--shd-surface)} .assignment-grid-icon{width:38px;height:38px} diff --git a/src/lib/builder/loadout-combat.test.ts b/src/lib/builder/loadout-combat.test.ts index 9a2a7bf..ccb6793 100644 --- a/src/lib/builder/loadout-combat.test.ts +++ b/src/lib/builder/loadout-combat.test.ts @@ -2,12 +2,31 @@ import { describe, expect, it } from 'vitest'; import { evaluateMechanicProgram, type MechanicProgram } from '$lib/calculations/mechanic'; import { createDraft } from './loadout.js'; import { + buildSkillDeploymentEvents, buildWeaponMechanicTimeline, + equippedConditionalProgram, mechanicModifiersAt, + skillModifierWindowsForMechanic, weaponCriticalChance } from './loadout-combat.js'; +import { calculateSkillDps, type SkillAbilityInput } from '$lib/calculations/skill'; describe('loadout mechanic timeline', () => { + function magazineBandsFor(magazine: number, encounterSeconds = magazine - 1) { + const profile = createDraft().combatProfile; + profile.encounterSeconds = encounterSeconds; + profile.hitRate = 0; + profile.shotsPerBurst = 100; + profile.burstPauseSeconds = 0; + + return buildWeaponMechanicTimeline( + { rpm: 60, magazine, reload_speed: 0 }, + profile + ) + .filter((event) => event.kind === 'shot_fired') + .map((event) => event.values?.magazineTwentyPercentBandsCrossed); + } + it('emits deterministic critical hits from modeled critical chance', () => { const profile = createDraft().combatProfile; profile.encounterSeconds = 4; @@ -27,6 +46,86 @@ describe('loadout mechanic timeline', () => { expect(criticalHits).toEqual([false, true, false, true, false]); }); + it('marks every fired shot with its deterministic hit result', () => { + const profile = createDraft().combatProfile; + profile.encounterSeconds = 4; + profile.hitRate = 50; + profile.shotsPerBurst = 100; + profile.burstPauseSeconds = 0; + + const hitResults = buildWeaponMechanicTimeline( + { rpm: 60, magazine: 100, reload_speed: 0 }, + profile + ) + .filter((event) => event.kind === 'shot_fired') + .map((event) => event.values?.hit); + + expect(hitResults).toEqual([false, true, false, true, false]); + }); + + it('marks enemy kills from deterministic critical killing shots', () => { + const profile = createDraft().combatProfile; + profile.encounterSeconds = 30; + profile.killsPerMinute = 4; + + const criticalKills = buildWeaponMechanicTimeline( + { rpm: 60, magazine: 100, reload_speed: 0 }, + profile, + 50 + ) + .filter((event) => event.kind === 'enemy_killed') + .map((event) => event.values?.critical); + + expect(criticalKills).toEqual([false, true]); + }); + + it('marks enemy kills from the configured headshot-kill rate', () => { + const profile = createDraft().combatProfile; + profile.encounterSeconds = 30; + profile.killsPerMinute = 8; + profile.headshotKillRate = 50; + + const headshotKills = buildWeaponMechanicTimeline( + { rpm: 60, magazine: 100, reload_speed: 0 }, + profile + ) + .filter((event) => event.kind === 'enemy_killed') + .map((event) => event.values?.headshot); + + expect(headshotKills).toEqual([false, true, false, true]); + }); + + it('marks reload starts from the pre-reload empty-magazine state', () => { + const profile = createDraft().combatProfile; + profile.encounterSeconds = 2; + profile.shotsPerBurst = 100; + profile.burstPauseSeconds = 0; + + const emptyStates = buildWeaponMechanicTimeline( + { rpm: 60, magazine: 2, reload_speed: 5 }, + profile + ) + .filter((event) => event.kind === 'reload_started') + .map((event) => event.values?.emptyMagazine); + + expect(emptyStates).toEqual([true]); + }); + + it('emits post-shot 20% magazine-band crossings for an even magazine', () => { + expect(magazineBandsFor(10)).toEqual([0, 1, 0, 1, 0, 1, 0, 1, 0, 0]); + }); + + it.each([ + { magazine: 7, expected: [0, 1, 1, 0, 1, 1, 0] }, + { magazine: 3, expected: [1, 2, 1] } + ])('counts every crossed band for an odd/small $magazine-round magazine', ({ magazine, expected }) => { + expect(magazineBandsFor(magazine)).toEqual(expected); + }); + + it('restarts magazine-band crossings after reloading', () => { + expect(magazineBandsFor(5, 5)).toEqual([1, 1, 1, 1, 0, 1]); + }); + it('derives critical chance through the configured binding transform', () => { expect(weaponCriticalChance( { critical: 80 }, @@ -40,6 +139,367 @@ describe('loadout mechanic timeline', () => { }] )).toBe(30); }); + + it('publishes one standardized movement state and lets sprinting win', () => { + const profile = createDraft().combatProfile; + profile.encounterSeconds = 1; + + const moving = buildWeaponMechanicTimeline( + { rpm: 60, magazine: 100, reload_speed: 0 }, + profile, + 0, + { scenarioInputs: { moving: true, sprinting: false } } + ).filter((event) => event.kind === 'movement_snapshot'); + const sprinting = buildWeaponMechanicTimeline( + { rpm: 60, magazine: 100, reload_speed: 0 }, + profile, + 0, + { scenarioInputs: { moving: true, sprinting: true } } + ).filter((event) => event.kind === 'movement_snapshot'); + + expect(moving.map((event) => event.values)).toEqual([ + { state: 'moving' }, + { state: 'moving' } + ]); + expect(sprinting.every((event) => event.values?.state === 'sprinting')).toBe(true); + }); + + it('falls back to a documented Tier I mechanic when equipment Tier II is absent', () => { + const persistedRule = { + rule: { + id: 'tier-i-only', + source_type: 'talent', + source_id: 'exotic-talent', + configured: 1, + script_version: 1, + simulation_readiness: 'ready', + scenario_inputs_json: '[]' + }, + statePools: [], + clauses: [{ + id: 'shot-clause', + rule_id: 'tier-i-only', + clause_key: 'shot', + label: 'Shot', + trigger_id: null, + event_kind: 'shot_fired', + parameters_json: '{}', + conditions_json: JSON.stringify({ kind: 'true' }), + reducer_json: JSON.stringify({ kind: 'none' }), + emission_kind: 'each_match', + state_pool_id: null, + mutations_json: '[]', + cooldown_seconds: null, + output_branch: 'active', + sort_order: 1, + active: 1 + }], + outcomes: [{ + id: 'weapon-damage-i', + rule_id: 'tier-i-only', + clause_id: 'shot-clause', + state_pool_id: null, + effect_key: 'Weapon Damage', + variant_key: 'I', + outcome_key: 'weapon-damage', + application_kind: 'active_modifier', + effect_scope: 'equipped_weapon', + stat_attribute_id: 'weapon-damage', + value: 15, + value_expression_json: null, + value_basis: 'flat', + resource_kind: null, + combat_timing: null, + delivery_mode: 'instant', + delivery_duration_seconds: null, + targeting_json: null, + value_scaling: 'flat', + unit: '%', + damage_basis: null, + application_phase: 'on_event', + min_state_value: null, + max_state_value: null, + description_slot: 1, + sort_order: 1 + }], + triggers: [] + }; + + const adapted = equippedConditionalProgram({ + key: 'weapon:0:exotic-talent', + tier: 2, + rule: persistedRule + }); + + expect(adapted?.clauses[0].outcomes?.[0]).toMatchObject({ + id: 'weapon-damage-i', + value: 15 + }); + }); +}); + +describe('movement-charge exotic mechanic wiring', () => { + const ability: SkillAbilityInput = { + id: 'skill-a', + baseDamage: 100, + damageType: 'kinetic', + delivery: { type: 'direct', hits: 1 }, + castSeconds: 0, + cooldownSeconds: 10, + targetCap: 1, + targetCount: 1, + chargePolicy: { type: 'none' }, + activation: { mode: 'on_cooldown' } + }; + const program: MechanicProgram = { + id: 'movement-charge-exotic', + name: 'Movement Charge Exotic', + scenarioInputs: [ + { + key: 'moving', + label: 'Moving', + kind: 'toggle', + defaultValue: false, + bestCaseValue: true + }, + { + key: 'sprinting', + label: 'Sprinting', + kind: 'toggle', + defaultValue: false, + bestCaseValue: true + } + ], + pools: [ + { + key: 'movement-charge', + label: 'Movement charge', + scope: 'agent', + initial: 0, + min: 0, + max: 40, + bestCaseValue: 40 + }, + { + key: 'kill-speed', + label: 'Kill movement speed', + scope: 'agent', + initial: 0, + min: 0, + max: 1, + expiresAfterSeconds: 10 + } + ], + clauses: [ + { + id: 'gain-while-moving', + when: 'movement_snapshot', + if: { + kind: 'compare', + left: { source: 'event', path: 'values.state' }, + operator: 'eq', + right: 'moving' + }, + reducer: { kind: 'periodic', intervalSeconds: 1 }, + mutations: [{ kind: 'add', pool: 'movement-charge', value: 4 }] + }, + { + id: 'gain-while-sprinting', + when: 'movement_snapshot', + if: { + kind: 'compare', + left: { source: 'event', path: 'values.state' }, + operator: 'eq', + right: 'sprinting' + }, + reducer: { kind: 'periodic', intervalSeconds: 1 }, + mutations: [{ kind: 'add', pool: 'movement-charge', value: 8 }] + }, + { + id: 'spend-on-shot', + when: 'shot_fired', + if: { + kind: 'compare', + left: { source: 'pool', pool: 'movement-charge' }, + operator: 'gt', + right: 0 + }, + reducer: { kind: 'none' }, + mutations: [{ kind: 'consume', pool: 'movement-charge', value: 1 }], + outcomes: [{ + id: 'shot-weapon-damage', + label: 'Shot Weapon Damage', + phase: 'on_event', + kind: 'attribute_modifier', + scope: 'equipped_weapon', + value: 15, + basis: 'flat', + attributeId: 'weapon-damage' + }] + }, + { + id: 'spend-on-skill', + when: 'skill_deployed', + if: { + kind: 'compare', + left: { source: 'pool', pool: 'movement-charge' }, + operator: 'gte', + right: 10 + }, + reducer: { kind: 'none' }, + mutations: [{ kind: 'consume', pool: 'movement-charge', value: 10 }], + outcomes: [{ + id: 'deployment-skill-damage', + label: 'Deployment Skill Damage', + phase: 'on_event', + kind: 'attribute_modifier', + scope: 'all_skills', + value: 15, + basis: 'flat', + attributeId: 'skill-damage' + }] + }, + { + id: 'speed-on-kill', + when: 'enemy_killed', + reducer: { kind: 'none' }, + mutations: [{ kind: 'set', pool: 'kill-speed', value: 1 }], + outcomes: [{ + id: 'kill-movement-speed', + label: 'Kill Movement Speed', + phase: 'while_active', + kind: 'attribute_modifier', + scope: 'self', + value: 10, + basis: 'flat', + attributeId: 'movement-speed', + statePool: 'kill-speed' + }] + } + ] + }; + + it('gains stacks, consumes them on shots and eligible deployments, and applies timed outcomes', () => { + const profile = createDraft().combatProfile; + profile.encounterSeconds = 15; + const skillDeployments = buildSkillDeploymentEvents([ability], 15); + const events = [ + ...buildWeaponMechanicTimeline( + { rpm: 60, magazine: 100, reload_speed: 0 }, + profile, + 0, + { + scenarioInputs: { moving: true, sprinting: false }, + skillDeploymentEvents: skillDeployments + } + ).filter((event) => + event.kind === 'movement_snapshot' || event.kind === 'skill_deployed'), + { kind: 'shot_fired' as const, at: 3.1 }, + { kind: 'enemy_killed' as const, at: 4 } + ]; + const result = evaluateMechanicProgram( + program, + events, + { + mode: 'custom', + encounterSeconds: 15, + inputs: { moving: true, sprinting: false } + } + ); + + expect(skillDeployments).toEqual([ + { + kind: 'skill_deployed', + at: 0, + targetId: 'skill-a', + values: { abilityId: 'skill-a' } + }, + { + kind: 'skill_deployed', + at: 10, + targetId: 'skill-a', + values: { abilityId: 'skill-a' } + } + ]); + expect(result.status).toBe('OK'); + expect(result.emissions.filter((emission) => emission.clauseId === 'spend-on-skill')) + .toHaveLength(1); + expect(result.emissions.find((emission) => emission.clauseId === 'spend-on-shot')?.mutations[0]) + .toMatchObject({ before: 12, after: 11 }); + expect(result.emissions.find((emission) => emission.clauseId === 'spend-on-skill')?.mutations[0]) + .toMatchObject({ before: 39, after: 29 }); + expect(result.outcomes).toEqual(expect.arrayContaining([ + expect.objectContaining({ + outcomeId: 'shot-weapon-damage', + at: 3.1, + appliedValue: 15 + }), + expect.objectContaining({ + outcomeId: 'deployment-skill-damage', + at: 10, + targetId: 'skill-a', + appliedValue: 15 + }), + expect.objectContaining({ + outcomeId: 'kill-movement-speed', + at: 4, + appliedValue: 10 + }) + ])); + expect(result.finalState).toContainEqual(expect.objectContaining({ + pool: 'kill-speed', + value: 0, + expiresAt: null + })); + + const source = { + status: 'OK' as const, + result, + program, + encounterSeconds: 15, + events + }; + const skillWindows = skillModifierWindowsForMechanic( + source, + null, + new Set(['skill-a']), + new Set(['skill-damage']) + ); + const movementWindows = skillModifierWindowsForMechanic( + source, + null, + new Set(['skill-a']), + new Set(['movement-speed']) + ); + expect(skillWindows).toEqual([{ + startSeconds: 10, + endSeconds: 15, + attributes: { 'skill-damage': 15 }, + abilityIds: ['skill-a'] + }]); + expect(movementWindows).toEqual([{ + startSeconds: 4, + endSeconds: 14, + attributes: { 'movement-speed': 10 }, + abilityIds: undefined + }]); + + const skillResult = calculateSkillDps({ + rules: { + revisionId: 'test', + bindings: [{ attributeId: 'skill-damage', stage: 'skill_damage', operator: 'add' }] + }, + abilities: [ability], + attributes: {}, + modifierWindows: skillWindows, + assumptions: { encounterSeconds: 15, targetCount: 1 } + }); + expect(skillResult.status).toBe('OK'); + if (skillResult.status === 'OK') { + expect(skillResult.abilities[0].activationCount).toBe(2); + expect(skillResult.abilities[0].damage.singleTargetEncounterDamage).toBe(215); + } + }); }); describe('per-shot mechanic modifiers', () => { diff --git a/src/lib/builder/loadout-combat.ts b/src/lib/builder/loadout-combat.ts index fe7cce7..e772e99 100644 --- a/src/lib/builder/loadout-combat.ts +++ b/src/lib/builder/loadout-combat.ts @@ -17,8 +17,12 @@ import { import { adaptConditionalEffectProgram } from '$lib/calculations/mechanic-persistence'; import { calculateSkillDps, + skillActivationTimes, + type SkillAbilityInput, type SkillAttributeBinding, type SkillCalculation, + type SkillCalculationInput, + type SkillCalculationRules, type SkillModifierWindow } from '$lib/calculations/skill'; import { gearSlots, scaled, type LoadoutDraft } from '$lib/builder/loadout'; @@ -415,41 +419,51 @@ function interpolatedConditionalProgram( }; } -function equippedConditionalProgram(equipped: any): MechanicProgram | null { +export function equippedConditionalProgram(equipped: any): MechanicProgram | null { if (String(equipped.key).startsWith('chips:')) { return interpolatedConditionalProgram(equipped.rule, 'minimum', 'maximum', Number(equipped.tier), 3); } if (String(equipped.key).startsWith('protocol:')) { return conditionalProgram(equipped.rule, 'base'); } - return conditionalProgram( - equipped.rule, - String(equipped.key).startsWith('weapon:') || String(equipped.key).startsWith('gear:') - ? (Number(equipped.tier) >= 2 ? 'II' : 'I') - : 'base' - ); + const equipmentTalent = String(equipped.key).startsWith('weapon:') + || String(equipped.key).startsWith('gear:'); + const variant = equipmentTalent + ? (Number(equipped.tier) >= 2 ? 'II' : 'I') + : 'base'; + return conditionalProgram(equipped.rule, variant) + ?? (equipmentTalent && variant === 'II' + ? conditionalProgram(equipped.rule, 'I') + : null); } -type ActiveMechanicEvaluation = { +export type ActiveMechanicEvaluation = { status: 'OK'; result: MechanicEvaluation; program: MechanicProgram; encounterSeconds: number; + events?: MechanicEvent[]; }; -function mechanicScenarioInputs(program: MechanicProgram, configured: unknown) { +function mechanicScenarioInputs( + program: MechanicProgram, + configured: unknown, + maximum = false +) { const values = configured && typeof configured === 'object' && !Array.isArray(configured) ? configured as Record : {}; return Object.fromEntries((program.scenarioInputs ?? []).map((input) => [ input.key, - values[input.key] ?? input.defaultValue ?? (input.kind === 'toggle' ? false : 0) + maximum + ? input.bestCaseValue ?? input.defaultValue ?? (input.kind === 'toggle' ? false : 0) + : values[input.key] ?? input.defaultValue ?? (input.kind === 'toggle' ? false : 0) ])); } function evaluateConfiguredMechanic( program: MechanicProgram, - events: MechanicEvent[], + buildEvents: (scenarioInputs: Record) => MechanicEvent[], state: any, profile: LoadoutDraft['combatProfile'], weaponBaseDamage: number, @@ -461,12 +475,14 @@ function evaluateConfiguredMechanic( if (calculationMode !== 'maximum' && state.mode === 'off') return null; const encounterSeconds = Math.max(1, Number(profile.encounterSeconds)); const maximum = calculationMode === 'maximum' || state.mode === 'peak'; + const scenarioInputs = mechanicScenarioInputs(program, state.inputs, maximum); + const events = buildEvents(scenarioInputs); const result = evaluateMechanicProgram( program, events, maximum ? { mode: 'maximum', encounterSeconds } - : { mode: 'custom', encounterSeconds, inputs: mechanicScenarioInputs(program, state.inputs) }, + : { mode: 'custom', encounterSeconds, inputs: scenarioInputs }, { maxHealth: Math.max(1, maxHealth), currentHealth: Math.max(0, maxHealth * Number(profile.healthPercent) / 100), @@ -476,7 +492,7 @@ function evaluateConfiguredMechanic( }, equipmentContext ); - return result.status === 'OK' ? { status: 'OK', result, program, encounterSeconds } : null; + return result.status === 'OK' ? { status: 'OK', result, program, encounterSeconds, events } : null; } type ModifierInterval = { @@ -489,6 +505,7 @@ type ModifierInterval = { outcomeId: string; phase: MechanicOutcome['phase']; activationEvent: MechanicEvent['kind']; + activationAbilityId: string | null; endAtNextActivation: boolean; }; @@ -497,17 +514,42 @@ function modifierIntervals(source: ActiveMechanicEvaluation): ModifierInterval[] .filter((emission) => emission.kind === 'attribute_modifier' && emission.attributeId) .sort((left, right) => left.at - right.at); return emissions.flatMap((emission, index) => { - const clause = source.program.clauses.find((candidate) => - candidate.outcomes?.some((outcome) => outcome.id === emission.outcomeId)); + const clause = source.program.clauses.find((candidate) => candidate.id === emission.clauseId); const definition = clause?.outcomes?.find((candidate) => candidate.id === emission.outcomeId); if (!clause || !definition) return []; - const next = emissions.slice(index + 1).find((candidate) => candidate.outcomeId === emission.outcomeId); + const activationAbilityId = definition.phase === 'on_event' && clause.when === 'skill_deployed' + ? emission.targetId + : null; + const nextDeploymentAt = activationAbilityId + ? source.events?.find((event) => + event.kind === 'skill_deployed' + && event.targetId === activationAbilityId + && event.at > emission.at + 0.000_001)?.at + : undefined; + const next = emissions.slice(index + 1).find((candidate) => + candidate.outcomeId === emission.outcomeId + && (activationAbilityId === null || candidate.targetId === activationAbilityId)); const pool = definition.statePool ? source.program.pools?.find((candidate) => candidate.key === definition.statePool) : null; + const deactivatedAt = definition.statePool + ? source.result.emissions.find((candidate) => + candidate.at >= emission.at + && candidate.mutations.some((mutation) => + mutation.pool === definition.statePool + && mutation.after !== undefined + && mutation.after <= 0 + ) + )?.at + : undefined; const naturalEnd = definition.phase === 'while_active' - ? emission.at + Number(pool?.expiresAfterSeconds ?? source.encounterSeconds) - : emission.at; + ? Math.min( + emission.at + Number(pool?.expiresAfterSeconds ?? source.encounterSeconds), + deactivatedAt ?? source.encounterSeconds + ) + : activationAbilityId + ? nextDeploymentAt ?? source.encounterSeconds + : emission.at; const end = Math.min(source.encounterSeconds, next?.at ?? naturalEnd, naturalEnd); if ( end <= emission.at @@ -521,11 +563,14 @@ function modifierIntervals(source: ActiveMechanicEvaluation): ModifierInterval[] stateValue: emission.stateValue, attributeId: String(emission.attributeId), scope: emission.scope, - outcomeId: emission.outcomeId, - phase: definition.phase, - activationEvent: clause.when, - endAtNextActivation: next !== undefined && next.at <= naturalEnd - }]; + outcomeId: emission.outcomeId, + phase: definition.phase, + activationEvent: clause.when, + activationAbilityId, + endAtNextActivation: activationAbilityId + ? nextDeploymentAt !== undefined + : next !== undefined && next.at <= naturalEnd + }]; }); } @@ -555,15 +600,22 @@ const TIMELINE_EVENT_ORDER: Readonly> = { weapon_swapped: 7, enemy_killed: 8, assist: 9, - skill_deployed: 10, - skill_hit: 11, - agent_hit: 12, - health_damage_taken: 13, - healing_applied: 14, - extra_health_granted: 15, - tick: 16, - proximity_snapshot: 17, - movement_snapshot: 18 + ally_downed: 10, + agent_downed: 11, + ally_rescued: 12, + grenade_thrown: 13, + medical_kit_used: 14, + signature_ability_deployed: 15, + skill_deployed: 16, + skill_hit: 17, + skill_multishot_triggered: 18, + agent_hit: 19, + health_damage_taken: 20, + healing_applied: 21, + extra_health_granted: 22, + tick: 23, + proximity_snapshot: 24, + movement_snapshot: 25 }; function intervalAppliesAt( @@ -634,10 +686,16 @@ function mechanicSummary(source: ActiveMechanicEvaluation) { }; } +export type MechanicTimelineOptions = { + scenarioInputs?: Record; + skillDeploymentEvents?: MechanicEvent[]; +}; + export function buildWeaponMechanicTimeline( weapon: any, profile: LoadoutDraft['combatProfile'], - criticalChancePercent = 0 + criticalChancePercent = 0, + options: MechanicTimelineOptions = {} ): MechanicEvent[] { const encounterSeconds = Math.max(0, Number(profile.encounterSeconds)); const rpm = Math.max(1, Number(weapon?.rpm)); @@ -662,7 +720,11 @@ export function buildWeaponMechanicTimeline( const shotInterval = 60 / rpm; while (time <= encounterSeconds && events.length < 100_000) { if (magazineRemaining <= 0) { - events.push({ kind: 'reload_started', at: time }); + events.push({ + kind: 'reload_started', + at: time, + values: { emptyMagazine: magazineRemaining === 0 } + }); time += reloadSeconds; if (time <= encounterSeconds) { events.push({ kind: 'reload_completed', at: time }); @@ -691,13 +753,21 @@ export function buildWeaponMechanicTimeline( const critical = hit && criticalAccumulator >= 1; if (critical) criticalAccumulator -= 1; const targetId = `target-${Math.floor(time / targetInterval)}`; + const emptiedBeforeShot = magazine - magazineRemaining; + const emptiedAfterShot = emptiedBeforeShot + 1; + const magazineTwentyPercentBandsCrossed = [20, 40, 60, 80].filter((threshold) => + emptiedBeforeShot * 100 < threshold * magazine + && emptiedAfterShot * 100 >= threshold * magazine + ).length; events.push({ kind: 'shot_fired', at: time, targetId, values: { + hit, magazineRemaining, magazinePercent: magazineRemaining / magazine * 100, + magazineTwentyPercentBandsCrossed, distanceMeters: Number(profile.targetDistanceMeters) } }); @@ -722,24 +792,53 @@ export function buildWeaponMechanicTimeline( if (!(perMinute > 0)) return; const interval = 60 / perMinute; let headshotKillAccumulator = 0; + let criticalKillAccumulator = 0; for (let at = interval; at <= encounterSeconds; at += interval) { if (kind === 'enemy_killed') { headshotKillAccumulator += Math.max(0, Math.min(100, Number(profile.headshotKillRate))) / 100; const headshot = headshotKillAccumulator >= 1; if (headshot) headshotKillAccumulator -= 1; + criticalKillAccumulator += criticalChance; + const critical = criticalKillAccumulator >= 1; + if (critical) criticalKillAccumulator -= 1; events.push({ kind, at, targetId: `target-${Math.floor(at / targetInterval)}`, - values: { headshot, source: 'weapon' } + values: { + headshot, + critical, + source: 'weapon', + behindCover: options.scenarioInputs?.['behind-cover'] === true, + distanceMeters: Number(profile.targetDistanceMeters) + } }); } - else events.push({ kind, at }); + else { + events.push(kind === 'health_damage_taken' + ? { + kind, + at, + values: { + distanceMeters: Number(profile.targetDistanceMeters), + recentSkillDeploymentSeconds: Number( + options.scenarioInputs?.['recent-skill-deployment-seconds'] ?? encounterSeconds + ) + } + } + : { kind, at }); + if (kind === 'health_damage_taken') events.push({ kind: 'agent_hit', at }); + } } }; intervalEvents('enemy_killed', Number(profile.killsPerMinute)); intervalEvents('health_damage_taken', Number(profile.damageTakenPerMinute)); intervalEvents('weapon_swapped', Number(profile.swapsPerMinute)); + const movementState = options.scenarioInputs?.sprinting === true + ? 'sprinting' + : options.scenarioInputs?.moving === true + ? 'moving' + : 'immobile'; for (let at = 0; at <= encounterSeconds; at += 1) { events.push({ kind: 'tick', at }); events.push({ @@ -748,12 +847,16 @@ export function buildWeaponMechanicTimeline( values: { distanceMeters: Number(profile.targetDistanceMeters), targetDistanceMeters: Number(profile.targetDistanceMeters), - enemyCount: 1, - allyCount: 0 - } - }); - events.push({ kind: 'movement_snapshot', at, values: { moving: false, sprinting: false } }); + extraHealthActive: Boolean(profile.extraHealthActive), + behindCover: options.scenarioInputs?.['behind-cover'] === true, + enemiesWithinRadius: Number(profile.targetDistanceMeters) <= 20 ? 1 : 0, + enemyCount: Number(profile.targetDistanceMeters) <= 20 ? 1 : 0, + allyCount: Number(options.scenarioInputs?.['allies-within-radius'] ?? 0) + } + }); + events.push({ kind: 'movement_snapshot', at, values: { state: movementState } }); } + events.push(...(options.skillDeploymentEvents ?? [])); return events.sort((left, right) => left.at - right.at); } @@ -768,6 +871,147 @@ function effectAppliesToSkills(scope: string) { return !['allies', 'equipped_weapon'].includes(scope); } +function skillCalculationBindings(catalog: Catalog): SkillAttributeBinding[] { + const supportedStages = new Set([ + 'flat_damage', + 'skill_damage', + 'final_damage', + 'critical_chance', + 'critical_damage', + 'multishot_chance', + 'multishot_damage', + 'cooldown_recovery', + 'duration' + ]); + return (catalog.calculation.skillProfiles ?? []).flatMap((profile: any) => { + const stage = profile.stage_key === 'engineering' ? 'skill_damage' : String(profile.stage_key); + return supportedStages.has(stage) + ? [{ + attributeId: String(profile.stat_attribute_id), + stage: stage as SkillAttributeBinding['stage'], + operator: profile.operator === 'multiply' ? 'multiply' as const : 'add' as const, + weight: Number(profile.weight ?? 1) + }] + : []; + }); +} + +function loadoutSkillAbilities(draft: LoadoutDraft, catalog: Catalog): SkillAbilityInput[] { + const focus = selectedFocus(draft, catalog); + return (focus?.abilities ?? []).map((ability: any): SkillAbilityInput => { + const attack = ability.attack; + const selected = draft.skillActivations[ability.id] ?? { mode: 'off', targets: 1 }; + const activation = selected.mode === 'custom_per_minute' + ? { mode: 'custom_per_minute' as const, usesPerMinute: Number(selected.usesPerMinute ?? 1) } + : selected.mode === 'on_cooldown' + ? { mode: 'on_cooldown' as const } + : { mode: 'off' as const }; + const chargePolicy = attack?.charge_policy === 'independent' + ? { type: 'independent' as const, charges: Number(attack.charge_count) } + : attack?.charge_policy === 'full_set' + ? { type: 'full_set' as const, charges: Number(attack.charge_count) } + : { type: 'none' as const }; + return { + id: String(ability.id), + baseDamage: attack?.base_damage === undefined ? undefined : Number(attack.base_damage), + damageType: attack?.damage_type, + delivery: attack?.delivery === 'periodic' + ? { + type: 'periodic' as const, + ticks: Math.max(1, Math.floor(Number(attack.damage_duration_seconds) / Number(attack.tick_interval_seconds))), + durationSeconds: Number(attack.damage_duration_seconds), + hitsPerTick: Number(attack.hits_per_activation ?? 1), + intervalSeconds: Number(attack.tick_interval_seconds), + firstTickDelaySeconds: Number(attack.tick_interval_seconds) + } + : { type: 'direct' as const, hits: Number(attack?.hits_per_activation ?? 1) }, + castSeconds: Number(attack?.cast_seconds ?? 0), + cooldownSeconds: Number(ability.cooldown_seconds ?? 0), + targetCap: Number(attack?.target_cap ?? 1), + targetCount: Number(selected.targets ?? 1), + chargePolicy, + activation + }; + }); +} + +export function buildSkillDeploymentEvents( + abilities: SkillAbilityInput[], + encounterSeconds: number, + rules: SkillCalculationRules = { revisionId: 'mechanic-timeline', bindings: [] }, + attributes: Record = {} +): MechanicEvent[] { + const input: SkillCalculationInput = { + rules, + abilities, + attributes, + assumptions: { encounterSeconds, targetCount: 1 } + }; + return abilities.flatMap((ability) => { + if ( + ability.activation.mode === 'off' + || ( + ability.activation.mode === 'on_cooldown' + && Number(ability.castSeconds) <= 0 + && Number(ability.cooldownSeconds) <= 0 + ) + ) return []; + return skillActivationTimes(ability, encounterSeconds, input, rules).map((at): MechanicEvent => ({ + kind: 'skill_deployed', + at, + targetId: ability.id, + values: { abilityId: ability.id } + })); + }).sort((left, right) => left.at - right.at); +} + +function loadoutSkillDeploymentEvents( + draft: LoadoutDraft, + catalog: Catalog, + summary: LoadoutSummary +) { + const bindings = skillCalculationBindings(catalog); + const boundIds = new Set(bindings.map((binding) => binding.attributeId)); + const attributes = Object.fromEntries( + summary.stats.filter((stat) => boundIds.has(stat.id)).map((stat) => [stat.id, stat.value]) + ); + return buildSkillDeploymentEvents( + loadoutSkillAbilities(draft, catalog), + Math.max(1, Number(draft.combatProfile.encounterSeconds)), + { revisionId: String(catalog.calculation.revision?.id ?? 'mechanic-timeline'), bindings }, + attributes + ); +} + +export function skillModifierWindowsForMechanic( + source: ActiveMechanicEvaluation, + sourceAbilityId: string | null, + focusAbilityIds: ReadonlySet, + allowedAttributeIds: ReadonlySet +): SkillModifierWindow[] { + return modifierIntervals(source).flatMap((interval) => { + if ( + !effectAppliesToSkills(interval.scope) + || !allowedAttributeIds.has(interval.attributeId) + || interval.end <= interval.start + ) return []; + const abilityIds = interval.activationAbilityId + ? (focusAbilityIds.has(interval.activationAbilityId) ? [interval.activationAbilityId] : []) + : interval.scope === 'assigned_ability' + ? (sourceAbilityId && focusAbilityIds.has(sourceAbilityId) ? [sourceAbilityId] : []) + : undefined; + if ((interval.activationAbilityId || interval.scope === 'assigned_ability') && !abilityIds?.length) { + return []; + } + return [{ + startSeconds: interval.start, + endSeconds: interval.end, + attributes: { [interval.attributeId]: interval.value }, + abilityIds + }]; + }); +} + function skillModifierWindows( draft: LoadoutDraft, catalog: Catalog, @@ -805,16 +1049,24 @@ function skillModifierWindows( const primaryBindings = calculationBindings(catalog, primaryWeapon); const baseAttributes = Object.fromEntries(summary.stats.map((stat) => [stat.id, stat.value])); const primaryCriticalChance = weaponCriticalChance(baseAttributes, primaryBindings); - const focusAbilityIds = new Set((focus?.abilities ?? []).map((ability: any) => String(ability.id))); + const focusAbilityIds = new Set( + (focus?.abilities ?? []).map((ability: any) => String(ability.id)) + ); const windows: SkillModifierWindow[] = []; const maxHealthId = catalog.attributes.find((item) => item.name === 'Health')?.id; const maxHealth = summary.stats.find((stat) => stat.id === maxHealthId)?.value ?? 1; + const skillDeploymentEvents = loadoutSkillDeploymentEvents(draft, catalog, summary); const evaluatedSources = sources.flatMap((source) => { const state = draft.conditionalEffects[source.key] ?? { mode: 'off' }; const profile = { ...draft.combatProfile, ...(state.overrides ?? {}) }; const evaluation = evaluateConfiguredMechanic( source.program!, - primaryWeapon ? buildWeaponMechanicTimeline(primaryWeapon, profile, primaryCriticalChance) : [], + (scenarioInputs) => primaryWeapon + ? buildWeaponMechanicTimeline(primaryWeapon, profile, primaryCriticalChance, { + scenarioInputs, + skillDeploymentEvents + }) + : skillDeploymentEvents, state, profile, Number(primarySelection.damage ?? 0), @@ -827,19 +1079,12 @@ function skillModifierWindows( }); for (const source of evaluatedSources) { - for (const interval of modifierIntervals(source.evaluation)) { - if (!effectAppliesToSkills(interval.scope) || !allowedAttributeIds.has(interval.attributeId)) continue; - const abilityIds = interval.scope === 'assigned_ability' - ? (source.abilityId && focusAbilityIds.has(source.abilityId) ? [source.abilityId] : []) - : undefined; - if (interval.scope === 'assigned_ability' && !abilityIds?.length) continue; - windows.push({ - startSeconds: interval.start, - endSeconds: interval.end, - attributes: { [interval.attributeId]: interval.value }, - abilityIds - }); - } + windows.push(...skillModifierWindowsForMechanic( + source.evaluation, + source.abilityId, + focusAbilityIds, + allowedAttributeIds + )); } return windows; @@ -872,6 +1117,7 @@ export function evaluateLoadoutCombat( program: conditionalProgram(effect.rule, 'base') })) ].filter((source) => source.program); + const skillDeploymentEvents = loadoutSkillDeploymentEvents(draft, catalog, summary); const required = (weaponIndex: number) => { const missing: string[] = []; if (!catalog.calculation.revision) missing.push('An active ISAC calculation revision'); @@ -911,13 +1157,14 @@ export function evaluateLoadoutCombat( ) => conditionalSources.flatMap((source) => { const state = draft.conditionalEffects[source.key] ?? { mode: 'off' }; const profile = { ...draft.combatProfile, ...(state.overrides ?? {}) }; - const evaluation = evaluateConfiguredMechanic( - source.program!, - buildWeaponMechanicTimeline( - timelineWeapon, - profile, - weaponCriticalChance(criticalAttributes, bindings) - ), + const evaluation = evaluateConfiguredMechanic( + source.program!, + (scenarioInputs) => buildWeaponMechanicTimeline( + timelineWeapon, + profile, + weaponCriticalChance(criticalAttributes, bindings), + { scenarioInputs, skillDeploymentEvents } + ), state, profile, Number(selection.damage), @@ -1070,70 +1317,13 @@ export function evaluateLoadoutSkillCombat( catalog: Catalog, summary = summarizeLoadout(draft, catalog) ): SkillCalculation { - const focus = selectedFocus(draft, catalog); const revisionId = String(catalog.calculation.revision?.id ?? ''); - const supportedStages = new Set([ - 'flat_damage', - 'skill_damage', - 'final_damage', - 'critical_chance', - 'critical_damage', - 'multishot_chance', - 'multishot_damage', - 'cooldown_recovery', - 'duration' - ]); - const bindings: SkillAttributeBinding[] = (catalog.calculation.skillProfiles ?? []) - .flatMap((profile: any) => { - const stage = profile.stage_key === 'engineering' ? 'skill_damage' : String(profile.stage_key); - return supportedStages.has(stage) - ? [{ - attributeId: String(profile.stat_attribute_id), - stage: stage as SkillAttributeBinding['stage'], - operator: profile.operator === 'multiply' ? 'multiply' as const : 'add' as const, - weight: Number(profile.weight ?? 1) - }] - : []; - }); + const bindings = skillCalculationBindings(catalog); const boundIds = new Set(bindings.map((binding) => binding.attributeId)); const attributes = Object.fromEntries( summary.stats.filter((stat) => boundIds.has(stat.id)).map((stat) => [stat.id, stat.value]) ); - const abilities = (focus?.abilities ?? []).map((ability: any) => { - const attack = ability.attack; - const selected = draft.skillActivations[ability.id] ?? { mode: 'off', targets: 1 }; - const activation = selected.mode === 'custom_per_minute' - ? { mode: 'custom_per_minute' as const, usesPerMinute: Number(selected.usesPerMinute ?? 1) } - : selected.mode === 'on_cooldown' - ? { mode: 'on_cooldown' as const } - : { mode: 'off' as const }; - const chargePolicy = attack?.charge_policy === 'independent' - ? { type: 'independent' as const, charges: Number(attack.charge_count) } - : attack?.charge_policy === 'full_set' - ? { type: 'full_set' as const, charges: Number(attack.charge_count) } - : { type: 'none' as const }; - return { - id: String(ability.id), - baseDamage: attack?.base_damage === undefined ? undefined : Number(attack.base_damage), - damageType: attack?.damage_type, - delivery: attack?.delivery === 'periodic' - ? { - type: 'periodic' as const, - ticks: Math.max(1, Math.floor(Number(attack.damage_duration_seconds) / Number(attack.tick_interval_seconds))), - durationSeconds: Number(attack.damage_duration_seconds), - hitsPerTick: Number(attack.hits_per_activation ?? 1), - intervalSeconds: Number(attack.tick_interval_seconds), - firstTickDelaySeconds: Number(attack.tick_interval_seconds) - } - : { type: 'direct' as const, hits: Number(attack?.hits_per_activation ?? 1) }, - castSeconds: Number(attack?.cast_seconds ?? 0), - cooldownSeconds: Number(ability.cooldown_seconds ?? 0), - targetCap: Number(attack?.target_cap ?? 1), - targetCount: Number(selected.targets ?? 1), - chargePolicy, - activation - }; - }); + const abilities = loadoutSkillAbilities(draft, catalog); return calculateSkillDps({ rules: { revisionId, bindings }, abilities, diff --git a/src/lib/calculations/mechanic-editor.test.ts b/src/lib/calculations/mechanic-editor.test.ts index d0497b0..9a21d77 100644 --- a/src/lib/calculations/mechanic-editor.test.ts +++ b/src/lib/calculations/mechanic-editor.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from 'vitest'; import { + currentHealthPercentBandPredicate, + eventFieldComparisonPredicate, parseConditionalMechanicDraft, + readCurrentHealthPercentBand, + readEventFieldComparison, + readStatePoolThreshold, + statePoolThresholdPredicate, type ConditionalMechanicDraftV1 } from './mechanic-editor.js'; @@ -54,6 +60,116 @@ const draft = (): ConditionalMechanicDraftV1 => ({ }); describe('conditional mechanic authoring payload', () => { + it('resolves and validates variant-specific pool expiration durations', () => { + const value = draft(); + value.pools[0] = { + ...value.pools[0], + expiresAfterSeconds: undefined, + expiresAfterSecondsVariants: { I: 12, II: 18 } + }; + const result = parseConditionalMechanicDraft(value, { variantKeys: ['I', 'II'] }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.programs.I.pools?.[0].expiresAfterSeconds).toBe(12); + expect(result.value.programs.II.pools?.[0].expiresAfterSeconds).toBe(18); + + value.pools[0].expiresAfterSecondsVariants = { I: 12 }; + const invalid = parseConditionalMechanicDraft(value, { variantKeys: ['I', 'II'] }); + expect(invalid.ok).toBe(false); + if (!invalid.ok) { + expect(invalid.errors).toContain( + 'State pool 1 variant expiration durations must define one positive number for each variant.' + ); + } + }); + + it('round-trips strict manual current Health percentage band editor state', () => { + const predicate = currentHealthPercentBandPredicate({ minimum: 25, maximum: 50 }); + expect(readCurrentHealthPercentBand(predicate)).toEqual({ minimum: 25, maximum: 50 }); + expect(predicate).toEqual({ + kind: 'all', + predicates: [{ + kind: 'compare', + left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' }, + operator: 'gte', + right: 25 + }, { + kind: 'compare', + left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' }, + operator: 'lt', + right: 50 + }] + }); + expect(readCurrentHealthPercentBand({ + kind: 'compare', + left: { source: 'event', path: 'values.healthPercent' }, + operator: 'lt', + right: 50 + })).toBeNull(); + }); + + it('round-trips editable event scalar comparisons without claiming advanced predicates', () => { + for (const value of ['moving', 4, true]) { + const comparison = { path: 'values.state', operator: 'eq' as const, value }; + const predicate = eventFieldComparisonPredicate(comparison); + expect(readEventFieldComparison(predicate)).toEqual(comparison); + } + expect(readEventFieldComparison({ + kind: 'compare', + left: { source: 'event', path: 'values.state' }, + operator: 'eq', + right: { source: 'event', path: 'values.previousState' } + })).toBeNull(); + expect(readEventFieldComparison({ + kind: 'all', + predicates: [] + })).toBeNull(); + }); + + it('round-trips state-pool thresholds and preserves unsupported targeted pools as advanced', () => { + const comparison = { pool: 'stacks', operator: 'gte' as const, value: 10 }; + const predicate = statePoolThresholdPredicate(comparison); + expect(readStatePoolThreshold(predicate)).toEqual(comparison); + expect(readStatePoolThreshold({ + kind: 'compare', + left: { source: 'pool', pool: 'stacks', entity: { kind: 'event_target' } }, + operator: 'gt', + right: 0 + })).toBeNull(); + }); + + it('parses movement-state and stack-threshold authoring predicates unchanged', () => { + const value = draft(); + value.clauses[0].event = 'movement_snapshot'; + value.clauses[0].if = eventFieldComparisonPredicate({ + path: 'values.state', + operator: 'eq', + value: 'sprinting' + }); + value.clauses.push({ + ...structuredClone(value.clauses[0]), + key: 'consume-stacks', + event: 'shot_fired', + if: statePoolThresholdPredicate({ + pool: 'effect', + operator: 'gt', + value: 0 + }) + }); + 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.map((clause) => clause.if)).toEqual( + value.clauses.map((clause) => clause.if) + ); + expect(result.value.programs.I.clauses.map((clause) => clause.if)).toEqual( + value.clauses.map((clause) => clause.if) + ); + }); + it('accepts an empty captured-only program for deferred modeling', () => { const partial: ConditionalMechanicDraftV1 = { version: 1, @@ -427,4 +543,51 @@ describe('conditional mechanic authoring payload', () => { errors: ['The conditional mechanic contains malformed JSON.'] }); }); + + it('rejects invalid pre-event timing for current Health percentage references', () => { + const invalid = draft(); + invalid.clauses[0].if = { + kind: 'compare', + left: { source: 'combat', path: 'currentHealthPercent', timing: 'pre_event' }, + operator: 'lt', + right: 50 + }; + const result = parseConditionalMechanicDraft(invalid, { + variantKeys: ['I', 'II'], + allowedAttributeIds: new Set(['weapon-damage']) + }); + expect(result.ok).toBe(false); + }); + + it('accepts compatible radius targeting and rejects incompatible scopes', () => { + const valid = draft(); + valid.clauses[0].outcomes[0].scope = 'allies'; + valid.clauses[0].outcomes[0].targeting = { + kind: 'within_radius', + population: 'allies', + radiusMeters: 40 + }; + const parsed = parseConditionalMechanicDraft(valid, { + variantKeys: ['I', 'II'], + allowedAttributeIds: new Set(['weapon-damage']) + }); + expect(parsed.ok).toBe(true); + if (parsed.ok) { + expect(parsed.value.draft.clauses[0].outcomes[0].targeting).toEqual( + valid.clauses[0].outcomes[0].targeting + ); + } + + valid.clauses[0].outcomes[0].scope = 'self'; + const incompatible = parseConditionalMechanicDraft(valid, { + variantKeys: ['I', 'II'], + allowedAttributeIds: new Set(['weapon-damage']) + }); + expect(incompatible.ok).toBe(false); + if (!incompatible.ok) { + expect(incompatible.errors).toContain( + 'Clause 1 outcome 1 ally radius targeting requires allies or self-and-allies scope.' + ); + } + }); }); diff --git a/src/lib/calculations/mechanic-editor.ts b/src/lib/calculations/mechanic-editor.ts index d6eac96..f02edf6 100644 --- a/src/lib/calculations/mechanic-editor.ts +++ b/src/lib/calculations/mechanic-editor.ts @@ -14,6 +14,7 @@ import { type PoolDefinition, type Predicate, type Reducer, + type Scalar, type ScenarioInputDefinition, type StateScope, type ValueReference @@ -32,6 +33,7 @@ export type MechanicStateKind = export type ConditionalMechanicPoolDraft = PoolDefinition & { stateKind: MechanicStateKind; + expiresAfterSecondsVariants?: Record; }; export type ConditionalMechanicVariantValue = { @@ -77,6 +79,138 @@ export type ConditionalMechanicDraftResult = | { ok: true; value: ParsedConditionalMechanicDraft } | { ok: false; errors: string[] }; +export type CurrentHealthPercentBand = { + minimum?: number; + maximum: number; +}; + +const currentHealthPercentReference = { + source: 'combat', + path: 'currentHealthPercent', + timing: 'current' +} as const; + +export function currentHealthPercentBandPredicate( + band: CurrentHealthPercentBand +): Predicate { + const maximum: Predicate = { + kind: 'compare', + left: currentHealthPercentReference, + operator: 'lt', + right: band.maximum + }; + if (band.minimum === undefined) return maximum; + return { + kind: 'all', + predicates: [{ + kind: 'compare', + left: currentHealthPercentReference, + operator: 'gte', + right: band.minimum + }, maximum] + }; +} + +export function readCurrentHealthPercentBand( + predicate: Predicate | undefined +): CurrentHealthPercentBand | null { + const predicates = predicate?.kind === 'all' ? predicate.predicates : predicate ? [predicate] : []; + let minimum: number | undefined; + let maximum: number | undefined; + for (const item of predicates) { + if ( + item.kind !== 'compare' + || typeof item.left !== 'object' + || item.left === null + || item.left.source !== 'combat' + || item.left.path !== 'currentHealthPercent' + || item.left.timing !== 'current' + || typeof item.right !== 'number' + ) return null; + if (item.operator === 'gte') minimum = item.right; + else if (item.operator === 'lt') maximum = item.right; + else return null; + } + return maximum === undefined ? null : { ...(minimum === undefined ? {} : { minimum }), maximum }; +} + +export type PredicateComparisonOperator = 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte'; +export type EditablePredicateScalar = Exclude; + +export type EventFieldComparison = { + path: string; + operator: PredicateComparisonOperator; + value: EditablePredicateScalar; +}; + +export function eventFieldComparisonPredicate( + comparison: EventFieldComparison +): Predicate { + return { + kind: 'compare', + left: { source: 'event', path: comparison.path }, + operator: comparison.operator, + right: comparison.value + }; +} + +export function readEventFieldComparison( + predicate: Predicate | undefined +): EventFieldComparison | null { + if ( + predicate?.kind !== 'compare' + || typeof predicate.left !== 'object' + || predicate.left === null + || predicate.left.source !== 'event' + || !predicate.left.path.trim() + || (typeof predicate.right !== 'string' + && typeof predicate.right !== 'number' + && typeof predicate.right !== 'boolean') + ) return null; + return { + path: predicate.left.path, + operator: predicate.operator, + value: predicate.right + }; +} + +export type StatePoolThresholdComparison = { + pool: string; + operator: PredicateComparisonOperator; + value: number; +}; + +export function statePoolThresholdPredicate( + comparison: StatePoolThresholdComparison +): Predicate { + return { + kind: 'compare', + left: { source: 'pool', pool: comparison.pool }, + operator: comparison.operator, + right: comparison.value + }; +} + +export function readStatePoolThreshold( + predicate: Predicate | undefined +): StatePoolThresholdComparison | null { + if ( + predicate?.kind !== 'compare' + || typeof predicate.left !== 'object' + || predicate.left === null + || predicate.left.source !== 'pool' + || !predicate.left.pool.trim() + || predicate.left.entity !== undefined + || typeof predicate.right !== 'number' + || !Number.isFinite(predicate.right) + ) return null; + return { + pool: predicate.left.pool, + operator: predicate.operator, + value: predicate.right + }; +} + const object = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); const finite = (value: unknown): value is number => @@ -97,13 +231,15 @@ export const CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES = { 'perStack', 'minStacks', 'maxStacks', + 'targeting', 'descriptionSlot' ], phase: ['on_event', 'after_event', 'on_enter', 'on_exit', 'while_active'], - kind: ['attribute_modifier', 'direct_damage', 'restoration', 'resource_cost', 'marker'], + kind: ['attribute_modifier', 'direct_damage', 'restoration', 'resource_cost', 'weapon_reload', 'marker'], scope: [ 'self', 'equipped_weapon', + 'inactive_weapon', 'target', 'allies', 'self_and_allies', @@ -111,7 +247,7 @@ export const CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES = { 'all_skills' ], basis: ['flat', 'max_health_percent', 'current_health_percent', 'weapon_base_percent'], - resource: ['health', 'armor', 'ammo'], + resource: ['health', 'armor', 'ammo', 'signature_ability_charge'], delivery: ['instant', 'continuous'], deliverySemantics: { instant: 'The full resolved value applies at activation.', @@ -175,7 +311,8 @@ function isReference(value: unknown): value is ValueReference { case 'combat': return typeof value.path === 'string' && Boolean(value.path.trim()) - && (value.timing === undefined || value.timing === 'pre_event' || value.timing === 'current'); + && (value.timing === undefined || value.timing === 'pre_event' || value.timing === 'current') + && !(value.path === 'currentHealthPercent' && value.timing === 'pre_event'); case 'equipment': return typeof value.path === 'string' && Boolean(value.path.trim()); case 'attribute': @@ -222,6 +359,7 @@ function isExpression(value: unknown): value is NumericExpression { if (finite(value)) return true; if (!object(value)) return false; if ('ref' in value) return isReference(value.ref); + if (value.op === 'floor') return isExpression(value.value); if (value.op === 'clamp') { return isExpression(value.value) && isExpression(value.min) && isExpression(value.max); } @@ -333,7 +471,7 @@ function parseScenarioInputs(value: unknown, errors: string[]) { return inputs; } -function parsePools(value: unknown, errors: string[]) { +function parsePools(value: unknown, variantKeys: readonly string[], errors: string[]) { if (!Array.isArray(value)) { errors.push('State pools must be an array.'); return [] as ConditionalMechanicPoolDraft[]; @@ -362,6 +500,26 @@ function parsePools(value: unknown, errors: string[]) { errors.push(`${path} ${property} must be a finite number.`); } } + if (candidate.expiresAfterSeconds !== undefined && candidate.expiresAfterSecondsVariants !== undefined) { + errors.push(`${path} cannot define both scalar and variant expiration durations.`); + } + if (candidate.expiresAfterSecondsVariants !== undefined) { + if (!object(candidate.expiresAfterSecondsVariants)) { + errors.push(`${path} variant expiration durations must be an object.`); + } else { + const variantDurations = candidate.expiresAfterSecondsVariants; + const keys = Object.keys(variantDurations); + if ( + keys.length !== variantKeys.length + || keys.some((variant) => !variantKeys.includes(variant)) + || variantKeys.some((variant) => + !finite(variantDurations[variant]) + || Number(variantDurations[variant]) <= 0) + ) { + errors.push(`${path} variant expiration durations must define one positive number for each variant.`); + } + } + } pools.push(candidate as ConditionalMechanicPoolDraft); } return pools; @@ -439,6 +597,25 @@ function parseOutcome( ) { errors.push(`${path} description slot must be a positive integer.`); } + if (value.targeting !== undefined) { + if ( + !object(value.targeting) + || value.targeting.kind !== 'within_radius' + || !['allies', 'enemies'].includes(String(value.targeting.population)) + || !finite(value.targeting.radiusMeters) + || value.targeting.radiusMeters <= 0 + ) { + errors.push(`${path} has invalid radius targeting.`); + } else if ( + value.targeting.population === 'allies' + && value.scope !== 'allies' + && value.scope !== 'self_and_allies' + ) { + errors.push(`${path} ally radius targeting requires allies or self-and-allies scope.`); + } else if (value.targeting.population === 'enemies' && value.scope !== 'target') { + errors.push(`${path} enemy radius targeting requires target scope.`); + } + } if (!object(value.variants)) { errors.push(`${path} variants must be an object.`); return null; @@ -555,7 +732,16 @@ function programsForDraft( id: `${options.programId ?? 'mechanic'}:${variant}`, name: options.programName?.trim() || 'Conditional Mechanic', scenarioInputs: draft.scenarioInputs, - pools: draft.pools.map(({ stateKind: _stateKind, ...pool }) => pool), + pools: draft.pools.map(({ + stateKind: _stateKind, + expiresAfterSecondsVariants, + ...pool + }) => ({ + ...pool, + ...(expiresAfterSecondsVariants + ? { expiresAfterSeconds: expiresAfterSecondsVariants[variant] } + : {}) + })), clauses: draft.clauses.map((clause): MechanicClause => ({ id: clause.key, ...(clause.label ? { label: clause.label } : {}), @@ -607,7 +793,7 @@ export function parseConditionalMechanicDraft( errors.push('The conditional mechanic has invalid simulation readiness.'); } const scenarioInputs = parseScenarioInputs(parsed.scenarioInputs, errors); - const pools = parsePools(parsed.pools, errors); + const pools = parsePools(parsed.pools, options.variantKeys, errors); const clauses = parseClauses(parsed.clauses, options.variantKeys, options, errors); const draft: ConditionalMechanicDraftV1 = { version: CONDITIONAL_MECHANIC_DRAFT_VERSION, diff --git a/src/lib/calculations/mechanic-persistence.test.ts b/src/lib/calculations/mechanic-persistence.test.ts index eca1646..4eace95 100644 --- a/src/lib/calculations/mechanic-persistence.test.ts +++ b/src/lib/calculations/mechanic-persistence.test.ts @@ -78,6 +78,7 @@ const outcome = ( value: 3, value_expression_json: null, value_basis: 'flat', + targeting_json: null, value_scaling: 'per_state_unit', unit: '%', damage_basis: null, @@ -152,6 +153,33 @@ describe('conditional-effect persistence adapter', () => { expect(result.program?.clauses[0].outcomes?.[0]).not.toHaveProperty('durationSeconds'); }); + it('preserves radius targeting metadata on an outcome', () => { + const result = adaptConditionalEffectProgram({ + rule: rule(), + statePools: [pool()], + clauses: [clause({ trigger_id: null, event_kind: 'enemy_killed' })], + outcomes: [outcome({ + effect_scope: 'allies', + targeting_json: JSON.stringify({ + kind: 'within_radius', + population: 'allies', + radiusMeters: 40 + }) + })], + triggers: [] + }, { name: 'Slaughter Healing', variantKey: 'I' }); + + expect(result.errors).toEqual([]); + expect(result.program?.clauses[0].outcomes?.[0]).toMatchObject({ + scope: 'allies', + targeting: { + kind: 'within_radius', + population: 'allies', + radiusMeters: 40 + } + }); + }); + it('loads continuous restoration delivery metadata', () => { const result = adaptConditionalEffectProgram({ rule: rule(), @@ -589,4 +617,30 @@ describe('conditional-effect persistence adapter', () => { 'Clause 1 mutations contains malformed JSON.' ])); }); + + it('preserves a persisted current Health percentage predicate', () => { + const condition = { + kind: 'compare', + left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' }, + operator: 'lt', + right: 25 + }; + const result = adaptConditionalEffectProgram({ + rule: rule(), + statePools: [], + clauses: [clause({ + trigger_id: null, + event_kind: 'tick', + parameters_json: '{}', + conditions_json: JSON.stringify(condition), + state_pool_id: null, + mutations_json: '[]' + })], + outcomes: [], + triggers: [] + }, { name: 'Spirited', variantKey: 'I' }); + + expect(result.status).toBe('READY'); + expect(result.program?.clauses[0].if).toEqual(condition); + }); }); diff --git a/src/lib/calculations/mechanic-persistence.ts b/src/lib/calculations/mechanic-persistence.ts index 63112ad..f1e25bf 100644 --- a/src/lib/calculations/mechanic-persistence.ts +++ b/src/lib/calculations/mechanic-persistence.ts @@ -84,6 +84,7 @@ export type ConditionalEffectOutcomeRow = { combat_timing?: DatabaseValue; delivery_mode?: DatabaseValue; delivery_duration_seconds?: DatabaseValue; + targeting_json?: DatabaseValue; value_scaling: DatabaseValue; unit: DatabaseValue; damage_basis: DatabaseValue; @@ -192,7 +193,8 @@ function isValueReference(value: unknown) { if (value.source === 'combat') { return typeof value.path === 'string' && Boolean(value.path.trim()) - && (value.timing === undefined || value.timing === 'pre_event' || value.timing === 'current'); + && (value.timing === undefined || value.timing === 'pre_event' || value.timing === 'current') + && !(value.path === 'currentHealthPercent' && value.timing === 'pre_event'); } if (value.source === 'attribute') { return typeof value.attributeId === 'string' && Boolean(value.attributeId.trim()); @@ -211,6 +213,7 @@ function isNumericExpression(value: unknown): value is NumericExpression { if (finite(value)) return true; if (!object(value)) return false; if ('ref' in value) return isValueReference(value.ref); + if (value.op === 'floor') return isNumericExpression(value.value); if (value.op === 'clamp') { return isNumericExpression(value.value) && isNumericExpression(value.min) @@ -441,6 +444,7 @@ function parsePool( row: ConditionalEffectStatePoolRow, ruleId: string, index: number, + selectedVariant: string, errors: string[] ): { id: string; definition: PoolDefinition } | null { const path = `State pool ${index + 1}`; @@ -460,6 +464,14 @@ function parsePool( const duration = number(row.duration_seconds); const parameters = parseJson(row.parameters_json, `${path} parameters`, errors); if (parameters.ok && !object(parameters.value)) errors.push(`${path} parameters must be a JSON object.`); + const durationVariants = parameters.ok && object(parameters.value) + && object(parameters.value.expiresAfterSecondsVariants) + ? parameters.value.expiresAfterSecondsVariants + : null; + const variantDuration = durationVariants ? number(durationVariants[selectedVariant] as DatabaseValue) : null; + if (durationVariants && variantDuration === null) { + errors.push(`${path} has no valid expiration duration for variant "${selectedVariant}".`); + } return { id, definition: { @@ -470,7 +482,9 @@ function parsePool( ...(minimum === null ? {} : { min: minimum }), max: maximum, ...(bestCase === null ? {} : { bestCaseValue: bestCase }), - ...(duration === null ? {} : { expiresAfterSeconds: duration }) + ...(variantDuration !== null + ? { expiresAfterSeconds: variantDuration } + : duration === null ? {} : { expiresAfterSeconds: duration }) } }; } @@ -478,10 +492,14 @@ function parsePool( function outcomeKind(value: string): MechanicOutcome['kind'] | null { if (value === 'active_modifier') return 'attribute_modifier'; if (value === 'direct_damage') return 'direct_damage'; - if (value === 'restoration' || ['restore_health', 'restore_armor', 'restore_ammo'].includes(value)) { + if ( + value === 'restoration' + || ['restore_health', 'restore_armor', 'restore_ammo', 'restore_signature_ability_charge'].includes(value) + ) { return 'restoration'; } if (value === 'resource_cost') return 'resource_cost'; + if (value === 'weapon_reload') return 'weapon_reload'; if (value === 'marker') return 'marker'; return null; } @@ -489,6 +507,7 @@ function outcomeKind(value: string): MechanicOutcome['kind'] | null { function outcomeScope(value: string): MechanicOutcome['scope'] | null { if (value === 'self') return 'self'; if (value === 'equipped_weapon') return 'equipped_weapon'; + if (value === 'inactive_weapon') return 'inactive_weapon'; if (value === 'target') return 'target'; if (value === 'allies') return 'allies'; if (value === 'self_and_allies') return 'self_and_allies'; @@ -547,15 +566,17 @@ function parseOutcome( ? 'armor' : text(row.application_kind) === 'restore_ammo' ? 'ammo' + : text(row.application_kind) === 'restore_signature_ability_charge' + ? 'signature_ability_charge' : text(row.application_kind) === 'resource_cost' ? 'health' : null; const resource = nullableText(row.resource_kind) ?? legacyResource; if ( (kind === 'restoration' || kind === 'resource_cost') - && !['health', 'armor', 'ammo'].includes(String(resource)) + && !['health', 'armor', 'ammo', 'signature_ability_charge'].includes(String(resource)) ) { - errors.push(`${path} requires an explicit health, armor, or ammo resource.`); + errors.push(`${path} requires an explicit supported resource.`); } const combatTiming = nullableText(row.combat_timing); if (combatTiming && combatTiming !== 'pre_event' && combatTiming !== 'current') { @@ -572,6 +593,13 @@ function parseOutcome( if (delivery === 'instant' && durationSeconds !== null) { errors.push(`${path} instant delivery cannot set a duration.`); } + let targeting: MechanicOutcome['targeting'] | undefined; + if (row.targeting_json !== null && row.targeting_json !== undefined && row.targeting_json !== '') { + const parsed = parseJson(row.targeting_json, `${path} targeting`, errors); + if (parsed.ok && typeof parsed.value === 'object' && parsed.value !== null) { + targeting = parsed.value as MechanicOutcome['targeting']; + } + } return { clauseId: nullableText(row.clause_id), outcome: { @@ -591,6 +619,7 @@ function parseOutcome( ...(delivery === 'continuous' && durationSeconds !== null ? { delivery, durationSeconds } : {}), + ...(targeting ? { targeting } : {}), ...(kind === 'attribute_modifier' && nullableText(row.stat_attribute_id) ? { attributeId: text(row.stat_attribute_id) } : {}), @@ -627,16 +656,6 @@ export function adaptConditionalEffectProgram( } const scenarioInputs = parseScenarioInputs(input.rule.scenario_inputs_json, errors); - const parsedPools = input.statePools - .slice() - .sort((left, right) => (number(left.sort_order) ?? 0) - (number(right.sort_order) ?? 0)) - .map((row, index) => parsePool(row, ruleId, index, errors)) - .filter((value): value is NonNullable => value !== null); - const poolKeys = new Map(parsedPools.map((pool) => [pool.id, pool.definition.key])); - const triggerMap = new Map( - input.triggers.map((trigger) => [text(trigger.id), trigger]) - ); - const variantKeys = [...new Set(input.outcomes.map((row) => text(row.variant_key)).filter(Boolean))]; let selectedVariant = options.variantKey?.trim() || ''; if (!selectedVariant) { @@ -648,6 +667,15 @@ export function adaptConditionalEffectProgram( } else if (variantKeys.length && !variantKeys.includes(selectedVariant)) { errors.push(`Variant "${selectedVariant}" does not exist for this rule.`); } + const parsedPools = input.statePools + .slice() + .sort((left, right) => (number(left.sort_order) ?? 0) - (number(right.sort_order) ?? 0)) + .map((row, index) => parsePool(row, ruleId, index, selectedVariant, errors)) + .filter((value): value is NonNullable => value !== null); + const poolKeys = new Map(parsedPools.map((pool) => [pool.id, pool.definition.key])); + const triggerMap = new Map( + input.triggers.map((trigger) => [text(trigger.id), trigger]) + ); const selectedOutcomes = input.outcomes .filter((row) => text(row.variant_key) === selectedVariant) diff --git a/src/lib/calculations/mechanic-recipes.test.ts b/src/lib/calculations/mechanic-recipes.test.ts index 26640ab..5fb947c 100644 --- a/src/lib/calculations/mechanic-recipes.test.ts +++ b/src/lib/calculations/mechanic-recipes.test.ts @@ -142,10 +142,17 @@ describe('mechanic recipe registry', () => { expect(health.clauses[1].if).toMatchObject({ kind: 'all', predicates: expect.arrayContaining([ - { kind: 'compare', left: { source: 'event', path: 'values.healthPercent' }, operator: 'lte', right: 50 }, - { kind: 'compare', left: { source: 'event', path: 'values.healthPercent' }, operator: 'gt', right: 25 } + { kind: 'compare', left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' }, operator: 'lt', right: 50 }, + { kind: 'compare', left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' }, operator: 'gte', right: 25 } ]) }); + expect(health.clauses.every((clause) => clause.when === 'tick')).toBe(true); + expect(health.clauses[2].if).toEqual({ + kind: 'compare', + left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' }, + operator: 'gte', + right: 50 + }); expect(seedFor('target_hit_within_distance').clauses[0]).toMatchObject({ when: 'weapon_hit', diff --git a/src/lib/calculations/mechanic-recipes.ts b/src/lib/calculations/mechanic-recipes.ts index 5151349..ca7be4a 100644 --- a/src/lib/calculations/mechanic-recipes.ts +++ b/src/lib/calculations/mechanic-recipes.ts @@ -110,6 +110,11 @@ function firingLifecycleSeed(activePool: string, includeDuration?: number): Mech function healthBandSeed(params: HealthThresholdParams): MechanicRecipeSeed { const sortedBands = [...params.bands].sort((left, right) => left.thresholdPercent - right.thresholdPercent); + const currentHealthPercent: ValueReference = { + source: 'combat', + path: 'currentHealthPercent', + timing: 'current' + }; const pools: PoolDefinition[] = sortedBands.map((band, index) => ({ key: `health-band-${index + 1}`, label: `Health band: ${band.outcomeKey}`, @@ -122,22 +127,22 @@ function healthBandSeed(params: HealthThresholdParams): MechanicRecipeSeed { const lowerBound = sortedBands[index - 1]?.thresholdPercent; const predicates: Predicate[] = [{ kind: 'compare', - left: eventValue('healthPercent'), - operator: 'lte', + left: currentHealthPercent, + operator: 'lt', right: band.thresholdPercent }]; if (lowerBound !== undefined) { predicates.push({ kind: 'compare', - left: eventValue('healthPercent'), - operator: 'gt', + left: currentHealthPercent, + operator: 'gte', right: lowerBound }); } return { id: `select-health-band-${index + 1}`, - label: `Select ${band.outcomeKey} at ${band.thresholdPercent}% Health`, - when: 'health_damage_taken', + label: `Select ${band.outcomeKey} below ${band.thresholdPercent}% Health`, + when: 'tick', if: { kind: 'all', predicates }, reducer: { kind: 'none' }, mutations: pools.map((pool, poolIndex) => @@ -150,11 +155,11 @@ function healthBandSeed(params: HealthThresholdParams): MechanicRecipeSeed { clauses.push({ id: 'clear-health-bands', label: 'Clear health bands above the highest threshold', - when: 'health_damage_taken', + when: 'tick', if: { kind: 'compare', - left: eventValue('healthPercent'), - operator: 'gt', + left: currentHealthPercent, + operator: 'gte', right: sortedBands.at(-1)?.thresholdPercent ?? 0 }, reducer: { kind: 'none' }, diff --git a/src/lib/calculations/mechanic.test.ts b/src/lib/calculations/mechanic.test.ts index 700e118..802e49b 100644 --- a/src/lib/calculations/mechanic.test.ts +++ b/src/lib/calculations/mechanic.test.ts @@ -5,7 +5,9 @@ import { formatMechanicProgram, validateMechanicProgram, type MechanicEvent, - type MechanicProgram + type MechanicEventKind, + type MechanicProgram, + type NumericExpression } from './mechanic.js'; const custom = (encounterSeconds = 30, inputs: Record = {}) => ({ @@ -17,14 +19,153 @@ const custom = (encounterSeconds = 30, inputs: Record describe('structured mechanic runtime', () => { it('advertises the authoritative weapon-hit target and headshot fields', () => { expect(MECHANIC_EVENT_REGISTRY.weapon_hit).toMatchObject({ - description: expect.stringContaining('headshot boolean'), + description: expect.stringContaining('previous target'), fields: [ { path: 'targetId', label: 'Target ID', type: 'string' }, - { path: 'values.headshot', label: 'Headshot', type: 'boolean' } + { path: 'values.previousTargetId', label: 'Previous target ID', type: 'string' }, + { path: 'values.headshot', label: 'Headshot', type: 'boolean' }, + { path: 'values.critical', label: 'Critical hit', type: 'boolean' }, + { path: 'values.behindCover', label: 'Agent behind cover', type: 'boolean' }, + { path: 'values.distanceMeters', label: 'Target distance', type: 'number' } ] }); }); + it('advertises imported support and equipment-use trigger events', () => { + expect(MECHANIC_EVENT_REGISTRY.ally_downed).toMatchObject({ + key: 'ally_downed', + label: 'Ally downed' + }); + expect(MECHANIC_EVENT_REGISTRY.agent_downed).toMatchObject({ + key: 'agent_downed', + label: 'Agent downed' + }); + expect(MECHANIC_EVENT_REGISTRY.ally_rescued).toMatchObject({ + key: 'ally_rescued', + label: 'Ally rescued' + }); + expect(MECHANIC_EVENT_REGISTRY.grenade_thrown).toMatchObject({ + key: 'grenade_thrown', + label: 'Grenade thrown' + }); + expect(MECHANIC_EVENT_REGISTRY.medical_kit_used).toMatchObject({ + key: 'medical_kit_used', + label: 'Medical Kit used' + }); + expect(MECHANIC_EVENT_REGISTRY.signature_ability_deployed).toMatchObject({ + key: 'signature_ability_deployed', + label: 'Signature Ability deployed' + }); + expect(MECHANIC_EVENT_REGISTRY.skill_multishot_triggered).toMatchObject({ + key: 'skill_multishot_triggered', + label: 'Skill Multi-Shot triggered' + }); + }); + + it('executes imported talent trigger families as first-class events', () => { + const eventKinds: MechanicEventKind[] = [ + 'ally_downed', + 'agent_downed', + 'ally_rescued', + 'grenade_thrown', + 'medical_kit_used', + 'signature_ability_deployed', + 'skill_multishot_triggered' + ]; + const program: MechanicProgram = { + id: 'imported-talent-events', + name: 'Imported talent events', + clauses: eventKinds.map((when) => ({ + id: when, + when, + reducer: { kind: 'none' }, + mutations: [{ kind: 'none' }], + outcomes: [{ + id: `${when}-marker`, + label: `${when} marker`, + phase: 'after_event', + kind: 'marker', + scope: 'self', + basis: 'flat', + value: 1 + }] + })) + }; + + expect(validateMechanicProgram(program)).toMatchObject({ ok: true }); + const events: MechanicEvent[] = eventKinds.map((kind, index) => ({ kind, at: index + 1 })); + expect(evaluateMechanicProgram(program, events, custom()).outcomes.map( + (outcome) => outcome.outcomeId + )).toEqual(eventKinds.map((kind) => `${kind}-marker`)); + }); + + it('derives the previous weapon-hit target for target-switch mechanics', () => { + const program: MechanicProgram = { + id: 'target-switch-stacks', + name: 'Target Switch Stacks', + pools: [{ + key: 'stacks', + label: 'Stacks', + scope: 'agent', + initial: 0, + min: 0, + max: 100, + expiresAfterSeconds: 5 + }], + clauses: [ + { + id: 'halve-on-switch', + when: 'weapon_hit', + if: { + kind: 'all', + predicates: [ + { kind: 'exists', value: { source: 'event', path: 'values.previousTargetId' } }, + { + kind: 'compare', + left: { source: 'event', path: 'values.previousTargetId' }, + operator: 'ne', + right: { source: 'event', path: 'targetId' } + } + ] + }, + reducer: { kind: 'none' }, + mutations: [{ + kind: 'set', + pool: 'stacks', + value: { + op: 'multiply', + left: { ref: { source: 'pool', pool: 'stacks' } }, + right: 0.5 + } + }] + }, + { + id: 'build-stacks', + when: 'weapon_hit', + if: { kind: 'exists', value: { source: 'event', path: 'targetId' } }, + reducer: { kind: 'none' }, + mutations: [{ kind: 'add', pool: 'stacks', value: 1 }] + } + ] + }; + const result = evaluateMechanicProgram(program, [ + { kind: 'weapon_hit', at: 0, targetId: 'alpha' }, + { kind: 'weapon_hit', at: 1, targetId: 'alpha' }, + { kind: 'weapon_hit', at: 2, targetId: 'bravo' } + ], custom(3)); + expect(result.status).toBe('OK'); + expect(result.finalState).toContainEqual(expect.objectContaining({ + pool: 'stacks', + value: 2 + })); + expect(result.emissions.map((emission) => emission.clauseId)).toEqual([ + 'build-stacks', + 'build-stacks', + 'halve-on-switch', + 'build-stacks' + ]); + }); + it('models Superior Firepower as same-target sliding hits and one shared expiring stack pool', () => { const superiorFirepower: MechanicProgram = { id: 'superior-firepower', @@ -1077,6 +1218,242 @@ describe('structured mechanic runtime', () => { }); describe('mechanic validation and pseudo-script', () => { + it('shares an identical timed modifier outcome across kill and assist refresh clauses', () => { + const weaponDamage = { + id: 'pummel-weapon-damage', + label: 'Weapon Damage', + phase: 'while_active' as const, + kind: 'attribute_modifier' as const, + scope: 'equipped_weapon' as const, + basis: 'flat' as const, + value: 10, + attributeId: 'weapon-damage', + statePool: 'pummel-active' + }; + const program: MechanicProgram = { + id: 'pummel-refresh', + name: 'Pummel Refresh', + pools: [{ + key: 'pummel-active', label: 'Pummel active', scope: 'agent', initial: 0, + min: 0, max: 1, expiresAfterSeconds: 12 + }], + clauses: [ + { + id: 'activate-on-kill', when: 'enemy_killed', reducer: { kind: 'none' }, + mutations: [{ kind: 'set', pool: 'pummel-active', value: 1 }], + outcomes: [weaponDamage] + }, + { + id: 'refresh-on-assist', when: 'assist', reducer: { kind: 'none' }, + mutations: [{ kind: 'set', pool: 'pummel-active', value: 1 }], + outcomes: [{ ...weaponDamage }] + } + ] + }; + + expect(validateMechanicProgram(program)).toMatchObject({ ok: true }); + const result = evaluateMechanicProgram(program, [ + { kind: 'enemy_killed', at: 1 }, + { kind: 'assist', at: 10 } + ], custom(20)); + + expect(result.status).toBe('OK'); + expect(result.outcomes.map(({ outcomeId, at, appliedValue }) => ({ outcomeId, at, appliedValue }))) + .toEqual([ + { outcomeId: 'pummel-weapon-damage', at: 1, appliedValue: 10 }, + { outcomeId: 'pummel-weapon-damage', at: 10, appliedValue: 10 } + ]); + expect(result.finalState).toContainEqual(expect.objectContaining({ + pool: 'pummel-active', value: 1, expiresAt: 22 + })); + }); + + it('rejects a shared outcome ID when its mechanical definitions conflict', () => { + const outcome = { + id: 'shared-damage', + label: 'Weapon Damage', + phase: 'on_event' as const, + kind: 'attribute_modifier' as const, + scope: 'equipped_weapon' as const, + basis: 'flat' as const, + value: 10, + attributeId: 'weapon-damage' + }; + const program: MechanicProgram = { + id: 'conflicting-shared-outcome', + name: 'Conflicting Shared Outcome', + clauses: [ + { + id: 'kill', when: 'enemy_killed', reducer: { kind: 'none' }, + mutations: [{ kind: 'none' }], outcomes: [outcome] + }, + { + id: 'assist', when: 'assist', reducer: { kind: 'none' }, + mutations: [{ kind: 'none' }], outcomes: [{ ...outcome, value: 14 }] + } + ] + }; + + expect(validateMechanicProgram(program)).toEqual({ + ok: false, + errors: ['Outcome "shared-damage" has conflicting definitions.'] + }); + }); + + it('applies distance bonuses in full five-meter steps and caps the result', () => { + const distanceBonus = ( + base: number, + increment: number, + maximum: number + ): NumericExpression => ({ + op: 'min', + left: maximum, + right: { + op: 'add', + left: base, + right: { + op: 'multiply', + left: increment, + right: { + op: 'floor', + value: { + op: 'divide', + left: { + op: 'max', + left: { ref: { source: 'event', path: 'values.distanceMeters' } }, + right: 0 + }, + right: 5 + } + } + } + } + }); + const program: MechanicProgram = { + id: 'long-distance-marking', + name: 'Long-Distance Marking', + clauses: [{ + id: 'headshot', + when: 'weapon_hit', + reducer: { kind: 'none' }, + mutations: [], + outcomes: [{ + id: 'skill-damage', + label: 'Skill Damage', + phase: 'after_event', + kind: 'attribute_modifier', + scope: 'self', + attributeId: 'skill-damage', + basis: 'flat', + value: distanceBonus(7.5, 1.5, 18) + }] + }] + }; + + expect(validateMechanicProgram(program)).toMatchObject({ ok: true }); + const result = evaluateMechanicProgram( + program, + [-1, 0, 4.99, 5, 9.99, 10, 34.99, 35, 100].map((distanceMeters, at) => ({ + kind: 'weapon_hit' as const, + at, + values: { distanceMeters } + })), + custom(10) + ); + + expect(result.status).toBe('OK'); + expect(result.outcomes.map((outcome) => outcome.resolvedValue)).toEqual([ + 7.5, 7.5, 7.5, 9, 9, 10.5, 16.5, 18, 18 + ]); + expect(result.script).toContain( + 'MIN(18, (7.5 + (1.5 * FLOOR((MAX(EVENT.values.distanceMeters, 0) / 5)))))' + ); + }); + + it('supports an immediate equipped-weapon magazine refill', () => { + const program: MechanicProgram = { + id: 'equipped-magazine', + name: 'Equipped Magazine', + clauses: [{ + id: 'refill-equipped-weapon', + when: 'enemy_killed', + reducer: { kind: 'none' }, + mutations: [{ kind: 'none' }], + outcomes: [{ + id: 'equipped-weapon-reload', + label: 'Refill equipped weapon', + phase: 'after_event', + kind: 'weapon_reload', + scope: 'equipped_weapon', + basis: 'flat', + value: 0 + }] + }] + }; + + const result = evaluateMechanicProgram( + program, + [{ kind: 'enemy_killed', at: 1 }], + custom(10) + ); + + expect(result.status).toBe('OK'); + expect(result.outcomes).toEqual([ + expect.objectContaining({ + kind: 'weapon_reload', + scope: 'equipped_weapon', + at: 1, + value: 0 + }) + ]); + expect(result.script).toContain('RELOAD EQUIPPED WEAPON AFTER 0s'); + }); + + it('schedules an inactive-weapon reload and refreshes a pending delay', () => { + const program: MechanicProgram = { + id: 'backup-magazine', + name: 'Backup Magazine', + clauses: [{ + id: 'reload-inactive-weapon', + when: 'weapon_swapped', + reducer: { kind: 'none' }, + mutations: [{ kind: 'none' }], + outcomes: [{ + id: 'inactive-weapon-reload', + label: 'Reload inactive weapon', + phase: 'after_event', + kind: 'weapon_reload', + scope: 'inactive_weapon', + basis: 'flat', + value: 4 + }] + }] + }; + + const refreshed = evaluateMechanicProgram(program, [ + { kind: 'weapon_swapped', at: 1 }, + { kind: 'weapon_swapped', at: 3 } + ], custom(10)); + expect(refreshed.status).toBe('OK'); + expect(refreshed.emissions.map(({ at }) => at)).toEqual([1, 3]); + expect(refreshed.outcomes).toEqual([ + expect.objectContaining({ + kind: 'weapon_reload', + scope: 'inactive_weapon', + at: 7, + value: 4 + }) + ]); + expect(refreshed.script).toContain('RELOAD INACTIVE WEAPON AFTER 4s'); + + const beyondEncounter = evaluateMechanicProgram( + program, + [{ kind: 'weapon_swapped', at: 8 }], + custom(10) + ); + expect(beyondEncounter.outcomes).toEqual([]); + }); + it('enforces clause cooldowns without counting events received during the cooldown', () => { const program: MechanicProgram = { id: 'cooldown', @@ -1187,6 +1564,213 @@ describe('mechanic validation and pseudo-script', () => { } }); + it('caps Signature Ability Charge at 1000 and scales grants by charge efficiency', () => { + const efficiencyAttributeId = 'signature-charge-efficiency'; + const program: MechanicProgram = { + id: 'signature-charge', + name: 'Signature Charge', + clauses: [{ + id: 'grant-charge', + when: 'weapon_hit', + reducer: { kind: 'none' }, + mutations: [], + outcomes: [{ + id: 'charge', + label: 'Signature Ability Charge', + phase: 'after_event', + kind: 'restoration', + scope: 'self', + resource: 'signature_ability_charge', + basis: 'flat', + value: { + op: 'multiply', + left: 70, + right: { + op: 'add', + left: 1, + right: { + op: 'divide', + left: { ref: { source: 'attribute', attributeId: efficiencyAttributeId } }, + right: 100 + } + } + } + }] + }] + }; + const result = evaluateMechanicProgram( + program, + [ + { kind: 'weapon_hit', at: 1 }, + { kind: 'weapon_hit', at: 2 } + ], + custom(), + { + maxHealth: 100, + currentHealth: 100, + currentSignatureAbilityCharge: 900, + attributes: { [efficiencyAttributeId]: 50 } + } + ); + expect(result.status).toBe('OK'); + expect(result.outcomes.map((outcome) => outcome.appliedValue)).toEqual([100, 0]); + expect(result.finalCombat?.currentSignatureAbilityCharge).toBe(1000); + expect(result.finalCombat?.maxSignatureAbilityCharge).toBeUndefined(); + }); + + it('evaluates mutually exclusive current Health percentage bands at strict boundaries', () => { + const band = ( + id: string, + minimum: number | undefined, + maximum: number, + value: number + ): MechanicProgram['clauses'][number] => ({ + id, + when: 'tick', + if: minimum === undefined + ? { + kind: 'compare', + left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' }, + operator: 'lt', + right: maximum + } + : { + kind: 'all', + predicates: [{ + kind: 'compare', + left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' }, + operator: 'gte', + right: minimum + }, { + kind: 'compare', + left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' }, + operator: 'lt', + right: maximum + }] + }, + reducer: { kind: 'none' }, + mutations: [], + outcomes: [{ + id: `${id}-damage-reduction`, + label: 'Damage Reduction', + phase: 'while_active', + kind: 'attribute_modifier', + scope: 'self', + basis: 'flat', + attributeId: 'damage-reduction', + value + }] + }); + const spirited: MechanicProgram = { + id: 'spirited', + name: 'Spirited', + clauses: [ + band('critical', undefined, 25, 15), + band('damaged', 25, 50, 12), + band('wounded', 50, 75, 9) + ] + }; + const evaluateAt = (healthPercent: number, mode: 'custom' | 'maximum' = 'custom') => + evaluateMechanicProgram( + spirited, + [{ kind: 'tick', at: 0 }], + mode === 'maximum' + ? { mode: 'maximum', encounterSeconds: 1 } + : custom(1), + { maxHealth: 1_000, currentHealth: healthPercent * 10 } + ); + const valuesAt = (healthPercent: number, mode: 'custom' | 'maximum' = 'custom') => + evaluateAt(healthPercent, mode).outcomes.map((outcome) => outcome.appliedValue); + + expect(valuesAt(0)).toEqual([15]); + expect(valuesAt(24.99)).toEqual([15]); + expect(valuesAt(25)).toEqual([12]); + expect(valuesAt(49.99)).toEqual([12]); + expect(valuesAt(50)).toEqual([9]); + expect(valuesAt(74.99)).toEqual([9]); + expect(valuesAt(75)).toEqual([]); + expect(valuesAt(100)).toEqual([]); + expect(valuesAt(24, 'maximum')).toEqual([15]); + }); + + it('re-evaluates current Health percentage after deterministic combat-state updates', () => { + const program: MechanicProgram = { + id: 'live-health', + name: 'Live Health', + clauses: [{ + id: 'health-cost', + when: 'tick', + reducer: { kind: 'none' }, + mutations: [], + outcomes: [{ + id: 'cost', + label: 'Health cost', + phase: 'on_event', + kind: 'resource_cost', + scope: 'self', + resource: 'health', + basis: 'flat', + value: 30 + }] + }, { + id: 'below-seventy-five', + when: 'tick', + if: { + kind: 'compare', + left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' }, + operator: 'lt', + right: 75 + }, + reducer: { kind: 'none' }, + mutations: [], + outcomes: [{ + id: 'reduction', + label: 'Damage Reduction', + phase: 'while_active', + kind: 'attribute_modifier', + scope: 'self', + attributeId: 'damage-reduction', + basis: 'flat', + value: 9 + }] + }] + }; + const result = evaluateMechanicProgram( + program, + [{ kind: 'tick', at: 0 }], + custom(1), + { maxHealth: 100, currentHealth: 100 } + ); + expect(result.finalCombat?.currentHealth).toBe(70); + expect(result.outcomes.map((outcome) => outcome.outcomeId)).toEqual(['cost', 'reduction']); + }); + + it('rejects pre-event timing for the derived current Health percentage', () => { + const invalid: MechanicProgram = { + id: 'invalid-health-percent', + name: 'Invalid Health Percent', + clauses: [{ + id: 'invalid', + when: 'tick', + if: { + kind: 'compare', + left: { source: 'combat', path: 'currentHealthPercent', timing: 'pre_event' }, + operator: 'lt', + right: 50 + }, + reducer: { kind: 'none' }, + mutations: [] + }] + }; + const result = validateMechanicProgram(invalid); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errors).toEqual(expect.arrayContaining([ + expect.stringContaining('current Health percentage must use current timing') + ])); + } + }); + it('formats nested conditions and state operations deterministically', () => { const program: MechanicProgram = { id: 'format', @@ -1221,4 +1805,49 @@ describe('mechanic validation and pseudo-script', () => { 'ADD 1 TO prepared' ].join('\n')); }); + + it('preserves radius targeting on ally modifiers and enemy markers', () => { + const program: MechanicProgram = { + id: 'radius-targeting', + name: 'Radius Targeting', + clauses: [{ + id: 'kill', + when: 'enemy_killed', + reducer: { kind: 'none' }, + mutations: [{ kind: 'none' }], + outcomes: [{ + id: 'healing', + label: 'Incoming Healing', + phase: 'on_event', + kind: 'attribute_modifier', + scope: 'allies', + attributeId: 'incoming-healing', + basis: 'flat', + value: 7, + targeting: { kind: 'within_radius', population: 'allies', radiusMeters: 40 } + }, { + id: 'taunt', + label: 'Taunt', + phase: 'on_event', + kind: 'marker', + scope: 'target', + basis: 'flat', + value: 1, + targeting: { kind: 'within_radius', population: 'enemies', radiusMeters: 20 } + }] + }] + }; + const result = evaluateMechanicProgram( + program, + [{ kind: 'enemy_killed', at: 1 }], + custom(5) + ); + expect(result.status).toBe('OK'); + expect(result.outcomes.map((outcome) => outcome.targeting)).toEqual([ + { kind: 'within_radius', population: 'allies', radiusMeters: 40 }, + { kind: 'within_radius', population: 'enemies', radiusMeters: 20 } + ]); + expect(result.script).toContain('TO ALLIES WITHIN 40m'); + expect(result.script).toContain('TO ENEMIES WITHIN 20m'); + }); }); diff --git a/src/lib/calculations/mechanic.ts b/src/lib/calculations/mechanic.ts index 69d9c89..7a6589a 100644 --- a/src/lib/calculations/mechanic.ts +++ b/src/lib/calculations/mechanic.ts @@ -2,12 +2,19 @@ export const MECHANIC_EVENT_KINDS = [ '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', 'movement_snapshot', @@ -36,15 +43,31 @@ export const MECHANIC_EVENT_REGISTRY: Readonly mechanicDefinitionsEqual(value, right[index])); + } + if (typeof left !== 'object') return false; + const leftRecord = left as Record; + const rightRecord = right as Record; + const leftKeys = Object.keys(leftRecord).sort(); + const rightKeys = Object.keys(rightRecord).sort(); + return leftKeys.length === rightKeys.length + && leftKeys.every((key, index) => + key === rightKeys[index] && mechanicDefinitionsEqual(leftRecord[key], rightRecord[key])); +} + export type PoolSnapshot = { pool: string; scope: StateScope; @@ -354,6 +480,7 @@ export type MechanicOutcomeEmission = { deliveryStart: number | null; deliveryEnd: number | null; totalResolvedValue: number; + targeting: MechanicOutcomeTargeting | null; }; export type MechanicEvaluation = { @@ -382,6 +509,8 @@ export type MechanicCombatContext = { currentArmor?: number; maxAmmo?: number; currentAmmo?: number; + maxSignatureAbilityCharge?: number; + currentSignatureAbilityCharge?: number; weaponBaseDamage?: number; attributes?: Record; values?: Record; @@ -425,6 +554,7 @@ type ContinuousDelivery = { start: number; end: number; appliedThrough: number; + targeting: MechanicOutcomeTargeting | null; }; type ReducerState = { count: number; @@ -494,6 +624,15 @@ function resolveReference(reference: ValueReference, context: EvaluationContext) return context.scenario[reference.key]; case 'combat': { const combat = reference.timing === 'pre_event' ? context.preEventCombat : context.combat; + if (reference.path === 'currentHealthPercent') { + if ( + !combat + || !finiteNumber(combat.maxHealth) + || combat.maxHealth <= 0 + || !finiteNumber(combat.currentHealth) + ) return undefined; + return clamp(combat.currentHealth / combat.maxHealth * 100, 0, 100); + } return getPath(combat, reference.path); } case 'attribute': @@ -513,6 +652,7 @@ function numericValue(expression: NumericExpression, context: EvaluationContext) const value = resolveReference(expression.ref, context); return finiteNumber(value) ? value : typeof value === 'boolean' ? Number(value) : 0; } + if (expression.op === 'floor') return Math.floor(numericValue(expression.value, context)); if (expression.op === 'clamp') { return clamp( numericValue(expression.value, context), @@ -893,6 +1033,19 @@ function combatResource( setCurrent: (value) => { combat.currentAmmo = value; } }; } + if (resource === 'signature_ability_charge') { + const maximum = finiteNumber(combat.maxSignatureAbilityCharge) + ? combat.maxSignatureAbilityCharge + : 1000; + const current = finiteNumber(combat.currentSignatureAbilityCharge) + ? combat.currentSignatureAbilityCharge + : 0; + return { + current, + maximum, + setCurrent: (value) => { combat.currentSignatureAbilityCharge = value; } + }; + } return null; } @@ -957,7 +1110,8 @@ function emitOutcomes( stateValue: current, start: at, end: at + durationSeconds, - appliedThrough: at + appliedThrough: at, + targeting: outcome.targeting ?? null }); continue; } @@ -973,10 +1127,13 @@ function emitOutcomes( } else if ((outcome.kind === 'resource_cost' || outcome.kind === 'restoration') && !targetResource) { appliedValue = 0; } + const emittedAt = outcome.kind === 'weapon_reload' + ? at + Math.max(0, appliedValue) + : at; emissions.push({ clauseId: clause.id, outcomeId: outcome.id, - at, + at: emittedAt, targetId: context.event.targetId ?? null, phase: outcome.phase, kind: outcome.kind, @@ -995,12 +1152,40 @@ function emitOutcomes( delivery: 'instant', deliveryStart: null, deliveryEnd: null, - totalResolvedValue: resolvedValue * weight + totalResolvedValue: resolvedValue * weight, + targeting: outcome.targeting ?? null }); } return emissions; } +function appendScheduledOutcomes( + target: MechanicOutcomeEmission[], + candidates: MechanicOutcomeEmission[], + triggeredAt: number, + encounterSeconds: number +) { + for (const candidate of candidates) { + if (candidate.kind !== 'weapon_reload') { + target.push(candidate); + continue; + } + for (let index = target.length - 1; index >= 0; index -= 1) { + const pending = target[index]; + if ( + pending.kind === 'weapon_reload' + && pending.clauseId === candidate.clauseId + && pending.outcomeId === candidate.outcomeId + && pending.scope === candidate.scope + && pending.at > triggeredAt + EPSILON + ) { + target.splice(index, 1); + } + } + if (candidate.at <= encounterSeconds + EPSILON) target.push(candidate); + } +} + function advanceContinuousDeliveries( at: number, deliveries: ContinuousDelivery[], @@ -1046,7 +1231,8 @@ function advanceContinuousDeliveries( delivery: 'continuous', deliveryStart: delivery.start, deliveryEnd: delivery.end, - totalResolvedValue: delivery.totalResolvedValue + totalResolvedValue: delivery.totalResolvedValue, + targeting: delivery.targeting }); } for (let index = deliveries.length - 1; index >= 0; index -= 1) { @@ -1182,7 +1368,12 @@ function validateReference(reference: ValueReference, pools: Set, inputs errors.push(`${path} references unknown scenario input "${reference.key}".`); } if (reference.source === 'event' && !reference.path.trim()) errors.push(`${path} requires an event path.`); - if (reference.source === 'combat' && !reference.path.trim()) errors.push(`${path} requires a combat path.`); + if (reference.source === 'combat') { + if (!reference.path.trim()) errors.push(`${path} requires a combat path.`); + if (reference.path === 'currentHealthPercent' && reference.timing === 'pre_event') { + errors.push(`${path} current Health percentage must use current timing.`); + } + } if (reference.source === 'attribute' && !reference.attributeId.trim()) errors.push(`${path} requires an Attribute ID.`); if (reference.source === 'equipment' && !reference.path.trim()) errors.push(`${path} requires an equipment path.`); } @@ -1202,6 +1393,10 @@ function visitExpression( validateReference(expression.ref, pools, inputs, errors, path); return; } + if (expression.op === 'floor') { + visitExpression(expression.value, pools, inputs, errors, `${path} value`); + return; + } if (expression.op === 'clamp') { visitExpression(expression.value, pools, inputs, errors, `${path} value`); visitExpression(expression.min, pools, inputs, errors, `${path} minimum`); @@ -1245,6 +1440,7 @@ function visitPredicate( function expressionUsesCombat(expression: NumericExpression): boolean { if (typeof expression === 'number') return false; if ('ref' in expression) return expression.ref.source === 'combat'; + if (expression.op === 'floor') return expressionUsesCombat(expression.value); if (expression.op === 'clamp') { return expressionUsesCombat(expression.value) || expressionUsesCombat(expression.min) @@ -1256,6 +1452,7 @@ function expressionUsesCombat(expression: NumericExpression): boolean { function expressionUsesEquipment(expression: NumericExpression): boolean { if (typeof expression === 'number') return false; if ('ref' in expression) return expression.ref.source === 'equipment'; + if (expression.op === 'floor') return expressionUsesEquipment(expression.value); if (expression.op === 'clamp') { return expressionUsesEquipment(expression.value) || expressionUsesEquipment(expression.min) @@ -1390,7 +1587,7 @@ export function validateMechanicProgram(program: MechanicProgram): ValidationRes } } const clauseIds = new Set(); - const outcomeIds = new Set(); + const outcomeDefinitions = new Map(); for (const [index, clause] of program.clauses.entries()) { const path = `Clause ${index + 1}`; if (!clause.id.trim()) errors.push(`${path} requires an ID.`); @@ -1478,14 +1675,19 @@ export function validateMechanicProgram(program: MechanicProgram): ValidationRes for (const [outcomeIndex, outcome] of (clause.outcomes ?? []).entries()) { const outcomePath = `${path} outcome ${outcomeIndex + 1}`; if (!outcome.id.trim()) errors.push(`${outcomePath} requires an ID.`); - if (outcomeIds.has(outcome.id)) errors.push(`Outcome "${outcome.id}" is duplicated.`); - outcomeIds.add(outcome.id); + const existingOutcome = outcomeDefinitions.get(outcome.id); + if (existingOutcome && !mechanicDefinitionsEqual(existingOutcome, outcome)) { + errors.push(`Outcome "${outcome.id}" has conflicting definitions.`); + } else if (!existingOutcome) { + outcomeDefinitions.set(outcome.id, outcome); + } if (outcome.kind === 'attribute_modifier' && !outcome.attributeId?.trim()) { errors.push(`${outcomePath} requires an Attribute ID.`); } if (![ 'self', 'equipped_weapon', + 'inactive_weapon', 'target', 'allies', 'self_and_allies', @@ -1494,11 +1696,45 @@ export function validateMechanicProgram(program: MechanicProgram): ValidationRes ].includes(outcome.scope)) { errors.push(`${outcomePath} has an unsupported scope.`); } + if ( + outcome.kind === 'weapon_reload' + && outcome.scope !== 'equipped_weapon' + && outcome.scope !== 'inactive_weapon' + ) { + errors.push(`${outcomePath} weapon reload must target the equipped or inactive weapon.`); + } + if (outcome.scope === 'inactive_weapon' && outcome.kind !== 'weapon_reload') { + errors.push(`${outcomePath} can only target the inactive weapon with a weapon reload.`); + } + if (outcome.targeting !== undefined) { + if ( + outcome.targeting.kind !== 'within_radius' + || !['allies', 'enemies'].includes(outcome.targeting.population) + || !finiteNumber(outcome.targeting.radiusMeters) + || outcome.targeting.radiusMeters <= 0 + ) { + errors.push(`${outcomePath} has invalid radius targeting.`); + } else if ( + outcome.targeting.population === 'allies' + && outcome.scope !== 'allies' + && outcome.scope !== 'self_and_allies' + ) { + errors.push(`${outcomePath} ally radius targeting requires allies or self-and-allies scope.`); + } else if ( + outcome.targeting.population === 'enemies' + && outcome.scope !== 'target' + ) { + errors.push(`${outcomePath} enemy radius targeting requires target scope.`); + } + } + if (outcome.kind === 'weapon_reload' && finiteNumber(outcome.value) && outcome.value < 0) { + errors.push(`${outcomePath} weapon reload delay cannot be negative.`); + } if ((outcome.kind === 'restoration' || outcome.kind === 'resource_cost') && outcome.resource === undefined) { errors.push(`${outcomePath} requires a resource.`); } if ((outcome.kind === 'restoration' || outcome.kind === 'resource_cost') && outcome.resource !== undefined - && !['health', 'armor', 'ammo'].includes(outcome.resource)) { + && !['health', 'armor', 'ammo', 'signature_ability_charge'].includes(outcome.resource)) { errors.push(`${outcomePath} has an unsupported resource.`); } if (outcome.resource !== undefined && outcome.kind !== 'restoration' && outcome.kind !== 'resource_cost') { @@ -1522,6 +1758,7 @@ export function validateMechanicProgram(program: MechanicProgram): ValidationRes direct_damage: ['flat', 'max_health_percent', 'current_health_percent', 'weapon_base_percent'], restoration: ['flat', 'max_health_percent', 'current_health_percent'], resource_cost: ['flat', 'max_health_percent', 'current_health_percent'], + weapon_reload: ['flat'], marker: ['flat'] }; if (!allowedBasis[outcome.kind].includes(outcome.basis)) { @@ -1644,6 +1881,10 @@ export function evaluateMechanicProgram( const continuousDeliveries: ContinuousDelivery[] = []; if (scenario.mode === 'maximum') { for (const clause of program.clauses) { + if ( + clause.when === 'tick' + && events.some((event) => event.kind === 'tick' && Math.abs(event.at) <= EPSILON) + ) continue; const event: MechanicEvent = { kind: clause.when, at: 0, @@ -1660,14 +1901,15 @@ export function evaluateMechanicProgram( preEventCombat: copyCombat(combat), equipment }; + if (!predicateMatches(clause.if, context)) continue; const before = new Map((clause.outcomes ?? []) .map((outcome) => outcome.statePool) .filter((pool): pool is string => Boolean(pool)) .map((pool) => [pool, stateValue(pool, undefined, context)])); - outcomes.push(...emitOutcomes({ + appendScheduledOutcomes(outcomes, emitOutcomes({ ...clause, outcomes: (clause.outcomes ?? []).filter((outcome) => outcome.phase === 'while_active') - }, 'after', 0, 1, before, context, continuousDeliveries)); + }, 'after', 0, 1, before, context, continuousDeliveries), 0, scenario.encounterSeconds); } } const ordered = events @@ -1676,7 +1918,19 @@ export function evaluateMechanicProgram( .sort((left, right) => left.event.at - right.event.at || left.index - right.index) .map(({ event }) => event); - for (const event of ordered) { + let previousWeaponHitTargetId: string | null = null; + for (const rawEvent of ordered) { + const event: MechanicEvent = rawEvent.kind === 'weapon_hit' && rawEvent.targetId + ? { + ...rawEvent, + values: { + ...(rawEvent.values ?? {}), + ...(previousWeaponHitTargetId === null + ? {} + : { previousTargetId: previousWeaponHitTargetId }) + } + } + : rawEvent; outcomes.push(...expirePools( event.at, program.clauses, @@ -1716,7 +1970,7 @@ export function evaluateMechanicProgram( .map((outcome) => outcome.statePool) .filter((pool): pool is string => Boolean(pool))); const before = new Map([...relevantPools].map((pool) => [pool, stateValue(pool, undefined, context)])); - outcomes.push(...emitOutcomes( + appendScheduledOutcomes(outcomes, emitOutcomes( clause, 'before', reduction.at, @@ -1724,7 +1978,7 @@ export function evaluateMechanicProgram( before, context, continuousDeliveries - )); + ), reduction.at, scenario.encounterSeconds); const mutationRecords = clause.mutations.map((mutation) => applyMutation(mutation, reduction.at, context, weight)); emissions.push({ @@ -1737,7 +1991,7 @@ export function evaluateMechanicProgram( if (clause.cooldownSeconds) { cooldowns.set(stateKey, reduction.at + clause.cooldownSeconds); } - outcomes.push(...emitOutcomes( + appendScheduledOutcomes(outcomes, emitOutcomes( clause, 'after', reduction.at, @@ -1745,9 +1999,12 @@ export function evaluateMechanicProgram( before, context, continuousDeliveries - )); + ), reduction.at, scenario.encounterSeconds); } } + if (event.kind === 'weapon_hit' && event.targetId) { + previousWeaponHitTargetId = event.targetId; + } } outcomes.push(...expirePools( scenario.encounterSeconds, @@ -1821,6 +2078,7 @@ function formatReference(reference: ValueReference): string { function formatExpression(expression: NumericExpression): string { if (typeof expression === 'number') return formatNumber(expression); if ('ref' in expression) return formatReference(expression.ref); + if (expression.op === 'floor') return `FLOOR(${formatExpression(expression.value)})`; if (expression.op === 'clamp') { return `CLAMP(${formatExpression(expression.value)}, ${formatExpression(expression.min)}, ${formatExpression(expression.max)})`; } @@ -1916,7 +2174,14 @@ function formatOutcome(outcome: MechanicOutcome): string { const delivery = outcome.delivery === 'continuous' ? `; DELIVER TOTAL CONTINUOUSLY OVER ${formatNumber(outcome.durationSeconds ?? 0)}s` : ''; - return `${outcome.phase.toUpperCase()}: APPLY ${formatExpression(outcome.value)} ${outcome.basis.toUpperCase()}${resource}${attribute}${perStack}${stackBand}${timing}${delivery} // ${outcome.label}`; + const targeting = outcome.targeting + ? ` TO ${outcome.targeting.population.toUpperCase()} WITHIN ${formatNumber(outcome.targeting.radiusMeters)}m` + : ''; + if (outcome.kind === 'weapon_reload') { + const target = outcome.scope === 'equipped_weapon' ? 'EQUIPPED' : 'INACTIVE'; + return `${outcome.phase.toUpperCase()}: RELOAD ${target} WEAPON AFTER ${formatExpression(outcome.value)}s // ${outcome.label}`; + } + return `${outcome.phase.toUpperCase()}: APPLY ${formatExpression(outcome.value)} ${outcome.basis.toUpperCase()}${resource}${attribute}${perStack}${stackBand}${timing}${targeting}${delivery} // ${outcome.label}`; } export function formatMechanicProgram(program: MechanicProgram): string { diff --git a/src/lib/calculations/skill.ts b/src/lib/calculations/skill.ts index 7c8dc1a..f1594a3 100644 --- a/src/lib/calculations/skill.ts +++ b/src/lib/calculations/skill.ts @@ -248,7 +248,7 @@ function stageFactorAt( return Math.max(EPSILON, (1 + values.additive / 100) * values.multiplicative); } -function activationTimes( +export function skillActivationTimes( ability: SkillAbilityInput, encounterSeconds: number, input: SkillCalculationInput, @@ -483,7 +483,7 @@ export function calculateSkillDps(input: SkillCalculationInput): SkillCalculatio }; } - const activations = activationTimes(ability, encounterSeconds, input, rules); + const activations = skillActivationTimes(ability, encounterSeconds, input, rules); const events = damageEvents(ability, activations, encounterSeconds, input, rules); const damageByEvent = events.map((event) => expectedDamage( diff --git a/src/lib/calculations/talent-description.test.ts b/src/lib/calculations/talent-description.test.ts index e083e33..1674bed 100644 --- a/src/lib/calculations/talent-description.test.ts +++ b/src/lib/calculations/talent-description.test.ts @@ -42,6 +42,46 @@ describe('talent description outcomes', () => { expect(missingTalentDescriptionSlots('Restore {1} over time.', outcomes)).toEqual([]); }); + it('accepts a numbered marker outcome for a structured timing value', () => { + const outcomes = numberedTalentOutcomes(draft([{ + key: 'delayed-reload', + label: 'Delayed reload', + phase: 'after_event', + kind: 'marker', + scope: 'equipped_weapon', + basis: 'flat', + descriptionSlot: 1, + variants: { + I: { value: 4 }, + II: { value: 3 } + } + }])); + + expect(outcomes).toHaveLength(1); + expect(outcomes[0]).toMatchObject({ kind: 'marker', descriptionSlot: 1 }); + expect(missingTalentDescriptionSlots('Reload after {1} seconds.', outcomes)).toEqual([]); + }); + + it('accepts a numbered weapon-reload delay', () => { + const outcomes = numberedTalentOutcomes(draft([{ + key: 'inactive-reload', + label: 'Inactive weapon reload', + phase: 'after_event', + kind: 'weapon_reload', + scope: 'inactive_weapon', + basis: 'flat', + descriptionSlot: 1, + variants: { + I: { value: 4 }, + II: { value: 3 } + } + }])); + + expect(outcomes).toHaveLength(1); + expect(outcomes[0]).toMatchObject({ kind: 'weapon_reload', descriptionSlot: 1 }); + expect(missingTalentDescriptionSlots('Reload after {1} seconds.', outcomes)).toEqual([]); + }); + it('reports exact missing slots instead of relying on outcome count', () => { const outcomes = numberedTalentOutcomes(draft([{ key: 'damage', diff --git a/src/lib/calculations/talent-description.ts b/src/lib/calculations/talent-description.ts index 601d905..bf89f75 100644 --- a/src/lib/calculations/talent-description.ts +++ b/src/lib/calculations/talent-description.ts @@ -7,7 +7,9 @@ const VALUE_BEARING_OUTCOME_KINDS = new Set([ 'attribute_modifier', 'direct_damage', 'restoration', - 'resource_cost' + 'resource_cost', + 'weapon_reload', + 'marker' ]); export const numberedTalentOutcomes = ( diff --git a/src/lib/components/SearchableAssignment.svelte b/src/lib/components/SearchableAssignment.svelte index c298843..2f25937 100644 --- a/src/lib/components/SearchableAssignment.svelte +++ b/src/lib/components/SearchableAssignment.svelte @@ -5,6 +5,9 @@ id: string; name: string; unit?: string; + detail?: string; + disabled?: boolean; + disabledReason?: string; pending?: boolean; proposerName?: string; iconUrl?: string | null; @@ -52,8 +55,14 @@ const visible = $derived(presentation === 'list' ? available.slice(0, 30) : available); const selectedItems = $derived(selectedIds.map((id) => items.find((item) => item.id === id)).filter((item): item is Item => Boolean(item))); const unitLabel = (item: Item) => item.unit === undefined ? '' : item.unit || 'FLAT'; - const accessibleName = (item: Item) => unitLabel(item) ? `${item.name}, unit ${unitLabel(item)}` : item.name; + const accessibleName = (item: Item) => [ + item.name, + unitLabel(item) ? `unit ${unitLabel(item)}` : '', + item.detail ?? '', + item.disabledReason ?? '' + ].filter(Boolean).join(', '); const toggle = (id: string) => { + if (items.find((item) => item.id === id)?.disabled && !selectedIds.includes(id)) return; if (mode === 'single') { value = value === id ? '' : id; onvaluechange?.(value); @@ -77,7 +86,7 @@ {#each selectedItems as item} {:else} {emptySelectionLabel} @@ -89,12 +98,12 @@ {/if}
{#each visible as item} - {:else} diff --git a/src/lib/components/builder/LoadoutMatrix.svelte b/src/lib/components/builder/LoadoutMatrix.svelte index 5389e3d..dfc4743 100644 --- a/src/lib/components/builder/LoadoutMatrix.svelte +++ b/src/lib/components/builder/LoadoutMatrix.svelte @@ -8,6 +8,7 @@ meta: string; summary: string; iconUrl?: string; + issue?: string; }; let { @@ -34,6 +35,12 @@ active === cell.id || (cell.id.startsWith('ability:') && active.startsWith('chip:') && Math.floor(Number(active.split(':')[1]) / 3) === Number(cell.id.split(':')[1])); + + const issueLabel = (issue: string) => { + if (/not configured|required/i.test(issue)) return 'ACTION REQUIRED'; + if (/incomplete/i.test(issue)) return 'DATA INCOMPLETE'; + return 'CHECK CONFIGURATION'; + };
@@ -48,19 +55,20 @@ type="button" class="doctrine-cell" class:active={selected(doctrine)} + class:needs-attention={Boolean(doctrine.issue)} aria-pressed={selected(doctrine)} onclick={() => onselect(doctrine.id)} > - {doctrine.label}{doctrine.name}{doctrine.summary} + {doctrine.label}{doctrine.name}{doctrine.summary}{#if doctrine.issue}{doctrine.issue}{/if}
SUPPLIES 3 ABILITIES
{#each abilities as cell} - {/each}
@@ -70,9 +78,9 @@
{#each weapons as cell} - {/each} @@ -83,9 +91,9 @@
{#each gear as cell} - {/each} @@ -94,9 +102,9 @@
-
@@ -113,9 +121,10 @@ :global(.matrix-icon){width:30px;height:30px;object-fit:contain} button>span{min-width:0;display:grid;gap:2px}button small,button em{overflow:hidden;color:var(--shd-text-dim);font-size:9px;font-style:normal;letter-spacing:.04em;text-overflow:ellipsis;white-space:nowrap}button strong{overflow:hidden;font-size:11px;text-overflow:ellipsis;white-space:nowrap}button>i{align-self:stretch;background:var(--quality-high-end)} button:hover,button:focus-visible{border-color:var(--shd-border-strong);background:var(--shd-surface-active);outline:none}button.active{border-color:var(--shd-builder-selection-border);background:var(--shd-builder-selection);box-shadow:inset 2px 0 var(--shd-orange)} + button.needs-attention{border-bottom-color:color-mix(in srgb,var(--shd-danger) 68%,var(--shd-border-soft))}button.needs-attention>i{background:var(--shd-danger)}button mark{width:fit-content;max-width:100%;overflow:hidden;margin-top:2px;padding:2px 4px;border:1px solid color-mix(in srgb,var(--shd-danger) 58%,transparent);background:#1d1212;color:var(--shd-danger);font-size:8px;font-weight:900;letter-spacing:.07em;line-height:1.2;text-overflow:ellipsis;text-transform:uppercase;white-space:nowrap} .supply-line{height:22px;display:grid;place-items:center;background:linear-gradient(90deg,transparent 49.7%,var(--shd-orange) 49.7% 50.3%,transparent 50.3%)}.supply-line span{padding:0 6px;background:var(--shd-surface);color:var(--shd-orange);font-size:8px;font-weight:900;letter-spacing:.08em} .ability-cells{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:3px}.ability-cells button{min-height:68px;grid-template-columns:25px minmax(0,1fr);padding:6px}.ability-cells :global(.matrix-icon){width:24px;height:24px} - .equipment-cells{display:grid;grid-template-columns:1fr 1fr;gap:3px}.equipment-cells button{min-height:69px}.protocol-section>button{width:100%;min-height:68px} + .equipment-cells{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:3px}.equipment-cells button{min-height:69px}.protocol-section>button{width:100%;min-height:68px} button:focus-visible{outline:2px solid var(--shd-focus-ring);outline-offset:2px} @media(max-width:1240px){.ability-cells{grid-template-columns:1fr}.ability-cells button{min-height:48px}} @media(max-width:800px){.ability-cells{grid-template-columns:repeat(3,minmax(0,1fr))}} diff --git a/src/lib/components/builder/SkillCalculationResult.svelte b/src/lib/components/builder/SkillCalculationResult.svelte index a6fc0a2..915c3f7 100644 --- a/src/lib/components/builder/SkillCalculationResult.svelte +++ b/src/lib/components/builder/SkillCalculationResult.svelte @@ -3,10 +3,12 @@ let { result, - abilityNames = {} + abilityNames = {}, + resolverHint = 'Complete the required Specialization ability data.' }: { result: SkillCalculation; abilityNames?: Record; + resolverHint?: string; } = $props(); const number = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }); @@ -17,10 +19,10 @@ {result.status === 'OK' ? 'CALCULATED' : 'INSUFFICIENT DATA'} {#if result.status === 'INSUFFICIENT_DATA'} -
- Skill calculation unavailable - ISAC requires the following data: -
    {#each result.missing as item}
  • {item.replaceAll(':', ' · ')}
  • {/each}
+
+ SKILL OUTPUT BLOCKED + {result.missing[0]?.replaceAll(':', ' · ') ?? 'Missing ability data'} + {result.missing.length > 1 ? `+${result.missing.length - 1} additional requirement${result.missing.length === 2 ? '' : 's'}. ` : ''}{resolverHint}
{:else}
@@ -39,19 +41,19 @@ {/each}
{/if} -
SKILLS CAN CRIT AND MULTI-SHOT · SKILLS DO NOT HEADSHOT · ENCOUNTER TRIGGERS FOLLOW THE PRIMARY WEAPON TIMELINE
+ {#if result.status === 'OK'}
SKILLS CAN CRIT AND MULTI-SHOT · SKILLS DO NOT HEADSHOT · ENCOUNTER TRIGGERS FOLLOW THE PRIMARY WEAPON TIMELINE
{/if} diff --git a/src/lib/components/builder/WeaponCalculationResult.svelte b/src/lib/components/builder/WeaponCalculationResult.svelte index 2f733e7..29d0ac0 100644 --- a/src/lib/components/builder/WeaponCalculationResult.svelte +++ b/src/lib/components/builder/WeaponCalculationResult.svelte @@ -28,9 +28,19 @@ revisionLabel?: string; }; - let { result }: { result: WeaponCalculationResult } = $props(); + let { + result, + resolverHint = 'Complete the required build configuration.' + }: { + result: WeaponCalculationResult; + resolverHint?: string; + } = $props(); const number = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }); function format(value: number | undefined) { return value === undefined ? '—' : number.format(value); } + const loadoutRequirement = (item: string) => + /weapon$| gear$|OS Protocol|Specialization focus|focus abilities|Skill Chips?$/i.test(item); + const systemRequirements = $derived((result.missing ?? []).filter((item) => !loadoutRequirement(item))); + const loadoutRequirementCount = $derived((result.missing ?? []).length - systemRequirements.length);
@@ -39,7 +49,11 @@ {result.status === 'complete' ? 'CALCULATED' : 'INSUFFICIENT DATA'} {#if result.status === 'insufficient'} -
Calculation unavailableComplete the following required data:
    {#each result.missing ?? [] as item}
  • {item}
  • {/each}
+
+ OUTPUT BLOCKED + {systemRequirements[0] ?? `${loadoutRequirementCount} loadout requirement${loadoutRequirementCount === 1 ? '' : 's'}`} + {loadoutRequirementCount ? `${loadoutRequirementCount} loadout requirement${loadoutRequirementCount === 1 ? '' : 's'} remain. ${resolverHint}` : resolverHint} +
{:else}
BODY HIT{format(result.bodyHit)}CRITICAL BODY{format(result.criticalBodyHit)} @@ -63,14 +77,14 @@
{/if} {/if} -
MODELED, BEFORE ENEMY MITIGATION{#if result.revisionLabel}{result.revisionLabel}{/if}
+ {#if result.status === 'complete'}
MODELED, BEFORE ENEMY MITIGATION{#if result.revisionLabel}{result.revisionLabel}{/if}
{/if}
diff --git a/src/lib/components/isac/MechanicProgramEditor.svelte b/src/lib/components/isac/MechanicProgramEditor.svelte index 27bd1bd..d423fab 100644 --- a/src/lib/components/isac/MechanicProgramEditor.svelte +++ b/src/lib/components/isac/MechanicProgramEditor.svelte @@ -7,11 +7,21 @@ import type { AiMechanicContext } from '$lib/ai/mechanic-authoring'; import { blankConditionalMechanicDraft, + currentHealthPercentBandPredicate, + eventFieldComparisonPredicate, parseConditionalMechanicDraft, + readCurrentHealthPercentBand, + readEventFieldComparison, + readStatePoolThreshold, + statePoolThresholdPredicate, type ConditionalMechanicClauseDraft, type ConditionalMechanicDraftV1, type ConditionalMechanicOutcomeDraft, - type ConditionalMechanicPoolDraft + type ConditionalMechanicPoolDraft, + type EditablePredicateScalar, + type EventFieldComparison, + type PredicateComparisonOperator, + type StatePoolThresholdComparison } from '$lib/calculations/mechanic-editor'; import { formatMechanicProgram, @@ -59,6 +69,11 @@ let recipeCount = $state(9); let recipeWindow = $state(3); let recipeDistance = $state(10); + let recipeHealthBands = $state([ + { thresholdPercent: 25, outcomeKey: '15-percent-reduction' }, + { thresholdPercent: 50, outcomeKey: '12-percent-reduction' }, + { thresholdPercent: 75, outcomeKey: '9-percent-reduction' } + ]); let replaceConfirmation = $state(false); let valueModelOverrides = $state>({}); @@ -90,17 +105,32 @@ { value: 'direct_damage', label: 'DAMAGE' }, { value: 'restoration', label: 'RESTORE' }, { value: 'resource_cost', label: 'COST' }, + { value: 'weapon_reload', label: 'WEAPON RELOAD' }, { value: 'marker', label: 'MARKER' } ]; const scopeOptions = $derived([ { value: 'self', label: 'SELF' }, { value: 'equipped_weapon', label: 'WEAPON' }, + { value: 'inactive_weapon', label: 'INACTIVE WEAPON' }, { value: 'target', label: 'TARGET' }, { value: 'allies', label: 'ALLIES' }, { value: 'self_and_allies', label: 'SELF + ALLIES' }, ...(allowAssignedAbility ? [{ value: 'assigned_ability', label: 'ABILITY' }] : []), { value: 'all_skills', label: 'ALL SKILLS' } ]); + const predicateOperatorOptions = [ + { value: 'eq', label: '=' }, + { value: 'ne', label: '≠' }, + { value: 'gt', label: '>' }, + { value: 'gte', label: '≥' }, + { value: 'lt', label: '<' }, + { value: 'lte', label: '≤' } + ]; + const predicateScalarTypeOptions = [ + { value: 'string', label: 'TEXT' }, + { value: 'number', label: 'NUMBER' }, + { value: 'boolean', label: 'BOOLEAN' } + ]; const slug = (value: string) => value.toLocaleLowerCase() .replace(/[^a-z0-9]+/g, '-') @@ -114,10 +144,119 @@ if (recipeKey === 'consecutive_same_target_hits') return { hitsRequired: recipeCount }; if (recipeKey === 'continuous_firing_duration') return { seconds: recipeWindow }; if (recipeKey === 'target_hit_within_distance' || recipeKey === 'within_distance') return { meters: recipeDistance }; - if (recipeKey === 'health_threshold_bands') return { bands: [{ thresholdPercent: 50, outcomeKey: 'active' }] }; + if (recipeKey === 'health_threshold_bands') return { bands: recipeHealthBands }; return {}; } + function conditionModel(clause: ConditionalMechanicClauseDraft) { + if (!clause.if || clause.if.kind === 'true') return 'always'; + if (readCurrentHealthPercentBand(clause.if)) return 'current_health_band'; + if (readEventFieldComparison(clause.if)) return 'event_field'; + if (readStatePoolThreshold(clause.if)) return 'state_pool_threshold'; + return 'advanced'; + } + + function conditionOptions(clause: ConditionalMechanicClauseDraft) { + const options = [ + { value: 'always', label: 'ALWAYS' }, + { value: 'current_health_band', label: 'HEALTH BAND' }, + { value: 'event_field', label: 'EVENT FIELD' }, + { value: 'state_pool_threshold', label: 'STATE THRESHOLD', disabled: !rule.pools.length } + ]; + return conditionModel(clause) === 'advanced' + ? [ + ...options.map((option) => ({ ...option, disabled: true })), + { value: 'advanced', label: 'ADVANCED', disabled: true } + ] + : options; + } + + function setConditionModel(clauseIndex: number, model: string) { + if (model === 'always') updateClause(clauseIndex, { if: { kind: 'true' } }); + if (model === 'current_health_band') { + updateClause(clauseIndex, { + if: currentHealthPercentBandPredicate({ maximum: 75 }) + }); + } + if (model === 'event_field') { + updateClause(clauseIndex, { + if: eventFieldComparisonPredicate({ + path: 'values.state', + operator: 'eq', + value: 'moving' + }) + }); + } + if (model === 'state_pool_threshold' && rule.pools[0]) { + updateClause(clauseIndex, { + if: statePoolThresholdPredicate({ + pool: rule.pools[0].key, + operator: 'gt', + value: 0 + }) + }); + } + } + + function updateHealthBand( + clauseIndex: number, + boundary: 'minimum' | 'maximum', + rawValue: string + ) { + const current = readCurrentHealthPercentBand(rule.clauses[clauseIndex].if) ?? { maximum: 75 }; + const numeric = rawValue === '' ? undefined : Number(rawValue); + const next = { + ...(boundary === 'minimum' + ? numeric === undefined ? {} : { minimum: numeric } + : current.minimum === undefined ? {} : { minimum: current.minimum }), + maximum: boundary === 'maximum' ? numeric ?? current.maximum : current.maximum + }; + updateClause(clauseIndex, { if: currentHealthPercentBandPredicate(next) }); + } + + function updateEventFieldComparison( + clauseIndex: number, + patch: Partial + ) { + const current = readEventFieldComparison(rule.clauses[clauseIndex].if) ?? { + path: 'values.state', + operator: 'eq', + value: 'moving' + } as const; + updateClause(clauseIndex, { + if: eventFieldComparisonPredicate({ ...current, ...patch }) + }); + } + + function predicateScalarType(value: EditablePredicateScalar) { + return typeof value; + } + + function setEventFieldScalarType(clauseIndex: number, type: string) { + const current = readEventFieldComparison(rule.clauses[clauseIndex].if); + if (!current) return; + const value: EditablePredicateScalar = type === 'number' + ? (typeof current.value === 'number' ? current.value : 0) + : type === 'boolean' + ? (typeof current.value === 'boolean' ? current.value : true) + : typeof current.value === 'string' ? current.value : String(current.value); + updateEventFieldComparison(clauseIndex, { value }); + } + + function updateStatePoolThreshold( + clauseIndex: number, + patch: Partial + ) { + const current = readStatePoolThreshold(rule.clauses[clauseIndex].if) ?? { + pool: rule.pools[0]?.key ?? '', + operator: 'gt', + value: 0 + } as const; + updateClause(clauseIndex, { + if: statePoolThresholdPredicate({ ...current, ...patch }) + }); + } + function applyRecipe(replace = false) { if (!recipeKey) return; const selectedRecipeKey = recipeKey; @@ -647,6 +786,19 @@ {#if ['target_hit_within_distance', 'within_distance'].includes(recipeKey)} {/if} + {#if recipeKey === 'health_threshold_bands'} +
+

Define strict “below Health” bands from strongest to weakest. Exact thresholds enter the next weaker band.

+ {#each recipeHealthBands as band, index} +
+ + + +
+ {/each} + +
+ {/if} {#if rule.clauses.length && recipeKey} @@ -669,7 +821,13 @@ - + {#if pool.expiresAfterSecondsVariants} + {#each valueColumns as column} + + {/each} + {:else} + + {/if}
{:else} @@ -679,6 +837,9 @@
{#each rule.clauses as clause, clauseIndex} + {@const conditionBand = readCurrentHealthPercentBand(clause.if)} + {@const eventFieldCondition = readEventFieldComparison(clause.if)} + {@const statePoolCondition = readStatePoolThreshold(clause.if)}
{String(clauseIndex + 1).padStart(2, '0')}
@@ -689,6 +850,75 @@
MATCH POLICY{reducerItems.find((item) => item.id === clause.reducer.kind)?.name ?? clause.reducer.kind}
+
+ setConditionModel(clauseIndex,values[0])} + /> + {#if conditionBand} +
+ + +
+ {:else if eventFieldCondition} +
+ + updateEventFieldComparison(clauseIndex,{operator:values[0] as PredicateComparisonOperator})} + /> + setEventFieldScalarType(clauseIndex,values[0])} + /> + {#if typeof eventFieldCondition.value === 'number'} + + {:else if typeof eventFieldCondition.value === 'boolean'} + updateEventFieldComparison(clauseIndex,{value:values[0] === 'true'})} + /> + {:else} + + {/if} +
+ {:else if statePoolCondition} +
+
STATE POOL ({id:pool.key,name:pool.label}))} + mode="single" + compact + subjectLabel="STATE POOL" + value={statePoolCondition.pool} + onvaluechange={(value) => updateStatePoolThreshold(clauseIndex,{pool:value})} + />
+ updateStatePoolThreshold(clauseIndex,{operator:values[0] as PredicateComparisonOperator})} + /> + +
+ {:else if conditionModel(clause) === 'advanced'} +

This saved condition remains active and is preserved unchanged. Use the generated mechanic preview to inspect it.

+ {/if} +
DESCRIPTION SLOT updateOutcome(clauseIndex,outcomeIndex,{descriptionSlot:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/>
4 ? 4 : scopeOptions.length} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{scope:values[0] as MechanicOutcomeScope})}/> + {#if outcome.scope === 'allies' || outcome.scope === 'self_and_allies' || outcome.scope === 'target'} +
+ updateOutcome(clauseIndex,outcomeIndex,{ + targeting: values[0] === 'none' + ? undefined + : {kind:'within_radius',population:values[0] as 'allies'|'enemies',radiusMeters:outcome.targeting?.radiusMeters ?? 20} + })} + /> + {#if outcome.targeting} + + {/if} +
+ {/if} {#if outcome.kind === 'attribute_modifier'} {:else if outcome.kind === 'restoration' || outcome.kind === 'resource_cost'} - updateOutcome(clauseIndex,outcomeIndex,{resource:values[0] as 'health'|'armor'|'ammo'})}/> + updateOutcome(clauseIndex,outcomeIndex,{resource:values[0] as 'health'|'armor'|'ammo'|'signature_ability_charge'})}/> {/if} {#if outcome.kind === 'restoration'}
@@ -876,6 +1124,9 @@ .scenario-row{display:grid;grid-template-columns:minmax(170px,1fr) minmax(260px,1.25fr) minmax(220px,1.3fr) repeat(4,minmax(85px,.45fr)) auto;align-items:end;gap:8px;padding:8px;border-bottom:1px solid var(--shd-border-soft)} .scenario-row>.field{margin:0}.scenario-row>.description-field{min-width:0} .recipe-grid{display:grid;grid-template-columns:minmax(260px,1fr) repeat(3,minmax(110px,.35fr)) auto auto;align-items:end;gap:var(--shd-gap-sm);padding:var(--shd-gap-sm)} + .health-band-config{grid-column:1/-1;display:grid;gap:var(--shd-gap-xs);padding:var(--shd-gap-sm);border:1px solid var(--shd-model-border);background:var(--shd-model-surface)} + .health-band-config>p,.advanced-condition-note{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4} + .health-band-config>div{display:grid;grid-template-columns:minmax(150px,.5fr) minmax(220px,1fr) auto;align-items:end;gap:var(--shd-gap-sm)} .recipe-grid>.field,.pool-row>.field,.branch-grid>.field,.outcome-grid>.field,.variant-grid>.field{margin:0} .replace-confirm{display:flex;align-items:center;justify-content:space-between;gap:var(--shd-gap-sm);padding:9px;border-top:1px solid var(--shd-danger);background:color-mix(in srgb,var(--shd-danger) 10%,var(--shd-surface))} .replace-confirm p{display:grid;gap:2px;margin:0}.replace-confirm span{color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.replace-confirm>div{display:flex;gap:7px} @@ -883,6 +1134,12 @@ .branch-stack{display:grid;gap:var(--shd-gap-sm)} .branch>header>div{display:flex;align-items:end;gap:9px}.branch>header>div>span{align-self:center;color:var(--shd-orange);font:900 var(--shd-type-telemetry)/1 var(--shd-font-mono)}.branch>header .field{min-width:min(360px,55vw);margin:0} .branch-grid{display:grid;grid-template-columns:repeat(2,minmax(240px,1fr)) minmax(160px,.45fr);align-items:end;gap:var(--shd-gap-sm);padding:var(--shd-gap-sm)} + .condition-controls{display:grid;grid-template-columns:minmax(260px,.7fr) minmax(320px,1.3fr);align-items:end;gap:var(--shd-gap-sm);padding:0 var(--shd-gap-sm) var(--shd-gap-sm)} + .health-boundaries{display:grid;grid-template-columns:repeat(2,minmax(150px,1fr));gap:var(--shd-gap-sm)} + .health-boundaries .field small{display:block;margin-top:4px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);line-height:1.3} + .predicate-fields{display:grid;grid-template-columns:minmax(170px,1fr) minmax(190px,.8fr);align-items:end;gap:var(--shd-gap-sm)} + .event-predicate-fields{grid-template-columns:minmax(180px,1fr) minmax(190px,.85fr) minmax(180px,.75fr) minmax(150px,.75fr)} + .predicate-fields>.field{margin:0} .field>span{display:block;margin-bottom:5px;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px} .branch-readout{display:grid;align-content:center;gap:3px;min-height:44px;padding:7px 9px;border:1px solid var(--shd-border);background:var(--shd-surface-raised)}.branch-readout span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.branch-readout strong{font-size:var(--shd-type-supporting)} .reducer-controls{display:grid;grid-template-columns:minmax(0,1fr) repeat(2,minmax(130px,.25fr));align-items:end;gap:var(--shd-gap-sm);padding:0 var(--shd-gap-sm) var(--shd-gap-sm)} @@ -899,7 +1156,7 @@ .compact-action{min-height:36px}.add-branch{min-height:44px}.empty-state{padding:10px}.script-preview{display:grid;grid-template-columns:minmax(190px,.28fr) minmax(0,1fr);gap:var(--shd-gap-sm);padding:var(--shd-gap-sm);border:1px solid var(--shd-border);border-top:2px solid var(--shd-success);background:var(--shd-surface)} .script-preview>div{display:grid;align-content:start;gap:3px}.script-preview>div span{color:var(--shd-success);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px} @media(max-width:1300px){.scenario-row{grid-template-columns:repeat(2,minmax(0,1fr))}.scenario-row>.description-field{grid-column:1/-1}} - @media(max-width:1000px){.recipe-grid,.pool-row,.branch-grid,.outcome-grid,.reducer-controls,.mutation-row,.state-scaling,.delivery-controls,.script-preview{grid-template-columns:1fr 1fr}.recipe-grid>:first-child,.script-preview>:last-child{grid-column:1/-1}.pool-row>:first-child,.branch-grid>:first-child,.outcome-grid>:first-child{grid-column:1/-1}} - @media(max-width:620px){.mechanic-heading,.recipe-panel>header,.scenario-panel>header,.state-panel>header,.branch>header,.outcome-heading,.replace-confirm,.outcome-footer{align-items:stretch;flex-direction:column}.recipe-grid,.scenario-row,.pool-row,.branch-grid,.outcome-grid,.reducer-controls,.mutation-row,.state-scaling,.delivery-controls,.script-preview{grid-template-columns:1fr}.recipe-grid>:first-child,.script-preview>:last-child,.scenario-row>.description-field,.pool-row>:first-child,.branch-grid>:first-child,.outcome-grid>:first-child{grid-column:auto}.branch>header>div{align-items:stretch;flex-direction:column}.branch>header .field{min-width:0}.remove,.danger-quiet,.compact-action,.add-branch{width:100%;min-height:44px}} + @media(max-width:1000px){.recipe-grid,.pool-row,.branch-grid,.outcome-grid,.reducer-controls,.mutation-row,.state-scaling,.delivery-controls,.script-preview{grid-template-columns:1fr 1fr}.recipe-grid>:first-child,.script-preview>:last-child{grid-column:1/-1}.pool-row>:first-child,.branch-grid>:first-child,.outcome-grid>:first-child{grid-column:1/-1}.condition-controls{grid-template-columns:1fr}.event-predicate-fields{grid-template-columns:repeat(2,minmax(0,1fr))}} + @media(max-width:620px){.mechanic-heading,.recipe-panel>header,.scenario-panel>header,.state-panel>header,.branch>header,.outcome-heading,.replace-confirm,.outcome-footer{align-items:stretch;flex-direction:column}.recipe-grid,.scenario-row,.pool-row,.branch-grid,.outcome-grid,.reducer-controls,.mutation-row,.state-scaling,.delivery-controls,.script-preview,.health-band-config>div,.health-boundaries,.predicate-fields,.event-predicate-fields{grid-template-columns:1fr}.recipe-grid>:first-child,.script-preview>:last-child,.scenario-row>.description-field,.pool-row>:first-child,.branch-grid>:first-child,.outcome-grid>:first-child{grid-column:auto}.branch>header>div{align-items:stretch;flex-direction:column}.branch>header .field{min-width:0}.remove,.danger-quiet,.compact-action,.add-branch{width:100%;min-height:44px}} @media(prefers-reduced-motion:reduce){.remove,.danger-quiet{transition:none}} diff --git a/src/lib/server/ai/mechanic-authoring-prompt.test.ts b/src/lib/server/ai/mechanic-authoring-prompt.test.ts index 0b345dd..b529fbc 100644 --- a/src/lib/server/ai/mechanic-authoring-prompt.test.ts +++ b/src/lib/server/ai/mechanic-authoring-prompt.test.ts @@ -78,7 +78,7 @@ describe('AI mechanic authoring prompt', () => { recipeKeys: [] })); - expect(AI_MECHANIC_PROMPT_VERSION).toBe(5); + expect(AI_MECHANIC_PROMPT_VERSION).toBe(6); expect(input.canonicalCatalog.grammar.outcome.optional).toEqual( expect.arrayContaining(['delivery', 'durationSeconds']) ); @@ -115,7 +115,7 @@ describe('AI mechanic authoring prompt', () => { expect.stringContaining('Do not replace continuous delivery with a periodic reducer') ])); expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain( - 'Prompt contract version: 5' + 'Prompt contract version: 6' ); const advertised = structuredClone( @@ -157,6 +157,33 @@ describe('AI mechanic authoring prompt', () => { expect(parsed.ok).toBe(true); }); + it('advertises authoritative current Health percentage with strict band semantics', () => { + const input = JSON.parse(buildAiMechanicAuthoringInput({ + request, + attributes: [], + events: [{ key: 'tick', label: 'Simulation tick' }], + actions: [], + recipeKeys: ['health_threshold_bands'] + })); + + expect(input.canonicalCatalog.grammar.combatReferences.currentHealthPercent) + .toMatchObject({ + label: 'Current Health percentage', + description: expect.stringContaining('0 through 100') + }); + expect(input.canonicalCatalog.grammar.referenceShapes).toContainEqual({ + ref: { + source: 'combat', + path: 'currentHealthPercent', + timing: 'current' + } + }); + expect(input.examplesAndRules).toEqual(expect.arrayContaining([ + expect.stringContaining('operator:"lt"'), + expect.stringContaining('<25, >=25 and <50, and >=50 and <75') + ])); + }); + it('advertises Precise Trauma through target expiry without inventing target tick iteration', () => { const skillDamageId = 'skill-damage-catalog-id'; const input = JSON.parse(buildAiMechanicAuthoringInput({ diff --git a/src/lib/server/ai/mechanic-authoring-prompt.ts b/src/lib/server/ai/mechanic-authoring-prompt.ts index a9988ec..b0e99ca 100644 --- a/src/lib/server/ai/mechanic-authoring-prompt.ts +++ b/src/lib/server/ai/mechanic-authoring-prompt.ts @@ -1,8 +1,9 @@ import type { AiMechanicAuthoringRequest } from '$lib/ai/mechanic-authoring'; import { CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES } from '$lib/calculations/mechanic-editor'; +import { MECHANIC_COMBAT_REFERENCE_REGISTRY } from '$lib/calculations/mechanic'; import type { AiMechanicAttribute } from './mechanic-authoring-context'; -export const AI_MECHANIC_PROMPT_VERSION = 5; +export const AI_MECHANIC_PROMPT_VERSION = 6; const mechanicsGrammar = { version: 1, @@ -17,9 +18,9 @@ const mechanicsGrammar = { required: ['key', 'label', 'stateKind', 'scope', 'initial'], stateKind: ['flag', 'count', 'stack', 'duration', 'scalar', 'target_mark', 'resource'], scope: ['agent', 'target'], - optional: ['min', 'max', 'bestCaseValue', 'expiresAfterSeconds'], + optional: ['min', 'max', 'bestCaseValue', 'expiresAfterSeconds', 'expiresAfterSecondsVariants'], expirationSemantics: - 'expiresAfterSeconds is one timer per pool entity. Every write resets that entity timer, and expiry resets only that entity to the pool initial value.', + 'expiresAfterSeconds is one timer per pool entity. Use expiresAfterSecondsVariants instead when each source variant has a different duration; define every requested variant exactly once. Every write resets that entity timer, and expiry resets only that entity to the pool initial value.', targetScopeSemantics: 'A target-scoped pool stores one independent value and expiry timer per resolved entity. Use mutation.entity event_target when a weapon-hit mechanic binds state to the enemy that was hit.' }, @@ -43,11 +44,13 @@ const mechanicsGrammar = { mutationKinds: ['none', 'add', 'set', 'consume', 'derive', 'clear', 'transfer'], entitySelectors: ['agent', 'event_actor', 'event_target', 'event_field'], valueReferences: ['event', 'scenario', 'combat', 'equipment', 'attribute', 'reducer', 'pool'], - numericExpressionOperators: ['add', 'subtract', 'multiply', 'divide', 'min', 'max', 'clamp'], + combatReferences: MECHANIC_COMBAT_REFERENCE_REGISTRY, + numericExpressionOperators: ['add', 'subtract', 'multiply', 'divide', 'min', 'max', 'clamp', 'floor'], referenceShapes: [ { ref: { source: 'event', path: 'values.distanceMeters' } }, { ref: { source: 'scenario', key: 'input-key' } }, { ref: { source: 'combat', path: 'currentHealth', timing: 'current' } }, + { ref: { source: 'combat', path: 'currentHealthPercent', timing: 'current' } }, { ref: { source: 'equipment', path: 'protocolTalentUpgrades' } }, { ref: { source: 'attribute', attributeId: 'replace-with-an-exact-catalog-attribute-id' } }, { ref: { source: 'reducer' } }, @@ -56,7 +59,8 @@ const mechanicsGrammar = { expressionShapes: [ { op: 'multiply', left: 3, right: { ref: { source: 'pool', pool: 'stacks' } } }, { op: 'min', left: { ref: { source: 'reducer' } }, right: 5 }, - { op: 'clamp', value: { ref: { source: 'event', path: 'values.enemyCount' } }, min: 0, max: 5 } + { op: 'clamp', value: { ref: { source: 'event', path: 'values.enemyCount' } }, min: 0, max: 5 }, + { op: 'floor', value: { op: 'divide', left: { ref: { source: 'event', path: 'values.distanceMeters' } }, right: 5 } } ], predicateShapes: [ { kind: 'true' }, @@ -339,6 +343,8 @@ export function buildAiMechanicAuthoringInput(input: { 'Percent values use the numeric percentage shown in game, such as 3 for +3%, rather than 0.03.', 'Live Attribute references must use {source:"attribute",attributeId:""}. Never use an Attribute name or invent an ID.', 'An Attribute whose catalog unit is % resolves to its displayed percentage number, such as 20 for 20%. Divide that reference by 100 when wording applies it as a percentage multiplier, such as 240 × 20%.', + 'combat.currentHealthPercent with timing:"current" resolves authoritative current Health divided by resolved maximum Health on a 0–100 scale. Use operator:"lt" for exact “below” wording.', + 'Health threshold bands must be mutually exclusive. For thresholds 25, 50, and 75, use <25, >=25 and <50, and >=50 and <75; exact threshold values enter the next weaker band.', 'Continuous Health restoration is supported. Use kind:"restoration", resource:"health", delivery:"continuous", and a positive durationSeconds. The variant expression resolves the total restoration across that duration, not a per-second value. Do not replace continuous delivery with a periodic reducer.', 'Each continuous-restoration activation creates an independent constant-rate stream. Overlapping streams add, and restoration above the resource maximum is lost when it occurs.', '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.', diff --git a/src/lib/server/conditional-effects.test.ts b/src/lib/server/conditional-effects.test.ts index 4884eb2..2fbe44e 100644 --- a/src/lib/server/conditional-effects.test.ts +++ b/src/lib/server/conditional-effects.test.ts @@ -1,5 +1,6 @@ import { connect } from '@tursodatabase/database'; import { describe, expect, it } from 'vitest'; +import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor'; import { conditionalRuleDraftFromRows, loadConditionalRuleDraft, @@ -174,7 +175,7 @@ describe('conditional mechanic persistence adapter', () => { stat_attribute_id TEXT, value REAL, value_expression_json TEXT, value_basis TEXT, value_scaling TEXT, unit TEXT, damage_basis TEXT, application_phase TEXT, combat_timing TEXT, resource_kind TEXT, delivery_mode TEXT, - delivery_duration_seconds REAL, min_state_value REAL, max_state_value REAL, + delivery_duration_seconds REAL, targeting_json TEXT, min_state_value REAL, max_state_value REAL, description_slot INTEGER, sort_order INTEGER, created_at TEXT, updated_at TEXT )`); @@ -189,13 +190,24 @@ describe('conditional mechanic persistence adapter', () => { scope: 'target', initial: 0, min: 0, - max: 1 + max: 1, + expiresAfterSecondsVariants: { + I: 12, + II: 18 + } + } as ConditionalMechanicDraftV1['pools'][number] & { + expiresAfterSecondsVariants: Record; }], clauses: [{ key: 'weapon-kill', label: 'Weapon kill', event: 'enemy_killed', - if: { kind: 'true' }, + if: { + kind: 'compare', + left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' }, + operator: 'lt', + right: 75 + }, reducer: { kind: 'none' }, mutations: [{ kind: 'set', @@ -213,6 +225,7 @@ describe('conditional mechanic persistence adapter', () => { basis: 'flat', delivery: 'continuous', durationSeconds: 3, + targeting: { kind: 'within_radius', population: 'allies', radiusMeters: 40 }, variants: { I: { value: 240 }, II: { value: 336 } @@ -275,6 +288,17 @@ describe('conditional mechanic persistence adapter', () => { { delivery_mode: 'continuous', delivery_duration_seconds: 3 }, { delivery_mode: 'instant', delivery_duration_seconds: null } ]); + expect(await (await database.prepare( + 'SELECT duration_seconds,parameters_json FROM conditional_effect_state_pools' + )).get()).toEqual({ + duration_seconds: null, + parameters_json: JSON.stringify({ + expiresAfterSecondsVariants: { + I: 12, + II: 18 + } + }) + }); const loaded = await loadConditionalRuleDraft( database, 'talent', @@ -284,15 +308,26 @@ describe('conditional mechanic persistence adapter', () => { expect(loaded?.clauses[0].outcomes[0]).toMatchObject({ delivery: 'continuous', durationSeconds: 3, + targeting: { kind: 'within_radius', population: 'allies', radiusMeters: 40 }, variants: { I: { value: 240 }, II: { value: 336 } } }); + expect(loaded?.clauses[0].if).toEqual({ + kind: 'compare', + left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' }, + operator: 'lt', + right: 75 + }); expect(loaded?.pools[0]).toMatchObject({ key: 'marked-target', stateKind: 'target_mark', - scope: 'target' + scope: 'target', + expiresAfterSecondsVariants: { + I: 12, + II: 18 + } }); expect(loaded?.clauses[0].mutations).toEqual([{ kind: 'set', diff --git a/src/lib/server/conditional-effects.ts b/src/lib/server/conditional-effects.ts index d6f737b..ccd4371 100644 --- a/src/lib/server/conditional-effects.ts +++ b/src/lib/server/conditional-effects.ts @@ -37,9 +37,11 @@ const applicationKind = (outcome: ConditionalMechanicOutcomeDraft) => { if (outcome.kind === 'attribute_modifier') return 'active_modifier'; if (outcome.kind === 'direct_damage') return 'direct_damage'; if (outcome.kind === 'resource_cost') return 'resource_cost'; + if (outcome.kind === 'weapon_reload') return 'weapon_reload'; if (outcome.kind === 'marker') return 'marker'; if (outcome.resource === 'armor') return 'restore_armor'; if (outcome.resource === 'ammo') return 'restore_ammo'; + if (outcome.resource === 'signature_ability_charge') return 'restore_signature_ability_charge'; return 'restore_health'; }; @@ -47,17 +49,24 @@ const outcomeKind = (row: OutcomeRow): MechanicOutcome['kind'] => { if (row.application_kind === 'active_modifier') return 'attribute_modifier'; if (row.application_kind === 'direct_damage') return 'direct_damage'; if (row.application_kind === 'resource_cost') return 'resource_cost'; + if (row.application_kind === 'weapon_reload') return 'weapon_reload'; if (row.application_kind === 'marker') return 'marker'; return 'restoration'; }; const outcomeResource = (row: OutcomeRow): MechanicResource | undefined => { - if (row.resource_kind === 'health' || row.resource_kind === 'armor' || row.resource_kind === 'ammo') { + if ( + row.resource_kind === 'health' + || row.resource_kind === 'armor' + || row.resource_kind === 'ammo' + || row.resource_kind === 'signature_ability_charge' + ) { return row.resource_kind; } if (row.application_kind === 'restore_health') return 'health'; if (row.application_kind === 'restore_armor') return 'armor'; if (row.application_kind === 'restore_ammo') return 'ammo'; + if (row.application_kind === 'restore_signature_ability_charge') return 'signature_ability_charge'; return undefined; }; @@ -74,6 +83,7 @@ const outcomeMetadataColumns = [ 'resource_kind', 'delivery_mode', 'delivery_duration_seconds', + 'targeting_json', 'state_pool_id', 'min_state_value', 'max_state_value', @@ -131,17 +141,25 @@ export function conditionalRuleDraftFromRows( const pools = poolRows .filter((row) => row.rule_id === rule.id) .sort((left, right) => Number(left.sort_order) - Number(right.sort_order)) - .map((row): ConditionalMechanicPoolDraft => ({ - key: String(row.state_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 || row.minimum_value === undefined ? {} : { min: Number(row.minimum_value) }), - ...(row.maximum_value === null || row.maximum_value === undefined ? {} : { max: Number(row.maximum_value) }), - ...(row.best_case_value === null || row.best_case_value === undefined ? {} : { bestCaseValue: Number(row.best_case_value) }), - ...(row.duration_seconds === null || row.duration_seconds === undefined ? {} : { expiresAfterSeconds: Number(row.duration_seconds) }) - })); + .map((row): ConditionalMechanicPoolDraft => { + const parameters = parseJson>(row.parameters_json, {}); + const expiresAfterSecondsVariants = parameters.expiresAfterSecondsVariants; + return { + key: String(row.state_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 || row.minimum_value === undefined ? {} : { min: Number(row.minimum_value) }), + ...(row.maximum_value === null || row.maximum_value === undefined ? {} : { max: Number(row.maximum_value) }), + ...(row.best_case_value === null || row.best_case_value === undefined ? {} : { bestCaseValue: Number(row.best_case_value) }), + ...(row.duration_seconds === null || row.duration_seconds === undefined ? {} : { expiresAfterSeconds: Number(row.duration_seconds) }), + ...(expiresAfterSecondsVariants && typeof expiresAfterSecondsVariants === 'object' + && !Array.isArray(expiresAfterSecondsVariants) + ? { expiresAfterSecondsVariants: expiresAfterSecondsVariants as Record } + : {}) + }; + }); const poolKeyById = new Map(poolRows.map((row) => [String(row.id), String(row.state_key)])); const rowsByClause = new Map(); for (const row of outcomeRows.filter((item) => item.rule_id === rule.id)) { @@ -189,6 +207,9 @@ export function conditionalRuleDraftFromRows( ...(delivery === 'continuous' ? { delivery, durationSeconds: durationSeconds as number } : {}), + ...(first.targeting_json + ? { targeting: parseJson(first.targeting_json, undefined) } + : {}), ...(first.stat_attribute_id ? { attributeId: String(first.stat_attribute_id) } : {}), ...(first.state_pool_id && poolKeyById.has(String(first.state_pool_id)) ? { statePool: poolKeyById.get(String(first.state_pool_id)) } @@ -293,6 +314,11 @@ export async function saveConditionalRuleInTransaction( const poolIds = new Map(); for (const [index, pool] of draft.pools.entries()) { + const expiresAfterSecondsVariants = ( + pool as ConditionalMechanicPoolDraft & { + expiresAfterSecondsVariants?: Record; + } + ).expiresAfterSecondsVariants; const poolId = randomUUID(); poolIds.set(pool.key, poolId); await (await database.prepare(`INSERT INTO conditional_effect_state_pools @@ -313,7 +339,9 @@ export async function saveConditionalRuleInTransaction( pool.expiresAfterSeconds ?? null, 'clear_all', 'refresh', - '{}', + JSON.stringify(expiresAfterSecondsVariants + ? { expiresAfterSecondsVariants } + : {}), index + 1, now, now @@ -356,9 +384,9 @@ export async function saveConditionalRuleInTransaction( application_kind,effect_scope,stat_attribute_id,value,value_expression_json, value_basis,value_scaling,unit,damage_basis,application_phase,combat_timing, resource_kind,delivery_mode,delivery_duration_seconds, - min_state_value,max_state_value,description_slot,sort_order, + targeting_json,min_state_value,max_state_value,description_slot,sort_order, created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run( + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run( randomUUID(), ruleId, clauseId, @@ -382,6 +410,7 @@ export async function saveConditionalRuleInTransaction( outcome.resource ?? null, outcome.delivery ?? 'instant', outcome.durationSeconds ?? null, + outcome.targeting === undefined ? null : JSON.stringify(outcome.targeting), outcome.minStacks ?? null, outcome.maxStacks ?? null, outcome.descriptionSlot ?? null, diff --git a/src/lib/server/request-origin.test.ts b/src/lib/server/request-origin.test.ts new file mode 100644 index 0000000..2d6f769 --- /dev/null +++ b/src/lib/server/request-origin.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { isAllowedShdBuildRequestOrigin } from './request-origin'; + +describe('isAllowedShdBuildRequestOrigin', () => { + it('allows requests without an Origin header and same-origin requests', () => { + expect(isAllowedShdBuildRequestOrigin(null, 'http://localhost:5173')).toBe(true); + expect( + isAllowedShdBuildRequestOrigin('http://localhost:5173', 'http://localhost:5173') + ).toBe(true); + }); + + it('allows the HTTPS SHD.build apex and subdomains', () => { + expect(isAllowedShdBuildRequestOrigin('https://shd.build', 'http://internal:3000')).toBe(true); + expect(isAllowedShdBuildRequestOrigin('https://dev.shd.build', 'http://internal:3000')).toBe( + true + ); + expect( + isAllowedShdBuildRequestOrigin('https://preview.branch.shd.build', 'http://internal:3000') + ).toBe(true); + }); + + it('rejects insecure, lookalike, unrelated, and invalid cross-origin values', () => { + expect(isAllowedShdBuildRequestOrigin('http://dev.shd.build', 'http://internal:3000')).toBe( + false + ); + expect(isAllowedShdBuildRequestOrigin('https://evilshd.build', 'http://internal:3000')).toBe( + false + ); + expect(isAllowedShdBuildRequestOrigin('https://example.com', 'http://internal:3000')).toBe( + false + ); + expect(isAllowedShdBuildRequestOrigin('not an origin', 'http://internal:3000')).toBe(false); + }); +}); diff --git a/src/lib/server/request-origin.ts b/src/lib/server/request-origin.ts new file mode 100644 index 0000000..cc59087 --- /dev/null +++ b/src/lib/server/request-origin.ts @@ -0,0 +1,17 @@ +function isShdBuildHostname(hostname: string): boolean { + return hostname === 'shd.build' || hostname.endsWith('.shd.build'); +} + +export function isAllowedShdBuildRequestOrigin( + requestOrigin: string | null, + serverOrigin: string +): boolean { + if (!requestOrigin || requestOrigin === serverOrigin) return true; + + try { + const origin = new URL(requestOrigin); + return origin.protocol === 'https:' && isShdBuildHostname(origin.hostname); + } catch { + return false; + } +} diff --git a/src/routes/api/isac/calculation-assistant/+server.ts b/src/routes/api/isac/calculation-assistant/+server.ts index c611486..5856158 100644 --- a/src/routes/api/isac/calculation-assistant/+server.ts +++ b/src/routes/api/isac/calculation-assistant/+server.ts @@ -15,6 +15,7 @@ import { } from '$lib/server/ai/calculation-authoring-schema'; import { generateAiCalculationDraft } from '$lib/server/ai/calculation-authoring-service'; import { AI_CALCULATION_PROMPT_VERSION } from '$lib/server/ai/calculation-authoring-prompt'; +import { isAllowedShdBuildRequestOrigin } from '$lib/server/request-origin'; import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; @@ -26,7 +27,7 @@ export const POST: RequestHandler = async ({ cookies, request, url }) => { 'ai.calculation.generate' ); const origin = request.headers.get('origin'); - if (origin && origin !== url.origin) { + if (!isAllowedShdBuildRequestOrigin(origin, url.origin)) { return json( { message: 'Cross-origin calculation authoring requests are not allowed.' }, { status: 403 } diff --git a/src/routes/api/isac/mechanic-assistant/+server.ts b/src/routes/api/isac/mechanic-assistant/+server.ts index 4f5f84b..a6021d1 100644 --- a/src/routes/api/isac/mechanic-assistant/+server.ts +++ b/src/routes/api/isac/mechanic-assistant/+server.ts @@ -15,6 +15,7 @@ import { AiMechanicRateLimitError, reserveAiMechanicGeneration } from '$lib/server/ai/mechanic-authoring-rate-limit'; +import { isAllowedShdBuildRequestOrigin } from '$lib/server/request-origin'; import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; @@ -26,7 +27,7 @@ export const POST: RequestHandler = async ({ cookies, request, url }) => { 'ai.mechanic.generate' ); const origin = request.headers.get('origin'); - if (origin && origin !== url.origin) { + if (!isAllowedShdBuildRequestOrigin(origin, url.origin)) { return json( { message: 'Cross-origin mechanic authoring requests are not allowed.' }, { status: 403 } diff --git a/src/routes/api/isac/mechanic-gap-report/+server.ts b/src/routes/api/isac/mechanic-gap-report/+server.ts index dea2da9..cbdcf0d 100644 --- a/src/routes/api/isac/mechanic-gap-report/+server.ts +++ b/src/routes/api/isac/mechanic-gap-report/+server.ts @@ -5,6 +5,7 @@ import { parseMechanicGapReport, submitMechanicGapReport } from '$lib/server/ai/mechanic-gap-report'; +import { isAllowedShdBuildRequestOrigin } from '$lib/server/request-origin'; import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; @@ -16,7 +17,7 @@ export const POST: RequestHandler = async ({ cookies, request, url }) => { 'ai.mechanic.generate' ); const origin = request.headers.get('origin'); - if (origin && origin !== url.origin) { + if (!isAllowedShdBuildRequestOrigin(origin, url.origin)) { return json({ message: 'Cross-origin mechanic gap reports are not allowed.' }, { status: 403 }); } const contentLength = Number(request.headers.get('content-length') ?? 0); diff --git a/src/routes/builder/+page.svelte b/src/routes/builder/+page.svelte index 3ca391b..4e9114f 100644 --- a/src/routes/builder/+page.svelte +++ b/src/routes/builder/+page.svelte @@ -413,7 +413,8 @@ summary: selectedFocus ? `${selectedFocus.primary_attribute} · ${selectedSpecializationPoints} / ${data.specializationProgression.availablePoints} POINTS` : 'ROOT CHOICE · DEFINES ALL THREE ABILITIES', - iconUrl: selectedFocus?.specialization.iconUrl + iconUrl: selectedFocus?.specialization.iconUrl, + issue: !selectedFocus ? 'REQUIRED FOR CALCULATION' : selectedFocus.abilities.length !== 3 ? 'ABILITY DATA INCOMPLETE' : undefined }); const abilityCells = $derived.by((): MatrixCell[] => [0, 1, 2].map((index) => { const ability = selectedFocus?.abilities[index]; @@ -425,7 +426,8 @@ name: ability?.name ?? 'NOT ASSIGNED', meta: ability?.attack?.damage_type ?? '', summary: chipNames.length ? chipNames.join(' · ') : `${configuredChips.length} / 3 SKILL CHIPS`, - iconUrl: ability?.iconUrl + iconUrl: ability?.iconUrl, + issue: !ability ? 'ABILITY NOT CONFIGURED' : configuredChips.length < 3 ? `${3 - configuredChips.length} CHIP${configuredChips.length === 2 ? '' : 'S'} REQUIRED` : undefined }; })); const weaponCells = $derived.by((): MatrixCell[] => draft.weapons.map((selection, index) => { @@ -438,7 +440,8 @@ name: item?.name ?? 'EMPTY SLOT', meta: item?.category ?? '', summary: item ? details || `T${selection.tier} · ${unitValue(selection.damage, '')} DAMAGE` : 'SELECT WEAPON', - iconUrl: weaponIcon(item?.category) + iconUrl: weaponIcon(item?.category), + issue: item ? undefined : 'REQUIRED FOR CALCULATION' }; })); const gearCells = $derived.by((): MatrixCell[] => gearSlots.map((slot) => { @@ -455,7 +458,8 @@ name: item?.name ?? 'EMPTY SLOT', meta: item ? `T${item.tier}` : '', summary: item ? details || `T${item.tier} · LEVEL ${selection.level}` : 'SELECT GEAR', - iconUrl: gearIcon(slot) + iconUrl: gearIcon(slot), + issue: item ? undefined : 'REQUIRED FOR CALCULATION' }; })); const protocolCell = $derived({ @@ -466,7 +470,8 @@ summary: protocolItem() ? `LEVEL ${draft.protocol.level} · ${draft.protocol.talentUpgrades} / 5 TALENT UPGRADES` : 'SELECT ONE PROTOCOL', - iconUrl: protocolItem()?.iconUrl + iconUrl: protocolItem()?.iconUrl, + issue: protocolItem() ? undefined : 'REQUIRED FOR CALCULATION' }); const loadoutConfigured = $derived(Boolean( selectedFocus @@ -764,8 +769,8 @@