Adding skills & isac talent updates

This commit is contained in:
jb
2026-07-29 20:56:22 -04:00
parent f1b473ffa8
commit 65a4987cad
68 changed files with 5486 additions and 569 deletions
+74
View File
@@ -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
[ ] <Step 1>
[ ] <Step 2>
[ ] <Step 3>
[ ] <Step 4>
[ ] <Step 5>
[ ] <Step 6>
## Open questions
- <Question 1>
- <Question 2>
- <Question 3>
```
## 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)
+21
View File
@@ -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.
+49
View File
@@ -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.
@@ -0,0 +1,131 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Avant Studio</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@500;700&family=Newsreader:opsz,wght@6..72,400;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<a class="skip-link" href="#main">Skip to content</a>
<div class="grain"></div>
<header class="top-bar">
<div class="logo">Avant Studio</div>
<nav>
<a href="#main">Work</a>
<a href="#process">Process</a>
<a href="#contact">Contact</a>
</nav>
<button class="cta">Start a project</button>
</header>
<main id="main">
<section class="hero">
<div class="hero-copy">
<p class="eyebrow">Design lab</p>
<h1>Striking, production-ready frontends without the generic glaze.</h1>
<p class="lede">We craft visceral digital spaces that move fast, feel tactile, and stay memorable. Built with intent, tuned for launch.</p>
<div class="actions">
<button class="cta primary">See capabilities</button>
<button class="ghost">View a live concept</button>
</div>
<div class="stats">
<div><span>42</span> shipped sites</div>
<div><span>95%</span> lighthouse perf</div>
<div><span>0</span> generic templates</div>
</div>
</div>
<div class="hero-card reveal">
<div class="tag">Now launching</div>
<div class="card-body">
<h2>Neon Observatory</h2>
<p>A retro-futurist control room with live telemetry, aurora gradients, and glitch hover states.</p>
<div class="pill">CSS Grid</div>
<div class="pill">Framer Motion</div>
<div class="pill">ARIA-first</div>
<div class="line"></div>
<div class="meta">
<span>ETA: 2 weeks</span>
<span>Visual QA locked</span>
</div>
</div>
</div>
</section>
<section class="grid" id="process">
<article class="panel reveal">
<p class="eyebrow">01 / Intent</p>
<h3>Pick a bold narrative, define the signature move, lock the palette.</h3>
<p>We interrogate purpose, audience, and constraints. Each build commits to one unmistakable gesture—stripe, glow, or grain—and sticks to it.</p>
</article>
<article class="panel reveal">
<p class="eyebrow">02 / Build</p>
<h3>Real code: semantic HTML, layered CSS, purposeful motion.</h3>
<p>CSS variables for theming, responsive grids, progressive enhancement. Animations gated by prefers-reduced-motion.</p>
</article>
<article class="panel reveal">
<p class="eyebrow">03 / Polish</p>
<h3>Accessibility, performance, and tactile interactions.</h3>
<p>Contrast checks, keyboard navigation, tuned shadows, intentional hover states. We avoid boilerplate aesthetics at all costs.</p>
</article>
</section>
<section class="timeline" id="contact">
<div class="timeline-head">
<p class="eyebrow">Field notes</p>
<h3>Recent drops</h3>
</div>
<div class="timeline-rail">
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
</div>
<div class="timeline-items">
<div class="timeline-card reveal">
<p class="label">2024</p>
<h4>Aerial atlas</h4>
<p>Editorial map portal with split-screen narrative and scroll-synced chapters.</p>
</div>
<div class="timeline-card reveal">
<p class="label">2023</p>
<h4>Glassfoundry</h4>
<p>Industrial dashboard with translucent panels, border glows, and live system vitals.</p>
</div>
<div class="timeline-card reveal">
<p class="label">2022</p>
<h4>Islands</h4>
<p>Pastel travel companion with hand-drawn dividers, slow float animations, and tactile cards.</p>
</div>
</div>
</section>
</main>
<footer class="footer">
<div class="marquee" aria-hidden="true">
<span>Distinctive frontends · Real code · No generic glaze · </span>
<span>Distinctive frontends · Real code · No generic glaze · </span>
<span>Distinctive frontends · Real code · No generic glaze · </span>
</div>
<div class="footer-grid">
<div>
<p class="eyebrow">Contact</p>
<a href="mailto:hello@avant.studio">hello@avant.studio</a>
</div>
<div>
<p class="eyebrow">Social</p>
<a href="#">Are.na</a>
<a href="#">Behance</a>
</div>
<div>
<p class="eyebrow">Collab</p>
<p>We ship components, pages, systems, and microsites.</p>
</div>
</div>
</footer>
<script src="./script.js"></script>
</body>
</html>
@@ -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));
@@ -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;
}
}
@@ -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.
@@ -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 `<link>` 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.
@@ -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.
@@ -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."
@@ -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.
@@ -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.
@@ -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."
@@ -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.
+24
View File
@@ -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
+21
View File
@@ -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.
+62
View File
@@ -0,0 +1,62 @@
# Stop Slop
A skill for removing AI tells from prose.
<img width="3840" height="2160" alt="G-Yg4RVbIAAhVxW" src="https://github.com/user-attachments/assets/902afc15-1f40-4a9d-af24-8cd67afb8ebf" />
## 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.
+68
View File
@@ -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
@@ -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.
@@ -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.
@@ -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. |
+202
View File
@@ -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.
+96
View File
@@ -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
@@ -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")
@@ -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()
@@ -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!")
@@ -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()
+2 -1
View File
@@ -37,4 +37,5 @@ Thumbs.db
vite.config.js.timestamp-* vite.config.js.timestamp-*
vite.config.ts.timestamp-* vite.config.ts.timestamp-*
/_img _img/
tmp/
+12
View File
@@ -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 5. Add compatibility support only when the project owner requires it for a
production-stable format. 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 ## ISAC editor lifecycle
All ISAC data-management editors must follow the same successful-save behavior: All ISAC data-management editors must follow the same successful-save behavior:
+1
View File
@@ -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')), 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_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), delivery_duration_seconds REAL CHECK(delivery_duration_seconds IS NULL OR delivery_duration_seconds>0),
targeting_json TEXT,
min_state_value REAL, min_state_value REAL,
max_state_value REAL, max_state_value REAL,
description_slot INTEGER CHECK(description_slot IS NULL OR description_slot>=1), description_slot INTEGER CHECK(description_slot IS NULL OR description_slot>=1),
+48 -14
View File
@@ -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 configuration controls. Do not display a prototype option that the current
loadout schema cannot save. 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 Use `--shd-builder-canvas` for recessed equipment fields. Use
`--shd-builder-selection` with `--shd-builder-selection-border` for the `--shd-builder-selection` with `--shd-builder-selection-border` for the
selected cell. Preserve button semantics, `aria-pressed`, focus-visible 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 never a replacement for the family classification. Exotic definitions add a
dedicated exotic-only talent after the two standard talent pools; their sixth dedicated exotic-only talent after the two standard talent pools; their sixth
augmentation cell communicates that this talent advances to Tier II. augmentation cell communicates that this talent advances to Tier II.
The exotic talent is owned by exactly one exotic weapon and is authored inline The exotic talent is authored in the Talent Registry under the `Exotic Weapon`
inside that weapon editor, never selected from a shared global pool. Its category. The weapon editor uses the shared searchable assignment pattern to
engineered sub-panel includes the talent name, shared description template, reference one registered exotic talent. Each option states whether another
Tier I/II previews, and the shared source-owned mechanic program editor. The Talent weapon owns it and whether contributors have documented Tier II. Assigned
Registry may display an owned exotic talent for discovery, but routes editing talents remain visible but unavailable to other weapon records. The selected
back to its weapon rather than opening the shared-talent editor. 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 Place shared weapon-category bonuses above the weapon registry in one collapsed
profile console. Its six category buttons show the three inherited Attribute 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: 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 - OS Protocol Talents show a base value and may scale an outcome through the
protocol's zero-to-five upgrade expression. protocol's zero-to-five upgrade expression.
- Skill Chip Talents show minimum and maximum values. - 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 The direct-event picker reads the 19 normalized events from the code-defined
event registry. Use searchable single-assignment because the vocabulary exceeds event registry. Use searchable single-assignment because the vocabulary exceeds
the compact segmented-choice limit. Each branch presents its event, match the compact segmented-choice limit. Each branch presents its event, match
policy and parameters, optional cooldown, state mutations, and outcomes. policy and parameters, optional cooldown, condition, state mutations, and
Recipe-generated predicates and weights remain visible in the generated outcomes. The branch condition offers `ALWAYS`, `HEALTH BAND`, `EVENT FIELD`,
script. State rows expose label, agent or target scope, initial value, bounds, 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 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`; uses a `STATE OWNER` segmented control with `EVENT TARGET` and `EVENT ACTOR`;
the selected owner remains visible in the generated script. Outcome rows expose 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 generated script refreshes, and reduced-motion preferences remove expansion
and replacement transitions. and replacement transitions.
The recipe starter opens with no recipe selected. Keep its count, duration, and The recipe starter opens with no recipe selected. Keep its count, duration,
distance parameters hidden until the author chooses a recipe. Disable `ADD distance, and Health-band parameters hidden until the author chooses a recipe.
RECIPE BRANCH` while the selection is empty, and show `REPLACE PROGRAM` only Health-band recipes expose ordered strict upper thresholds and editable band
after the author selects a recipe and the current program contains a branch. 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 #### AI mechanic drafting
+114
View File
@@ -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.
+37 -11
View File
@@ -275,19 +275,25 @@ remain fixed. Each successful save appends a
`game_data/weapon_bonus_profile.updated` audit event with the complete previous `game_data/weapon_bonus_profile.updated` audit event with the complete previous
and resulting profile. and resulting profile.
# Weapon-owned exotic talents # Referenced exotic weapon talents
Standard weapon talent pools remain reusable across weapons and use Talent 1 / 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 Top or Talent 2 / Bottom. Contributors create dedicated talents in the Talent
`weapons.exotic_talent_id`. 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`, An exotic weapon references one active exotic talent through
`talent_tiers`, description template, and typed conditional rule together with `weapons.exotic_talent_id`. The weapon editor lists registered exotic talents
the weapon. Server validation rejects an exotic talent ID owned by another and identifies talents assigned to another weapon. Server validation rejects
weapon and rejects any shared Talent. Converting a weapon back to standard standard talents, inactive talents, and talents already assigned elsewhere.
clears the relationship and archives its now-unowned exotic Talent. The shared Changing or clearing a weapon relationship leaves the previous talent active
Talent Registry rejects exotic Talent creation and editing so contributors and available for another assignment.
cannot bypass weapon ownership.
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 # 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 `source_type` and `source_id` pair identifies that owner. No standalone Trigger
record or shared Trigger relationship participates in execution. 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: 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 - OS Protocol Talents store a base value. An outcome may use an expression that
reads `equipment.protocolTalentUpgrades`, from zero through five, so selected reads `equipment.protocolTalentUpgrades`, from zero through five, so selected
values can scale to twice the base at upgrade five. 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 fails when the loadout does not supply a referenced Attribute; the runtime does
not substitute zero. 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, Continuous restoration resolves the outcome's total when the branch activates,
then delivers that total at a constant rate over the configured duration. then delivers that total at a constant rate over the configured duration.
Activations create independent streams, overlapping streams add, and restoration Activations create independent streams, overlapping streams add, and restoration
+225 -5
View File
@@ -644,7 +644,8 @@ if (conditionalRuleColumns.some((column) => column.name === 'trigger_id')) {
variant_key TEXT NOT NULL DEFAULT 'base', variant_key TEXT NOT NULL DEFAULT 'base',
outcome_key TEXT NOT NULL DEFAULT 'active', outcome_key TEXT NOT NULL DEFAULT 'active',
application_kind TEXT NOT NULL DEFAULT 'active_modifier' CHECK(application_kind IN ( 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 ( effect_scope TEXT NOT NULL DEFAULT 'self' CHECK(effect_scope IN (
'self','equipped_weapon','target','allies','self_and_allies','assigned_ability','all_skills' '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( CHECK(
(application_kind='active_modifier' AND stat_attribute_id IS NOT NULL AND damage_basis IS NULL) (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='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 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', variant_key TEXT NOT NULL DEFAULT 'base',
outcome_key TEXT NOT NULL DEFAULT 'active', outcome_key TEXT NOT NULL DEFAULT 'active',
application_kind TEXT NOT NULL DEFAULT 'active_modifier' CHECK(application_kind IN ( 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 ( effect_scope TEXT NOT NULL DEFAULT 'self' CHECK(effect_scope IN (
'self','equipped_weapon','target','allies','self_and_allies','assigned_ability','all_skills' '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' 'while_active','on_event','after_event','on_enter','on_exit'
)), )),
combat_timing TEXT NOT NULL DEFAULT 'current' CHECK(combat_timing IN ('pre_event','current')), 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, min_state_value REAL,
max_state_value REAL, max_state_value REAL,
description_slot INTEGER CHECK(description_slot IS NULL OR description_slot>=1), 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( CHECK(
(application_kind='active_modifier' AND stat_attribute_id IS NOT NULL AND damage_basis IS NULL) (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='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 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_health' THEN 'health'
WHEN 'restore_armor' THEN 'armor' WHEN 'restore_armor' THEN 'armor'
WHEN 'restore_ammo' THEN 'ammo' WHEN 'restore_ammo' THEN 'ammo'
WHEN 'restore_signature_ability_charge' THEN 'signature_ability_charge'
ELSE NULL END, ELSE NULL END,
min_state_value,max_state_value,description_slot,sort_order,created_at,updated_at min_state_value,max_state_value,description_slot,sort_order,created_at,updated_at
FROM conditional_effect_outcomes_triggered`); 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 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)'); 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( const calibrationCases = await (await database.prepare(
'SELECT id,loadout_snapshot_json FROM calculation_calibration_cases' 'SELECT id,loadout_snapshot_json FROM calculation_calibration_cases'
)).all(); )).all();
+22 -19
View File
File diff suppressed because one or more lines are too long
+460
View File
@@ -2,12 +2,31 @@ import { describe, expect, it } from 'vitest';
import { evaluateMechanicProgram, type MechanicProgram } from '$lib/calculations/mechanic'; import { evaluateMechanicProgram, type MechanicProgram } from '$lib/calculations/mechanic';
import { createDraft } from './loadout.js'; import { createDraft } from './loadout.js';
import { import {
buildSkillDeploymentEvents,
buildWeaponMechanicTimeline, buildWeaponMechanicTimeline,
equippedConditionalProgram,
mechanicModifiersAt, mechanicModifiersAt,
skillModifierWindowsForMechanic,
weaponCriticalChance weaponCriticalChance
} from './loadout-combat.js'; } from './loadout-combat.js';
import { calculateSkillDps, type SkillAbilityInput } from '$lib/calculations/skill';
describe('loadout mechanic timeline', () => { 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', () => { it('emits deterministic critical hits from modeled critical chance', () => {
const profile = createDraft().combatProfile; const profile = createDraft().combatProfile;
profile.encounterSeconds = 4; profile.encounterSeconds = 4;
@@ -27,6 +46,86 @@ describe('loadout mechanic timeline', () => {
expect(criticalHits).toEqual([false, true, false, true, false]); 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', () => { it('derives critical chance through the configured binding transform', () => {
expect(weaponCriticalChance( expect(weaponCriticalChance(
{ critical: 80 }, { critical: 80 },
@@ -40,6 +139,367 @@ describe('loadout mechanic timeline', () => {
}] }]
)).toBe(30); )).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', () => { describe('per-shot mechanic modifiers', () => {
+312 -122
View File
@@ -17,8 +17,12 @@ import {
import { adaptConditionalEffectProgram } from '$lib/calculations/mechanic-persistence'; import { adaptConditionalEffectProgram } from '$lib/calculations/mechanic-persistence';
import { import {
calculateSkillDps, calculateSkillDps,
skillActivationTimes,
type SkillAbilityInput,
type SkillAttributeBinding, type SkillAttributeBinding,
type SkillCalculation, type SkillCalculation,
type SkillCalculationInput,
type SkillCalculationRules,
type SkillModifierWindow type SkillModifierWindow
} from '$lib/calculations/skill'; } from '$lib/calculations/skill';
import { gearSlots, scaled, type LoadoutDraft } from '$lib/builder/loadout'; 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:')) { if (String(equipped.key).startsWith('chips:')) {
return interpolatedConditionalProgram(equipped.rule, 'minimum', 'maximum', Number(equipped.tier), 3); return interpolatedConditionalProgram(equipped.rule, 'minimum', 'maximum', Number(equipped.tier), 3);
} }
if (String(equipped.key).startsWith('protocol:')) { if (String(equipped.key).startsWith('protocol:')) {
return conditionalProgram(equipped.rule, 'base'); return conditionalProgram(equipped.rule, 'base');
} }
return conditionalProgram( const equipmentTalent = String(equipped.key).startsWith('weapon:')
equipped.rule, || String(equipped.key).startsWith('gear:');
String(equipped.key).startsWith('weapon:') || String(equipped.key).startsWith('gear:') const variant = equipmentTalent
? (Number(equipped.tier) >= 2 ? 'II' : 'I') ? (Number(equipped.tier) >= 2 ? 'II' : 'I')
: 'base' : 'base';
); return conditionalProgram(equipped.rule, variant)
?? (equipmentTalent && variant === 'II'
? conditionalProgram(equipped.rule, 'I')
: null);
} }
type ActiveMechanicEvaluation = { export type ActiveMechanicEvaluation = {
status: 'OK'; status: 'OK';
result: MechanicEvaluation; result: MechanicEvaluation;
program: MechanicProgram; program: MechanicProgram;
encounterSeconds: number; 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) const values = configured && typeof configured === 'object' && !Array.isArray(configured)
? configured as Record<string, number | boolean> ? configured as Record<string, number | boolean>
: {}; : {};
return Object.fromEntries((program.scenarioInputs ?? []).map((input) => [ return Object.fromEntries((program.scenarioInputs ?? []).map((input) => [
input.key, 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( function evaluateConfiguredMechanic(
program: MechanicProgram, program: MechanicProgram,
events: MechanicEvent[], buildEvents: (scenarioInputs: Record<string, number | boolean>) => MechanicEvent[],
state: any, state: any,
profile: LoadoutDraft['combatProfile'], profile: LoadoutDraft['combatProfile'],
weaponBaseDamage: number, weaponBaseDamage: number,
@@ -461,12 +475,14 @@ function evaluateConfiguredMechanic(
if (calculationMode !== 'maximum' && state.mode === 'off') return null; if (calculationMode !== 'maximum' && state.mode === 'off') return null;
const encounterSeconds = Math.max(1, Number(profile.encounterSeconds)); const encounterSeconds = Math.max(1, Number(profile.encounterSeconds));
const maximum = calculationMode === 'maximum' || state.mode === 'peak'; const maximum = calculationMode === 'maximum' || state.mode === 'peak';
const scenarioInputs = mechanicScenarioInputs(program, state.inputs, maximum);
const events = buildEvents(scenarioInputs);
const result = evaluateMechanicProgram( const result = evaluateMechanicProgram(
program, program,
events, events,
maximum maximum
? { mode: 'maximum', encounterSeconds } ? { mode: 'maximum', encounterSeconds }
: { mode: 'custom', encounterSeconds, inputs: mechanicScenarioInputs(program, state.inputs) }, : { mode: 'custom', encounterSeconds, inputs: scenarioInputs },
{ {
maxHealth: Math.max(1, maxHealth), maxHealth: Math.max(1, maxHealth),
currentHealth: Math.max(0, maxHealth * Number(profile.healthPercent) / 100), currentHealth: Math.max(0, maxHealth * Number(profile.healthPercent) / 100),
@@ -476,7 +492,7 @@ function evaluateConfiguredMechanic(
}, },
equipmentContext equipmentContext
); );
return result.status === 'OK' ? { status: 'OK', result, program, encounterSeconds } : null; return result.status === 'OK' ? { status: 'OK', result, program, encounterSeconds, events } : null;
} }
type ModifierInterval = { type ModifierInterval = {
@@ -489,6 +505,7 @@ type ModifierInterval = {
outcomeId: string; outcomeId: string;
phase: MechanicOutcome['phase']; phase: MechanicOutcome['phase'];
activationEvent: MechanicEvent['kind']; activationEvent: MechanicEvent['kind'];
activationAbilityId: string | null;
endAtNextActivation: boolean; endAtNextActivation: boolean;
}; };
@@ -497,17 +514,42 @@ function modifierIntervals(source: ActiveMechanicEvaluation): ModifierInterval[]
.filter((emission) => emission.kind === 'attribute_modifier' && emission.attributeId) .filter((emission) => emission.kind === 'attribute_modifier' && emission.attributeId)
.sort((left, right) => left.at - right.at); .sort((left, right) => left.at - right.at);
return emissions.flatMap((emission, index) => { return emissions.flatMap((emission, index) => {
const clause = source.program.clauses.find((candidate) => const clause = source.program.clauses.find((candidate) => candidate.id === emission.clauseId);
candidate.outcomes?.some((outcome) => outcome.id === emission.outcomeId));
const definition = clause?.outcomes?.find((candidate) => candidate.id === emission.outcomeId); const definition = clause?.outcomes?.find((candidate) => candidate.id === emission.outcomeId);
if (!clause || !definition) return []; 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 const pool = definition.statePool
? source.program.pools?.find((candidate) => candidate.key === definition.statePool) ? source.program.pools?.find((candidate) => candidate.key === definition.statePool)
: null; : 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' const naturalEnd = definition.phase === 'while_active'
? emission.at + Number(pool?.expiresAfterSeconds ?? source.encounterSeconds) ? Math.min(
: emission.at; 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); const end = Math.min(source.encounterSeconds, next?.at ?? naturalEnd, naturalEnd);
if ( if (
end <= emission.at end <= emission.at
@@ -521,11 +563,14 @@ function modifierIntervals(source: ActiveMechanicEvaluation): ModifierInterval[]
stateValue: emission.stateValue, stateValue: emission.stateValue,
attributeId: String(emission.attributeId), attributeId: String(emission.attributeId),
scope: emission.scope, scope: emission.scope,
outcomeId: emission.outcomeId, outcomeId: emission.outcomeId,
phase: definition.phase, phase: definition.phase,
activationEvent: clause.when, activationEvent: clause.when,
endAtNextActivation: next !== undefined && next.at <= naturalEnd activationAbilityId,
}]; endAtNextActivation: activationAbilityId
? nextDeploymentAt !== undefined
: next !== undefined && next.at <= naturalEnd
}];
}); });
} }
@@ -555,15 +600,22 @@ const TIMELINE_EVENT_ORDER: Readonly<Record<MechanicEvent['kind'], number>> = {
weapon_swapped: 7, weapon_swapped: 7,
enemy_killed: 8, enemy_killed: 8,
assist: 9, assist: 9,
skill_deployed: 10, ally_downed: 10,
skill_hit: 11, agent_downed: 11,
agent_hit: 12, ally_rescued: 12,
health_damage_taken: 13, grenade_thrown: 13,
healing_applied: 14, medical_kit_used: 14,
extra_health_granted: 15, signature_ability_deployed: 15,
tick: 16, skill_deployed: 16,
proximity_snapshot: 17, skill_hit: 17,
movement_snapshot: 18 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( function intervalAppliesAt(
@@ -634,10 +686,16 @@ function mechanicSummary(source: ActiveMechanicEvaluation) {
}; };
} }
export type MechanicTimelineOptions = {
scenarioInputs?: Record<string, number | boolean>;
skillDeploymentEvents?: MechanicEvent[];
};
export function buildWeaponMechanicTimeline( export function buildWeaponMechanicTimeline(
weapon: any, weapon: any,
profile: LoadoutDraft['combatProfile'], profile: LoadoutDraft['combatProfile'],
criticalChancePercent = 0 criticalChancePercent = 0,
options: MechanicTimelineOptions = {}
): MechanicEvent[] { ): MechanicEvent[] {
const encounterSeconds = Math.max(0, Number(profile.encounterSeconds)); const encounterSeconds = Math.max(0, Number(profile.encounterSeconds));
const rpm = Math.max(1, Number(weapon?.rpm)); const rpm = Math.max(1, Number(weapon?.rpm));
@@ -662,7 +720,11 @@ export function buildWeaponMechanicTimeline(
const shotInterval = 60 / rpm; const shotInterval = 60 / rpm;
while (time <= encounterSeconds && events.length < 100_000) { while (time <= encounterSeconds && events.length < 100_000) {
if (magazineRemaining <= 0) { if (magazineRemaining <= 0) {
events.push({ kind: 'reload_started', at: time }); events.push({
kind: 'reload_started',
at: time,
values: { emptyMagazine: magazineRemaining === 0 }
});
time += reloadSeconds; time += reloadSeconds;
if (time <= encounterSeconds) { if (time <= encounterSeconds) {
events.push({ kind: 'reload_completed', at: time }); events.push({ kind: 'reload_completed', at: time });
@@ -691,13 +753,21 @@ export function buildWeaponMechanicTimeline(
const critical = hit && criticalAccumulator >= 1; const critical = hit && criticalAccumulator >= 1;
if (critical) criticalAccumulator -= 1; if (critical) criticalAccumulator -= 1;
const targetId = `target-${Math.floor(time / targetInterval)}`; 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({ events.push({
kind: 'shot_fired', kind: 'shot_fired',
at: time, at: time,
targetId, targetId,
values: { values: {
hit,
magazineRemaining, magazineRemaining,
magazinePercent: magazineRemaining / magazine * 100, magazinePercent: magazineRemaining / magazine * 100,
magazineTwentyPercentBandsCrossed,
distanceMeters: Number(profile.targetDistanceMeters) distanceMeters: Number(profile.targetDistanceMeters)
} }
}); });
@@ -722,24 +792,53 @@ export function buildWeaponMechanicTimeline(
if (!(perMinute > 0)) return; if (!(perMinute > 0)) return;
const interval = 60 / perMinute; const interval = 60 / perMinute;
let headshotKillAccumulator = 0; let headshotKillAccumulator = 0;
let criticalKillAccumulator = 0;
for (let at = interval; at <= encounterSeconds; at += interval) { for (let at = interval; at <= encounterSeconds; at += interval) {
if (kind === 'enemy_killed') { if (kind === 'enemy_killed') {
headshotKillAccumulator += Math.max(0, Math.min(100, Number(profile.headshotKillRate))) / 100; headshotKillAccumulator += Math.max(0, Math.min(100, Number(profile.headshotKillRate))) / 100;
const headshot = headshotKillAccumulator >= 1; const headshot = headshotKillAccumulator >= 1;
if (headshot) headshotKillAccumulator -= 1; if (headshot) headshotKillAccumulator -= 1;
criticalKillAccumulator += criticalChance;
const critical = criticalKillAccumulator >= 1;
if (critical) criticalKillAccumulator -= 1;
events.push({ events.push({
kind, kind,
at, at,
targetId: `target-${Math.floor(at / targetInterval)}`, 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('enemy_killed', Number(profile.killsPerMinute));
intervalEvents('health_damage_taken', Number(profile.damageTakenPerMinute)); intervalEvents('health_damage_taken', Number(profile.damageTakenPerMinute));
intervalEvents('weapon_swapped', Number(profile.swapsPerMinute)); 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) { for (let at = 0; at <= encounterSeconds; at += 1) {
events.push({ kind: 'tick', at }); events.push({ kind: 'tick', at });
events.push({ events.push({
@@ -748,12 +847,16 @@ export function buildWeaponMechanicTimeline(
values: { values: {
distanceMeters: Number(profile.targetDistanceMeters), distanceMeters: Number(profile.targetDistanceMeters),
targetDistanceMeters: Number(profile.targetDistanceMeters), targetDistanceMeters: Number(profile.targetDistanceMeters),
enemyCount: 1, extraHealthActive: Boolean(profile.extraHealthActive),
allyCount: 0 behindCover: options.scenarioInputs?.['behind-cover'] === true,
} enemiesWithinRadius: Number(profile.targetDistanceMeters) <= 20 ? 1 : 0,
}); enemyCount: Number(profile.targetDistanceMeters) <= 20 ? 1 : 0,
events.push({ kind: 'movement_snapshot', at, values: { moving: false, sprinting: false } }); 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); return events.sort((left, right) => left.at - right.at);
} }
@@ -768,6 +871,147 @@ function effectAppliesToSkills(scope: string) {
return !['allies', 'equipped_weapon'].includes(scope); 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<string, number> = {}
): 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<string>,
allowedAttributeIds: ReadonlySet<string>
): 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( function skillModifierWindows(
draft: LoadoutDraft, draft: LoadoutDraft,
catalog: Catalog, catalog: Catalog,
@@ -805,16 +1049,24 @@ function skillModifierWindows(
const primaryBindings = calculationBindings(catalog, primaryWeapon); const primaryBindings = calculationBindings(catalog, primaryWeapon);
const baseAttributes = Object.fromEntries(summary.stats.map((stat) => [stat.id, stat.value])); const baseAttributes = Object.fromEntries(summary.stats.map((stat) => [stat.id, stat.value]));
const primaryCriticalChance = weaponCriticalChance(baseAttributes, primaryBindings); const primaryCriticalChance = weaponCriticalChance(baseAttributes, primaryBindings);
const focusAbilityIds = new Set((focus?.abilities ?? []).map((ability: any) => String(ability.id))); const focusAbilityIds = new Set<string>(
(focus?.abilities ?? []).map((ability: any) => String(ability.id))
);
const windows: SkillModifierWindow[] = []; const windows: SkillModifierWindow[] = [];
const maxHealthId = catalog.attributes.find((item) => item.name === 'Health')?.id; const maxHealthId = catalog.attributes.find((item) => item.name === 'Health')?.id;
const maxHealth = summary.stats.find((stat) => stat.id === maxHealthId)?.value ?? 1; const maxHealth = summary.stats.find((stat) => stat.id === maxHealthId)?.value ?? 1;
const skillDeploymentEvents = loadoutSkillDeploymentEvents(draft, catalog, summary);
const evaluatedSources = sources.flatMap((source) => { const evaluatedSources = sources.flatMap((source) => {
const state = draft.conditionalEffects[source.key] ?? { mode: 'off' }; const state = draft.conditionalEffects[source.key] ?? { mode: 'off' };
const profile = { ...draft.combatProfile, ...(state.overrides ?? {}) }; const profile = { ...draft.combatProfile, ...(state.overrides ?? {}) };
const evaluation = evaluateConfiguredMechanic( const evaluation = evaluateConfiguredMechanic(
source.program!, source.program!,
primaryWeapon ? buildWeaponMechanicTimeline(primaryWeapon, profile, primaryCriticalChance) : [], (scenarioInputs) => primaryWeapon
? buildWeaponMechanicTimeline(primaryWeapon, profile, primaryCriticalChance, {
scenarioInputs,
skillDeploymentEvents
})
: skillDeploymentEvents,
state, state,
profile, profile,
Number(primarySelection.damage ?? 0), Number(primarySelection.damage ?? 0),
@@ -827,19 +1079,12 @@ function skillModifierWindows(
}); });
for (const source of evaluatedSources) { for (const source of evaluatedSources) {
for (const interval of modifierIntervals(source.evaluation)) { windows.push(...skillModifierWindowsForMechanic(
if (!effectAppliesToSkills(interval.scope) || !allowedAttributeIds.has(interval.attributeId)) continue; source.evaluation,
const abilityIds = interval.scope === 'assigned_ability' source.abilityId,
? (source.abilityId && focusAbilityIds.has(source.abilityId) ? [source.abilityId] : []) focusAbilityIds,
: undefined; allowedAttributeIds
if (interval.scope === 'assigned_ability' && !abilityIds?.length) continue; ));
windows.push({
startSeconds: interval.start,
endSeconds: interval.end,
attributes: { [interval.attributeId]: interval.value },
abilityIds
});
}
} }
return windows; return windows;
@@ -872,6 +1117,7 @@ export function evaluateLoadoutCombat(
program: conditionalProgram(effect.rule, 'base') program: conditionalProgram(effect.rule, 'base')
})) }))
].filter((source) => source.program); ].filter((source) => source.program);
const skillDeploymentEvents = loadoutSkillDeploymentEvents(draft, catalog, summary);
const required = (weaponIndex: number) => { const required = (weaponIndex: number) => {
const missing: string[] = []; const missing: string[] = [];
if (!catalog.calculation.revision) missing.push('An active ISAC calculation revision'); if (!catalog.calculation.revision) missing.push('An active ISAC calculation revision');
@@ -911,13 +1157,14 @@ export function evaluateLoadoutCombat(
) => conditionalSources.flatMap((source) => { ) => conditionalSources.flatMap((source) => {
const state = draft.conditionalEffects[source.key] ?? { mode: 'off' }; const state = draft.conditionalEffects[source.key] ?? { mode: 'off' };
const profile = { ...draft.combatProfile, ...(state.overrides ?? {}) }; const profile = { ...draft.combatProfile, ...(state.overrides ?? {}) };
const evaluation = evaluateConfiguredMechanic( const evaluation = evaluateConfiguredMechanic(
source.program!, source.program!,
buildWeaponMechanicTimeline( (scenarioInputs) => buildWeaponMechanicTimeline(
timelineWeapon, timelineWeapon,
profile, profile,
weaponCriticalChance(criticalAttributes, bindings) weaponCriticalChance(criticalAttributes, bindings),
), { scenarioInputs, skillDeploymentEvents }
),
state, state,
profile, profile,
Number(selection.damage), Number(selection.damage),
@@ -1070,70 +1317,13 @@ export function evaluateLoadoutSkillCombat(
catalog: Catalog, catalog: Catalog,
summary = summarizeLoadout(draft, catalog) summary = summarizeLoadout(draft, catalog)
): SkillCalculation { ): SkillCalculation {
const focus = selectedFocus(draft, catalog);
const revisionId = String(catalog.calculation.revision?.id ?? ''); const revisionId = String(catalog.calculation.revision?.id ?? '');
const supportedStages = new Set([ const bindings = skillCalculationBindings(catalog);
'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 boundIds = new Set(bindings.map((binding) => binding.attributeId)); const boundIds = new Set(bindings.map((binding) => binding.attributeId));
const attributes = Object.fromEntries( const attributes = Object.fromEntries(
summary.stats.filter((stat) => boundIds.has(stat.id)).map((stat) => [stat.id, stat.value]) summary.stats.filter((stat) => boundIds.has(stat.id)).map((stat) => [stat.id, stat.value])
); );
const abilities = (focus?.abilities ?? []).map((ability: any) => { const abilities = loadoutSkillAbilities(draft, catalog);
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
};
});
return calculateSkillDps({ return calculateSkillDps({
rules: { revisionId, bindings }, rules: { revisionId, bindings },
abilities, abilities,
@@ -1,6 +1,12 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
currentHealthPercentBandPredicate,
eventFieldComparisonPredicate,
parseConditionalMechanicDraft, parseConditionalMechanicDraft,
readCurrentHealthPercentBand,
readEventFieldComparison,
readStatePoolThreshold,
statePoolThresholdPredicate,
type ConditionalMechanicDraftV1 type ConditionalMechanicDraftV1
} from './mechanic-editor.js'; } from './mechanic-editor.js';
@@ -54,6 +60,116 @@ const draft = (): ConditionalMechanicDraftV1 => ({
}); });
describe('conditional mechanic authoring payload', () => { 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', () => { it('accepts an empty captured-only program for deferred modeling', () => {
const partial: ConditionalMechanicDraftV1 = { const partial: ConditionalMechanicDraftV1 = {
version: 1, version: 1,
@@ -427,4 +543,51 @@ describe('conditional mechanic authoring payload', () => {
errors: ['The conditional mechanic contains malformed JSON.'] 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.'
);
}
});
}); });
+192 -6
View File
@@ -14,6 +14,7 @@ import {
type PoolDefinition, type PoolDefinition,
type Predicate, type Predicate,
type Reducer, type Reducer,
type Scalar,
type ScenarioInputDefinition, type ScenarioInputDefinition,
type StateScope, type StateScope,
type ValueReference type ValueReference
@@ -32,6 +33,7 @@ export type MechanicStateKind =
export type ConditionalMechanicPoolDraft = PoolDefinition & { export type ConditionalMechanicPoolDraft = PoolDefinition & {
stateKind: MechanicStateKind; stateKind: MechanicStateKind;
expiresAfterSecondsVariants?: Record<string, number>;
}; };
export type ConditionalMechanicVariantValue = { export type ConditionalMechanicVariantValue = {
@@ -77,6 +79,138 @@ export type ConditionalMechanicDraftResult =
| { ok: true; value: ParsedConditionalMechanicDraft } | { ok: true; value: ParsedConditionalMechanicDraft }
| { ok: false; errors: string[] }; | { 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<Scalar, null>;
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<string, unknown> => const object = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value); typeof value === 'object' && value !== null && !Array.isArray(value);
const finite = (value: unknown): value is number => const finite = (value: unknown): value is number =>
@@ -97,13 +231,15 @@ export const CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES = {
'perStack', 'perStack',
'minStacks', 'minStacks',
'maxStacks', 'maxStacks',
'targeting',
'descriptionSlot' 'descriptionSlot'
], ],
phase: ['on_event', 'after_event', 'on_enter', 'on_exit', 'while_active'], 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: [ scope: [
'self', 'self',
'equipped_weapon', 'equipped_weapon',
'inactive_weapon',
'target', 'target',
'allies', 'allies',
'self_and_allies', 'self_and_allies',
@@ -111,7 +247,7 @@ export const CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES = {
'all_skills' 'all_skills'
], ],
basis: ['flat', 'max_health_percent', 'current_health_percent', 'weapon_base_percent'], 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'], delivery: ['instant', 'continuous'],
deliverySemantics: { deliverySemantics: {
instant: 'The full resolved value applies at activation.', instant: 'The full resolved value applies at activation.',
@@ -175,7 +311,8 @@ function isReference(value: unknown): value is ValueReference {
case 'combat': case 'combat':
return typeof value.path === 'string' return typeof value.path === 'string'
&& Boolean(value.path.trim()) && 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': case 'equipment':
return typeof value.path === 'string' && Boolean(value.path.trim()); return typeof value.path === 'string' && Boolean(value.path.trim());
case 'attribute': case 'attribute':
@@ -222,6 +359,7 @@ function isExpression(value: unknown): value is NumericExpression {
if (finite(value)) return true; if (finite(value)) return true;
if (!object(value)) return false; if (!object(value)) return false;
if ('ref' in value) return isReference(value.ref); if ('ref' in value) return isReference(value.ref);
if (value.op === 'floor') return isExpression(value.value);
if (value.op === 'clamp') { if (value.op === 'clamp') {
return isExpression(value.value) && isExpression(value.min) && isExpression(value.max); return isExpression(value.value) && isExpression(value.min) && isExpression(value.max);
} }
@@ -333,7 +471,7 @@ function parseScenarioInputs(value: unknown, errors: string[]) {
return inputs; return inputs;
} }
function parsePools(value: unknown, errors: string[]) { function parsePools(value: unknown, variantKeys: readonly string[], errors: string[]) {
if (!Array.isArray(value)) { if (!Array.isArray(value)) {
errors.push('State pools must be an array.'); errors.push('State pools must be an array.');
return [] as ConditionalMechanicPoolDraft[]; return [] as ConditionalMechanicPoolDraft[];
@@ -362,6 +500,26 @@ function parsePools(value: unknown, errors: string[]) {
errors.push(`${path} ${property} must be a finite number.`); 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); pools.push(candidate as ConditionalMechanicPoolDraft);
} }
return pools; return pools;
@@ -439,6 +597,25 @@ function parseOutcome(
) { ) {
errors.push(`${path} description slot must be a positive integer.`); 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)) { if (!object(value.variants)) {
errors.push(`${path} variants must be an object.`); errors.push(`${path} variants must be an object.`);
return null; return null;
@@ -555,7 +732,16 @@ function programsForDraft(
id: `${options.programId ?? 'mechanic'}:${variant}`, id: `${options.programId ?? 'mechanic'}:${variant}`,
name: options.programName?.trim() || 'Conditional Mechanic', name: options.programName?.trim() || 'Conditional Mechanic',
scenarioInputs: draft.scenarioInputs, 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 => ({ clauses: draft.clauses.map((clause): MechanicClause => ({
id: clause.key, id: clause.key,
...(clause.label ? { label: clause.label } : {}), ...(clause.label ? { label: clause.label } : {}),
@@ -607,7 +793,7 @@ export function parseConditionalMechanicDraft(
errors.push('The conditional mechanic has invalid simulation readiness.'); errors.push('The conditional mechanic has invalid simulation readiness.');
} }
const scenarioInputs = parseScenarioInputs(parsed.scenarioInputs, errors); 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 clauses = parseClauses(parsed.clauses, options.variantKeys, options, errors);
const draft: ConditionalMechanicDraftV1 = { const draft: ConditionalMechanicDraftV1 = {
version: CONDITIONAL_MECHANIC_DRAFT_VERSION, version: CONDITIONAL_MECHANIC_DRAFT_VERSION,
@@ -78,6 +78,7 @@ const outcome = (
value: 3, value: 3,
value_expression_json: null, value_expression_json: null,
value_basis: 'flat', value_basis: 'flat',
targeting_json: null,
value_scaling: 'per_state_unit', value_scaling: 'per_state_unit',
unit: '%', unit: '%',
damage_basis: null, damage_basis: null,
@@ -152,6 +153,33 @@ describe('conditional-effect persistence adapter', () => {
expect(result.program?.clauses[0].outcomes?.[0]).not.toHaveProperty('durationSeconds'); 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', () => { it('loads continuous restoration delivery metadata', () => {
const result = adaptConditionalEffectProgram({ const result = adaptConditionalEffectProgram({
rule: rule(), rule: rule(),
@@ -589,4 +617,30 @@ describe('conditional-effect persistence adapter', () => {
'Clause 1 mutations contains malformed JSON.' '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);
});
}); });
+43 -15
View File
@@ -84,6 +84,7 @@ export type ConditionalEffectOutcomeRow = {
combat_timing?: DatabaseValue; combat_timing?: DatabaseValue;
delivery_mode?: DatabaseValue; delivery_mode?: DatabaseValue;
delivery_duration_seconds?: DatabaseValue; delivery_duration_seconds?: DatabaseValue;
targeting_json?: DatabaseValue;
value_scaling: DatabaseValue; value_scaling: DatabaseValue;
unit: DatabaseValue; unit: DatabaseValue;
damage_basis: DatabaseValue; damage_basis: DatabaseValue;
@@ -192,7 +193,8 @@ function isValueReference(value: unknown) {
if (value.source === 'combat') { if (value.source === 'combat') {
return typeof value.path === 'string' return typeof value.path === 'string'
&& Boolean(value.path.trim()) && 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') { if (value.source === 'attribute') {
return typeof value.attributeId === 'string' && Boolean(value.attributeId.trim()); 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 (finite(value)) return true;
if (!object(value)) return false; if (!object(value)) return false;
if ('ref' in value) return isValueReference(value.ref); if ('ref' in value) return isValueReference(value.ref);
if (value.op === 'floor') return isNumericExpression(value.value);
if (value.op === 'clamp') { if (value.op === 'clamp') {
return isNumericExpression(value.value) return isNumericExpression(value.value)
&& isNumericExpression(value.min) && isNumericExpression(value.min)
@@ -441,6 +444,7 @@ function parsePool(
row: ConditionalEffectStatePoolRow, row: ConditionalEffectStatePoolRow,
ruleId: string, ruleId: string,
index: number, index: number,
selectedVariant: string,
errors: string[] errors: string[]
): { id: string; definition: PoolDefinition } | null { ): { id: string; definition: PoolDefinition } | null {
const path = `State pool ${index + 1}`; const path = `State pool ${index + 1}`;
@@ -460,6 +464,14 @@ function parsePool(
const duration = number(row.duration_seconds); const duration = number(row.duration_seconds);
const parameters = parseJson(row.parameters_json, `${path} parameters`, errors); const parameters = parseJson(row.parameters_json, `${path} parameters`, errors);
if (parameters.ok && !object(parameters.value)) errors.push(`${path} parameters must be a JSON object.`); 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 { return {
id, id,
definition: { definition: {
@@ -470,7 +482,9 @@ function parsePool(
...(minimum === null ? {} : { min: minimum }), ...(minimum === null ? {} : { min: minimum }),
max: maximum, max: maximum,
...(bestCase === null ? {} : { bestCaseValue: bestCase }), ...(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 { function outcomeKind(value: string): MechanicOutcome['kind'] | null {
if (value === 'active_modifier') return 'attribute_modifier'; if (value === 'active_modifier') return 'attribute_modifier';
if (value === 'direct_damage') return 'direct_damage'; 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'; return 'restoration';
} }
if (value === 'resource_cost') return 'resource_cost'; if (value === 'resource_cost') return 'resource_cost';
if (value === 'weapon_reload') return 'weapon_reload';
if (value === 'marker') return 'marker'; if (value === 'marker') return 'marker';
return null; return null;
} }
@@ -489,6 +507,7 @@ function outcomeKind(value: string): MechanicOutcome['kind'] | null {
function outcomeScope(value: string): MechanicOutcome['scope'] | null { function outcomeScope(value: string): MechanicOutcome['scope'] | null {
if (value === 'self') return 'self'; if (value === 'self') return 'self';
if (value === 'equipped_weapon') return 'equipped_weapon'; if (value === 'equipped_weapon') return 'equipped_weapon';
if (value === 'inactive_weapon') return 'inactive_weapon';
if (value === 'target') return 'target'; if (value === 'target') return 'target';
if (value === 'allies') return 'allies'; if (value === 'allies') return 'allies';
if (value === 'self_and_allies') return 'self_and_allies'; if (value === 'self_and_allies') return 'self_and_allies';
@@ -547,15 +566,17 @@ function parseOutcome(
? 'armor' ? 'armor'
: text(row.application_kind) === 'restore_ammo' : text(row.application_kind) === 'restore_ammo'
? 'ammo' ? 'ammo'
: text(row.application_kind) === 'restore_signature_ability_charge'
? 'signature_ability_charge'
: text(row.application_kind) === 'resource_cost' : text(row.application_kind) === 'resource_cost'
? 'health' ? 'health'
: null; : null;
const resource = nullableText(row.resource_kind) ?? legacyResource; const resource = nullableText(row.resource_kind) ?? legacyResource;
if ( if (
(kind === 'restoration' || kind === 'resource_cost') (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); const combatTiming = nullableText(row.combat_timing);
if (combatTiming && combatTiming !== 'pre_event' && combatTiming !== 'current') { if (combatTiming && combatTiming !== 'pre_event' && combatTiming !== 'current') {
@@ -572,6 +593,13 @@ function parseOutcome(
if (delivery === 'instant' && durationSeconds !== null) { if (delivery === 'instant' && durationSeconds !== null) {
errors.push(`${path} instant delivery cannot set a duration.`); 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 { return {
clauseId: nullableText(row.clause_id), clauseId: nullableText(row.clause_id),
outcome: { outcome: {
@@ -591,6 +619,7 @@ function parseOutcome(
...(delivery === 'continuous' && durationSeconds !== null ...(delivery === 'continuous' && durationSeconds !== null
? { delivery, durationSeconds } ? { delivery, durationSeconds }
: {}), : {}),
...(targeting ? { targeting } : {}),
...(kind === 'attribute_modifier' && nullableText(row.stat_attribute_id) ...(kind === 'attribute_modifier' && nullableText(row.stat_attribute_id)
? { attributeId: text(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 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<typeof value> => 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))]; const variantKeys = [...new Set(input.outcomes.map((row) => text(row.variant_key)).filter(Boolean))];
let selectedVariant = options.variantKey?.trim() || ''; let selectedVariant = options.variantKey?.trim() || '';
if (!selectedVariant) { if (!selectedVariant) {
@@ -648,6 +667,15 @@ export function adaptConditionalEffectProgram(
} else if (variantKeys.length && !variantKeys.includes(selectedVariant)) { } else if (variantKeys.length && !variantKeys.includes(selectedVariant)) {
errors.push(`Variant "${selectedVariant}" does not exist for this rule.`); 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<typeof value> => 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 const selectedOutcomes = input.outcomes
.filter((row) => text(row.variant_key) === selectedVariant) .filter((row) => text(row.variant_key) === selectedVariant)
@@ -142,10 +142,17 @@ describe('mechanic recipe registry', () => {
expect(health.clauses[1].if).toMatchObject({ expect(health.clauses[1].if).toMatchObject({
kind: 'all', kind: 'all',
predicates: expect.arrayContaining([ predicates: expect.arrayContaining([
{ kind: 'compare', left: { source: 'event', path: 'values.healthPercent' }, operator: 'lte', right: 50 }, { kind: 'compare', left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' }, operator: 'lt', right: 50 },
{ kind: 'compare', left: { source: 'event', path: 'values.healthPercent' }, operator: 'gt', right: 25 } { 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({ expect(seedFor('target_hit_within_distance').clauses[0]).toMatchObject({
when: 'weapon_hit', when: 'weapon_hit',
+14 -9
View File
@@ -110,6 +110,11 @@ function firingLifecycleSeed(activePool: string, includeDuration?: number): Mech
function healthBandSeed(params: HealthThresholdParams): MechanicRecipeSeed { function healthBandSeed(params: HealthThresholdParams): MechanicRecipeSeed {
const sortedBands = [...params.bands].sort((left, right) => left.thresholdPercent - right.thresholdPercent); 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) => ({ const pools: PoolDefinition[] = sortedBands.map((band, index) => ({
key: `health-band-${index + 1}`, key: `health-band-${index + 1}`,
label: `Health band: ${band.outcomeKey}`, label: `Health band: ${band.outcomeKey}`,
@@ -122,22 +127,22 @@ function healthBandSeed(params: HealthThresholdParams): MechanicRecipeSeed {
const lowerBound = sortedBands[index - 1]?.thresholdPercent; const lowerBound = sortedBands[index - 1]?.thresholdPercent;
const predicates: Predicate[] = [{ const predicates: Predicate[] = [{
kind: 'compare', kind: 'compare',
left: eventValue('healthPercent'), left: currentHealthPercent,
operator: 'lte', operator: 'lt',
right: band.thresholdPercent right: band.thresholdPercent
}]; }];
if (lowerBound !== undefined) { if (lowerBound !== undefined) {
predicates.push({ predicates.push({
kind: 'compare', kind: 'compare',
left: eventValue('healthPercent'), left: currentHealthPercent,
operator: 'gt', operator: 'gte',
right: lowerBound right: lowerBound
}); });
} }
return { return {
id: `select-health-band-${index + 1}`, id: `select-health-band-${index + 1}`,
label: `Select ${band.outcomeKey} at ${band.thresholdPercent}% Health`, label: `Select ${band.outcomeKey} below ${band.thresholdPercent}% Health`,
when: 'health_damage_taken', when: 'tick',
if: { kind: 'all', predicates }, if: { kind: 'all', predicates },
reducer: { kind: 'none' }, reducer: { kind: 'none' },
mutations: pools.map((pool, poolIndex) => mutations: pools.map((pool, poolIndex) =>
@@ -150,11 +155,11 @@ function healthBandSeed(params: HealthThresholdParams): MechanicRecipeSeed {
clauses.push({ clauses.push({
id: 'clear-health-bands', id: 'clear-health-bands',
label: 'Clear health bands above the highest threshold', label: 'Clear health bands above the highest threshold',
when: 'health_damage_taken', when: 'tick',
if: { if: {
kind: 'compare', kind: 'compare',
left: eventValue('healthPercent'), left: currentHealthPercent,
operator: 'gt', operator: 'gte',
right: sortedBands.at(-1)?.thresholdPercent ?? 0 right: sortedBands.at(-1)?.thresholdPercent ?? 0
}, },
reducer: { kind: 'none' }, reducer: { kind: 'none' },
+632 -3
View File
@@ -5,7 +5,9 @@ import {
formatMechanicProgram, formatMechanicProgram,
validateMechanicProgram, validateMechanicProgram,
type MechanicEvent, type MechanicEvent,
type MechanicProgram type MechanicEventKind,
type MechanicProgram,
type NumericExpression
} from './mechanic.js'; } from './mechanic.js';
const custom = (encounterSeconds = 30, inputs: Record<string, number | boolean> = {}) => ({ const custom = (encounterSeconds = 30, inputs: Record<string, number | boolean> = {}) => ({
@@ -17,14 +19,153 @@ const custom = (encounterSeconds = 30, inputs: Record<string, number | boolean>
describe('structured mechanic runtime', () => { describe('structured mechanic runtime', () => {
it('advertises the authoritative weapon-hit target and headshot fields', () => { it('advertises the authoritative weapon-hit target and headshot fields', () => {
expect(MECHANIC_EVENT_REGISTRY.weapon_hit).toMatchObject({ expect(MECHANIC_EVENT_REGISTRY.weapon_hit).toMatchObject({
description: expect.stringContaining('headshot boolean'), description: expect.stringContaining('previous target'),
fields: [ fields: [
{ path: 'targetId', label: 'Target ID', type: 'string' }, { 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', () => { it('models Superior Firepower as same-target sliding hits and one shared expiring stack pool', () => {
const superiorFirepower: MechanicProgram = { const superiorFirepower: MechanicProgram = {
id: 'superior-firepower', id: 'superior-firepower',
@@ -1077,6 +1218,242 @@ describe('structured mechanic runtime', () => {
}); });
describe('mechanic validation and pseudo-script', () => { 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', () => { it('enforces clause cooldowns without counting events received during the cooldown', () => {
const program: MechanicProgram = { const program: MechanicProgram = {
id: 'cooldown', 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', () => { it('formats nested conditions and state operations deterministically', () => {
const program: MechanicProgram = { const program: MechanicProgram = {
id: 'format', id: 'format',
@@ -1221,4 +1805,49 @@ describe('mechanic validation and pseudo-script', () => {
'ADD 1 TO prepared' 'ADD 1 TO prepared'
].join('\n')); ].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');
});
}); });
+293 -28
View File
@@ -2,12 +2,19 @@ export const MECHANIC_EVENT_KINDS = [
'shot_fired', 'shot_fired',
'weapon_hit', 'weapon_hit',
'skill_deployed', 'skill_deployed',
'signature_ability_deployed',
'skill_hit', 'skill_hit',
'skill_multishot_triggered',
'agent_hit', 'agent_hit',
'health_damage_taken', 'health_damage_taken',
'damage_dealt', 'damage_dealt',
'enemy_killed', 'enemy_killed',
'assist', 'assist',
'ally_downed',
'agent_downed',
'ally_rescued',
'grenade_thrown',
'medical_kit_used',
'healing_applied', 'healing_applied',
'extra_health_granted', 'extra_health_granted',
'movement_snapshot', 'movement_snapshot',
@@ -36,15 +43,31 @@ export const MECHANIC_EVENT_REGISTRY: Readonly<Record<MechanicEventKind, Mechani
shot_fired: { shot_fired: {
key: 'shot_fired', key: 'shot_fired',
label: 'Shot fired', label: 'Shot fired',
description: 'A round leaves the equipped weapon; hit confirmation is not required.' description: 'A round leaves the equipped weapon; hit confirmation is not required.',
fields: [
{
path: 'values.hit',
label: 'Shot hit',
type: 'boolean'
},
{
path: 'values.magazineTwentyPercentBandsCrossed',
label: '20% magazine bands crossed',
type: 'number'
}
]
}, },
weapon_hit: { weapon_hit: {
key: 'weapon_hit', key: 'weapon_hit',
label: 'Weapon hit', label: 'Weapon hit',
description: 'A weapon attack connects with a target. The headshot boolean distinguishes headshots from body shots.', description: 'A weapon attack connects with a target. The previous target and event values distinguish target switches, headshots, cover state, and distance.',
fields: [ fields: [
{ path: 'targetId', label: 'Target ID', type: 'string' }, { 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' }
] ]
}, },
skill_deployed: { skill_deployed: {
@@ -52,10 +75,24 @@ export const MECHANIC_EVENT_REGISTRY: Readonly<Record<MechanicEventKind, Mechani
label: 'Skill deployed', label: 'Skill deployed',
description: 'The Agent deploys or activates a specialization ability.' description: 'The Agent deploys or activates a specialization ability.'
}, },
signature_ability_deployed: {
key: 'signature_ability_deployed',
label: 'Signature Ability deployed',
description: 'The Agent deploys or activates their Signature Ability.'
},
skill_hit: { skill_hit: {
key: 'skill_hit', key: 'skill_hit',
label: 'Skill hit', label: 'Skill hit',
description: 'A deployed skill connects with a target. Critical and distance details belong in event values.' description: 'A deployed skill connects with a target. Critical and distance details belong in event values.',
fields: [
{ path: 'values.critical', label: 'Critical hit', type: 'boolean' },
{ path: 'values.distanceMeters', label: 'Target distance', type: 'number' }
]
},
skill_multishot_triggered: {
key: 'skill_multishot_triggered',
label: 'Skill Multi-Shot triggered',
description: 'A deployed skill triggers an additional Multi-Shot.'
}, },
agent_hit: { agent_hit: {
key: 'agent_hit', key: 'agent_hit',
@@ -65,7 +102,11 @@ export const MECHANIC_EVENT_REGISTRY: Readonly<Record<MechanicEventKind, Mechani
health_damage_taken: { health_damage_taken: {
key: 'health_damage_taken', key: 'health_damage_taken',
label: 'Health damage taken', label: 'Health damage taken',
description: 'The Agent loses Health. Damage absorbed entirely by Extra Health does not emit this event.' description: 'The Agent loses Health. Damage absorbed entirely by Extra Health does not emit this event.',
fields: [
{ path: 'values.distanceMeters', label: 'Attacker distance', type: 'number' },
{ path: 'values.recentSkillDeploymentSeconds', label: 'Seconds since relevant Skill deployment', type: 'number' }
]
}, },
damage_dealt: { damage_dealt: {
key: 'damage_dealt', key: 'damage_dealt',
@@ -75,12 +116,50 @@ export const MECHANIC_EVENT_REGISTRY: Readonly<Record<MechanicEventKind, Mechani
enemy_killed: { enemy_killed: {
key: 'enemy_killed', key: 'enemy_killed',
label: 'Enemy killed', label: 'Enemy killed',
description: 'An enemy dies. Weapon or skill source may be supplied when a mechanic requires the distinction.' description: 'An enemy dies. Weapon or skill source may be supplied when a mechanic requires the distinction.',
fields: [
{ path: 'values.headshot', label: 'Headshot killing shot', type: 'boolean' },
{ path: 'values.critical', label: 'Critical killing shot', type: 'boolean' },
{ path: 'values.source', label: 'Killing source', type: 'string' },
{ path: 'values.behindCover', label: 'Agent behind cover', type: 'boolean' },
{ path: 'values.distanceMeters', label: 'Enemy distance', type: 'number' }
]
}, },
assist: { assist: {
key: 'assist', key: 'assist',
label: 'Assist', label: 'Assist',
description: 'The Agent receives credit for assisting with an enemy defeat.' description: 'The Agent receives credit for assisting with an enemy defeat.',
fields: [
{ path: 'values.behindCover', label: 'Agent behind cover', type: 'boolean' }
]
},
ally_downed: {
key: 'ally_downed',
label: 'Ally downed',
description: 'An allied Agent enters the downed state.',
fields: [
{ path: 'values.distanceMeters', label: 'Ally distance', type: 'number' }
]
},
agent_downed: {
key: 'agent_downed',
label: 'Agent downed',
description: 'The Agent enters the downed state.'
},
ally_rescued: {
key: 'ally_rescued',
label: 'Ally rescued',
description: 'The Agent completes a rescue of a downed ally.'
},
grenade_thrown: {
key: 'grenade_thrown',
label: 'Grenade thrown',
description: 'The Agent throws a grenade and consumes it from inventory.'
},
medical_kit_used: {
key: 'medical_kit_used',
label: 'Medical Kit used',
description: 'The Agent consumes a Medical Kit.'
}, },
healing_applied: { healing_applied: {
key: 'healing_applied', key: 'healing_applied',
@@ -95,7 +174,14 @@ export const MECHANIC_EVENT_REGISTRY: Readonly<Record<MechanicEventKind, Mechani
movement_snapshot: { movement_snapshot: {
key: 'movement_snapshot', key: 'movement_snapshot',
label: 'Movement snapshot', label: 'Movement snapshot',
description: 'A sampled Agent movement state such as immobile, moving, or sprinting.' description: 'A one-second sample of the Agent movement state.',
fields: [
{
path: 'values.state',
label: 'Movement state (immobile, moving, or sprinting)',
type: 'string'
}
]
}, },
proximity_snapshot: { proximity_snapshot: {
key: 'proximity_snapshot', key: 'proximity_snapshot',
@@ -115,7 +201,10 @@ export const MECHANIC_EVENT_REGISTRY: Readonly<Record<MechanicEventKind, Mechani
reload_started: { reload_started: {
key: 'reload_started', key: 'reload_started',
label: 'Reload started', label: 'Reload started',
description: 'The equipped weapon begins reloading.' description: 'The equipped weapon begins reloading.',
fields: [
{ path: 'values.emptyMagazine', label: 'Empty magazine', type: 'boolean' }
]
}, },
reload_completed: { reload_completed: {
key: 'reload_completed', key: 'reload_completed',
@@ -178,9 +267,21 @@ export type ValueReference =
| { source: 'reducer' } | { source: 'reducer' }
| { source: 'pool'; pool: string; entity?: EntitySelector }; | { source: 'pool'; pool: string; entity?: EntitySelector };
export const MECHANIC_COMBAT_REFERENCE_REGISTRY = {
currentHealth: {
label: 'Current Health',
description: 'The Agent’s resolved current Health.'
},
currentHealthPercent: {
label: 'Current Health percentage',
description: 'The Agent’s resolved current Health divided by resolved maximum Health, from 0 through 100.'
}
} as const;
export type NumericExpression = export type NumericExpression =
| number | number
| { ref: ValueReference } | { ref: ValueReference }
| { op: 'floor'; value: NumericExpression }
| { | {
op: 'add' | 'subtract' | 'multiply' | 'divide' | 'min' | 'max'; op: 'add' | 'subtract' | 'multiply' | 'divide' | 'min' | 'max';
left: NumericExpression; left: NumericExpression;
@@ -256,19 +357,26 @@ export type OutcomeBasis =
export type MechanicOutcomeScope = export type MechanicOutcomeScope =
| 'self' | 'self'
| 'equipped_weapon' | 'equipped_weapon'
| 'inactive_weapon'
| 'target' | 'target'
| 'allies' | 'allies'
| 'self_and_allies' | 'self_and_allies'
| 'assigned_ability' | 'assigned_ability'
| 'all_skills'; | 'all_skills';
export type MechanicResource = 'health' | 'armor' | 'ammo'; export type MechanicResource = 'health' | 'armor' | 'ammo' | 'signature_ability_charge';
export type MechanicOutcomeTargeting = {
kind: 'within_radius';
population: 'allies' | 'enemies';
radiusMeters: number;
};
export type MechanicOutcome = { export type MechanicOutcome = {
id: string; id: string;
label: string; label: string;
phase: OutcomePhase; phase: OutcomePhase;
kind: 'attribute_modifier' | 'direct_damage' | 'restoration' | 'resource_cost' | 'marker'; kind: 'attribute_modifier' | 'direct_damage' | 'restoration' | 'resource_cost' | 'weapon_reload' | 'marker';
scope: MechanicOutcomeScope; scope: MechanicOutcomeScope;
value: NumericExpression; value: NumericExpression;
basis: OutcomeBasis; basis: OutcomeBasis;
@@ -281,6 +389,7 @@ export type MechanicOutcome = {
perStack?: boolean; perStack?: boolean;
minStacks?: number; minStacks?: number;
maxStacks?: number; maxStacks?: number;
targeting?: MechanicOutcomeTargeting;
}; };
export type MechanicClause = { export type MechanicClause = {
@@ -304,6 +413,23 @@ export type MechanicProgram = {
clauses: MechanicClause[]; clauses: MechanicClause[];
}; };
function mechanicDefinitionsEqual(left: unknown, right: unknown): boolean {
if (Object.is(left, right)) return true;
if (typeof left !== typeof right || left === null || right === null) return false;
if (Array.isArray(left) || Array.isArray(right)) {
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false;
return left.every((value, index) => mechanicDefinitionsEqual(value, right[index]));
}
if (typeof left !== 'object') return false;
const leftRecord = left as Record<string, unknown>;
const rightRecord = right as Record<string, unknown>;
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 = { export type PoolSnapshot = {
pool: string; pool: string;
scope: StateScope; scope: StateScope;
@@ -354,6 +480,7 @@ export type MechanicOutcomeEmission = {
deliveryStart: number | null; deliveryStart: number | null;
deliveryEnd: number | null; deliveryEnd: number | null;
totalResolvedValue: number; totalResolvedValue: number;
targeting: MechanicOutcomeTargeting | null;
}; };
export type MechanicEvaluation = { export type MechanicEvaluation = {
@@ -382,6 +509,8 @@ export type MechanicCombatContext = {
currentArmor?: number; currentArmor?: number;
maxAmmo?: number; maxAmmo?: number;
currentAmmo?: number; currentAmmo?: number;
maxSignatureAbilityCharge?: number;
currentSignatureAbilityCharge?: number;
weaponBaseDamage?: number; weaponBaseDamage?: number;
attributes?: Record<string, number>; attributes?: Record<string, number>;
values?: Record<string, Scalar>; values?: Record<string, Scalar>;
@@ -425,6 +554,7 @@ type ContinuousDelivery = {
start: number; start: number;
end: number; end: number;
appliedThrough: number; appliedThrough: number;
targeting: MechanicOutcomeTargeting | null;
}; };
type ReducerState = { type ReducerState = {
count: number; count: number;
@@ -494,6 +624,15 @@ function resolveReference(reference: ValueReference, context: EvaluationContext)
return context.scenario[reference.key]; return context.scenario[reference.key];
case 'combat': { case 'combat': {
const combat = reference.timing === 'pre_event' ? context.preEventCombat : context.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); return getPath(combat, reference.path);
} }
case 'attribute': case 'attribute':
@@ -513,6 +652,7 @@ function numericValue(expression: NumericExpression, context: EvaluationContext)
const value = resolveReference(expression.ref, context); const value = resolveReference(expression.ref, context);
return finiteNumber(value) ? value : typeof value === 'boolean' ? Number(value) : 0; 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') { if (expression.op === 'clamp') {
return clamp( return clamp(
numericValue(expression.value, context), numericValue(expression.value, context),
@@ -893,6 +1033,19 @@ function combatResource(
setCurrent: (value) => { combat.currentAmmo = value; } 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; return null;
} }
@@ -957,7 +1110,8 @@ function emitOutcomes(
stateValue: current, stateValue: current,
start: at, start: at,
end: at + durationSeconds, end: at + durationSeconds,
appliedThrough: at appliedThrough: at,
targeting: outcome.targeting ?? null
}); });
continue; continue;
} }
@@ -973,10 +1127,13 @@ function emitOutcomes(
} else if ((outcome.kind === 'resource_cost' || outcome.kind === 'restoration') && !targetResource) { } else if ((outcome.kind === 'resource_cost' || outcome.kind === 'restoration') && !targetResource) {
appliedValue = 0; appliedValue = 0;
} }
const emittedAt = outcome.kind === 'weapon_reload'
? at + Math.max(0, appliedValue)
: at;
emissions.push({ emissions.push({
clauseId: clause.id, clauseId: clause.id,
outcomeId: outcome.id, outcomeId: outcome.id,
at, at: emittedAt,
targetId: context.event.targetId ?? null, targetId: context.event.targetId ?? null,
phase: outcome.phase, phase: outcome.phase,
kind: outcome.kind, kind: outcome.kind,
@@ -995,12 +1152,40 @@ function emitOutcomes(
delivery: 'instant', delivery: 'instant',
deliveryStart: null, deliveryStart: null,
deliveryEnd: null, deliveryEnd: null,
totalResolvedValue: resolvedValue * weight totalResolvedValue: resolvedValue * weight,
targeting: outcome.targeting ?? null
}); });
} }
return emissions; 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( function advanceContinuousDeliveries(
at: number, at: number,
deliveries: ContinuousDelivery[], deliveries: ContinuousDelivery[],
@@ -1046,7 +1231,8 @@ function advanceContinuousDeliveries(
delivery: 'continuous', delivery: 'continuous',
deliveryStart: delivery.start, deliveryStart: delivery.start,
deliveryEnd: delivery.end, deliveryEnd: delivery.end,
totalResolvedValue: delivery.totalResolvedValue totalResolvedValue: delivery.totalResolvedValue,
targeting: delivery.targeting
}); });
} }
for (let index = deliveries.length - 1; index >= 0; index -= 1) { for (let index = deliveries.length - 1; index >= 0; index -= 1) {
@@ -1182,7 +1368,12 @@ function validateReference(reference: ValueReference, pools: Set<string>, inputs
errors.push(`${path} references unknown scenario input "${reference.key}".`); 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 === '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 === '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.`); 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); validateReference(expression.ref, pools, inputs, errors, path);
return; return;
} }
if (expression.op === 'floor') {
visitExpression(expression.value, pools, inputs, errors, `${path} value`);
return;
}
if (expression.op === 'clamp') { if (expression.op === 'clamp') {
visitExpression(expression.value, pools, inputs, errors, `${path} value`); visitExpression(expression.value, pools, inputs, errors, `${path} value`);
visitExpression(expression.min, pools, inputs, errors, `${path} minimum`); visitExpression(expression.min, pools, inputs, errors, `${path} minimum`);
@@ -1245,6 +1440,7 @@ function visitPredicate(
function expressionUsesCombat(expression: NumericExpression): boolean { function expressionUsesCombat(expression: NumericExpression): boolean {
if (typeof expression === 'number') return false; if (typeof expression === 'number') return false;
if ('ref' in expression) return expression.ref.source === 'combat'; if ('ref' in expression) return expression.ref.source === 'combat';
if (expression.op === 'floor') return expressionUsesCombat(expression.value);
if (expression.op === 'clamp') { if (expression.op === 'clamp') {
return expressionUsesCombat(expression.value) return expressionUsesCombat(expression.value)
|| expressionUsesCombat(expression.min) || expressionUsesCombat(expression.min)
@@ -1256,6 +1452,7 @@ function expressionUsesCombat(expression: NumericExpression): boolean {
function expressionUsesEquipment(expression: NumericExpression): boolean { function expressionUsesEquipment(expression: NumericExpression): boolean {
if (typeof expression === 'number') return false; if (typeof expression === 'number') return false;
if ('ref' in expression) return expression.ref.source === 'equipment'; if ('ref' in expression) return expression.ref.source === 'equipment';
if (expression.op === 'floor') return expressionUsesEquipment(expression.value);
if (expression.op === 'clamp') { if (expression.op === 'clamp') {
return expressionUsesEquipment(expression.value) return expressionUsesEquipment(expression.value)
|| expressionUsesEquipment(expression.min) || expressionUsesEquipment(expression.min)
@@ -1390,7 +1587,7 @@ export function validateMechanicProgram(program: MechanicProgram): ValidationRes
} }
} }
const clauseIds = new Set<string>(); const clauseIds = new Set<string>();
const outcomeIds = new Set<string>(); const outcomeDefinitions = new Map<string, MechanicOutcome>();
for (const [index, clause] of program.clauses.entries()) { for (const [index, clause] of program.clauses.entries()) {
const path = `Clause ${index + 1}`; const path = `Clause ${index + 1}`;
if (!clause.id.trim()) errors.push(`${path} requires an ID.`); 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()) { for (const [outcomeIndex, outcome] of (clause.outcomes ?? []).entries()) {
const outcomePath = `${path} outcome ${outcomeIndex + 1}`; const outcomePath = `${path} outcome ${outcomeIndex + 1}`;
if (!outcome.id.trim()) errors.push(`${outcomePath} requires an ID.`); if (!outcome.id.trim()) errors.push(`${outcomePath} requires an ID.`);
if (outcomeIds.has(outcome.id)) errors.push(`Outcome "${outcome.id}" is duplicated.`); const existingOutcome = outcomeDefinitions.get(outcome.id);
outcomeIds.add(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()) { if (outcome.kind === 'attribute_modifier' && !outcome.attributeId?.trim()) {
errors.push(`${outcomePath} requires an Attribute ID.`); errors.push(`${outcomePath} requires an Attribute ID.`);
} }
if (![ if (![
'self', 'self',
'equipped_weapon', 'equipped_weapon',
'inactive_weapon',
'target', 'target',
'allies', 'allies',
'self_and_allies', 'self_and_allies',
@@ -1494,11 +1696,45 @@ export function validateMechanicProgram(program: MechanicProgram): ValidationRes
].includes(outcome.scope)) { ].includes(outcome.scope)) {
errors.push(`${outcomePath} has an unsupported 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) { if ((outcome.kind === 'restoration' || outcome.kind === 'resource_cost') && outcome.resource === undefined) {
errors.push(`${outcomePath} requires a resource.`); errors.push(`${outcomePath} requires a resource.`);
} }
if ((outcome.kind === 'restoration' || outcome.kind === 'resource_cost') && outcome.resource !== undefined 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.`); errors.push(`${outcomePath} has an unsupported resource.`);
} }
if (outcome.resource !== undefined && outcome.kind !== 'restoration' && outcome.kind !== 'resource_cost') { 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'], direct_damage: ['flat', 'max_health_percent', 'current_health_percent', 'weapon_base_percent'],
restoration: ['flat', 'max_health_percent', 'current_health_percent'], restoration: ['flat', 'max_health_percent', 'current_health_percent'],
resource_cost: ['flat', 'max_health_percent', 'current_health_percent'], resource_cost: ['flat', 'max_health_percent', 'current_health_percent'],
weapon_reload: ['flat'],
marker: ['flat'] marker: ['flat']
}; };
if (!allowedBasis[outcome.kind].includes(outcome.basis)) { if (!allowedBasis[outcome.kind].includes(outcome.basis)) {
@@ -1644,6 +1881,10 @@ export function evaluateMechanicProgram(
const continuousDeliveries: ContinuousDelivery[] = []; const continuousDeliveries: ContinuousDelivery[] = [];
if (scenario.mode === 'maximum') { if (scenario.mode === 'maximum') {
for (const clause of program.clauses) { 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 = { const event: MechanicEvent = {
kind: clause.when, kind: clause.when,
at: 0, at: 0,
@@ -1660,14 +1901,15 @@ export function evaluateMechanicProgram(
preEventCombat: copyCombat(combat), preEventCombat: copyCombat(combat),
equipment equipment
}; };
if (!predicateMatches(clause.if, context)) continue;
const before = new Map((clause.outcomes ?? []) const before = new Map((clause.outcomes ?? [])
.map((outcome) => outcome.statePool) .map((outcome) => outcome.statePool)
.filter((pool): pool is string => Boolean(pool)) .filter((pool): pool is string => Boolean(pool))
.map((pool) => [pool, stateValue(pool, undefined, context)])); .map((pool) => [pool, stateValue(pool, undefined, context)]));
outcomes.push(...emitOutcomes({ appendScheduledOutcomes(outcomes, emitOutcomes({
...clause, ...clause,
outcomes: (clause.outcomes ?? []).filter((outcome) => outcome.phase === 'while_active') 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 const ordered = events
@@ -1676,7 +1918,19 @@ export function evaluateMechanicProgram(
.sort((left, right) => left.event.at - right.event.at || left.index - right.index) .sort((left, right) => left.event.at - right.event.at || left.index - right.index)
.map(({ event }) => event); .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( outcomes.push(...expirePools(
event.at, event.at,
program.clauses, program.clauses,
@@ -1716,7 +1970,7 @@ export function evaluateMechanicProgram(
.map((outcome) => outcome.statePool) .map((outcome) => outcome.statePool)
.filter((pool): pool is string => Boolean(pool))); .filter((pool): pool is string => Boolean(pool)));
const before = new Map([...relevantPools].map((pool) => [pool, stateValue(pool, undefined, context)])); const before = new Map([...relevantPools].map((pool) => [pool, stateValue(pool, undefined, context)]));
outcomes.push(...emitOutcomes( appendScheduledOutcomes(outcomes, emitOutcomes(
clause, clause,
'before', 'before',
reduction.at, reduction.at,
@@ -1724,7 +1978,7 @@ export function evaluateMechanicProgram(
before, before,
context, context,
continuousDeliveries continuousDeliveries
)); ), reduction.at, scenario.encounterSeconds);
const mutationRecords = clause.mutations.map((mutation) => const mutationRecords = clause.mutations.map((mutation) =>
applyMutation(mutation, reduction.at, context, weight)); applyMutation(mutation, reduction.at, context, weight));
emissions.push({ emissions.push({
@@ -1737,7 +1991,7 @@ export function evaluateMechanicProgram(
if (clause.cooldownSeconds) { if (clause.cooldownSeconds) {
cooldowns.set(stateKey, reduction.at + clause.cooldownSeconds); cooldowns.set(stateKey, reduction.at + clause.cooldownSeconds);
} }
outcomes.push(...emitOutcomes( appendScheduledOutcomes(outcomes, emitOutcomes(
clause, clause,
'after', 'after',
reduction.at, reduction.at,
@@ -1745,9 +1999,12 @@ export function evaluateMechanicProgram(
before, before,
context, context,
continuousDeliveries continuousDeliveries
)); ), reduction.at, scenario.encounterSeconds);
} }
} }
if (event.kind === 'weapon_hit' && event.targetId) {
previousWeaponHitTargetId = event.targetId;
}
} }
outcomes.push(...expirePools( outcomes.push(...expirePools(
scenario.encounterSeconds, scenario.encounterSeconds,
@@ -1821,6 +2078,7 @@ function formatReference(reference: ValueReference): string {
function formatExpression(expression: NumericExpression): string { function formatExpression(expression: NumericExpression): string {
if (typeof expression === 'number') return formatNumber(expression); if (typeof expression === 'number') return formatNumber(expression);
if ('ref' in expression) return formatReference(expression.ref); if ('ref' in expression) return formatReference(expression.ref);
if (expression.op === 'floor') return `FLOOR(${formatExpression(expression.value)})`;
if (expression.op === 'clamp') { if (expression.op === 'clamp') {
return `CLAMP(${formatExpression(expression.value)}, ${formatExpression(expression.min)}, ${formatExpression(expression.max)})`; 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' const delivery = outcome.delivery === 'continuous'
? `; DELIVER TOTAL CONTINUOUSLY OVER ${formatNumber(outcome.durationSeconds ?? 0)}s` ? `; 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 { export function formatMechanicProgram(program: MechanicProgram): string {
+2 -2
View File
@@ -248,7 +248,7 @@ function stageFactorAt(
return Math.max(EPSILON, (1 + values.additive / 100) * values.multiplicative); return Math.max(EPSILON, (1 + values.additive / 100) * values.multiplicative);
} }
function activationTimes( export function skillActivationTimes(
ability: SkillAbilityInput, ability: SkillAbilityInput,
encounterSeconds: number, encounterSeconds: number,
input: SkillCalculationInput, 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 events = damageEvents(ability, activations, encounterSeconds, input, rules);
const damageByEvent = events.map((event) => const damageByEvent = events.map((event) =>
expectedDamage( expectedDamage(
@@ -42,6 +42,46 @@ describe('talent description outcomes', () => {
expect(missingTalentDescriptionSlots('Restore {1} over time.', outcomes)).toEqual([]); 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', () => { it('reports exact missing slots instead of relying on outcome count', () => {
const outcomes = numberedTalentOutcomes(draft([{ const outcomes = numberedTalentOutcomes(draft([{
key: 'damage', key: 'damage',
+3 -1
View File
@@ -7,7 +7,9 @@ const VALUE_BEARING_OUTCOME_KINDS = new Set([
'attribute_modifier', 'attribute_modifier',
'direct_damage', 'direct_damage',
'restoration', 'restoration',
'resource_cost' 'resource_cost',
'weapon_reload',
'marker'
]); ]);
export const numberedTalentOutcomes = ( export const numberedTalentOutcomes = (
+13 -4
View File
@@ -5,6 +5,9 @@
id: string; id: string;
name: string; name: string;
unit?: string; unit?: string;
detail?: string;
disabled?: boolean;
disabledReason?: string;
pending?: boolean; pending?: boolean;
proposerName?: string; proposerName?: string;
iconUrl?: string | null; iconUrl?: string | null;
@@ -52,8 +55,14 @@
const visible = $derived(presentation === 'list' ? available.slice(0, 30) : available); 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 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 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) => { const toggle = (id: string) => {
if (items.find((item) => item.id === id)?.disabled && !selectedIds.includes(id)) return;
if (mode === 'single') { if (mode === 'single') {
value = value === id ? '' : id; value = value === id ? '' : id;
onvaluechange?.(value); onvaluechange?.(value);
@@ -77,7 +86,7 @@
{#each selectedItems as item} {#each selectedItems as item}
<button type="button" class:pending-reference={item.pending} onclick={() => toggle(item.id)} aria-label={`Remove ${accessibleName(item)}`}> <button type="button" class:pending-reference={item.pending} onclick={() => toggle(item.id)} aria-label={`Remove ${accessibleName(item)}`}>
{#if item.showIcon || item.iconUrl}<ItemIcon src={item.iconUrl} alt="" className="assignment-item-icon assignment-chip-icon"/>{/if} {#if item.showIcon || item.iconUrl}<ItemIcon src={item.iconUrl} alt="" className="assignment-item-icon assignment-chip-icon"/>{/if}
<span>{item.name}{#if unitLabel(item)}<small class="assignment-item-unit">UNIT · {unitLabel(item)}</small>{/if}{#if item.pending}<small>PENDING</small>{/if}</span><b>×</b> <span>{item.name}{#if unitLabel(item)}<small class="assignment-item-unit">UNIT · {unitLabel(item)}</small>{/if}{#if item.detail}<small>{item.detail}</small>{/if}{#if item.pending}<small>PENDING</small>{/if}</span><b>×</b>
</button> </button>
{:else} {:else}
<small>{emptySelectionLabel}</small> <small>{emptySelectionLabel}</small>
@@ -89,12 +98,12 @@
{/if} {/if}
<div class="assignment-results" role="list"> <div class="assignment-results" role="list">
{#each visible as item} {#each visible as item}
<button type="button" class:selected={selectedIds.includes(item.id)} class:pending-reference={item.pending} class:with-icon={Boolean(item.showIcon || item.iconUrl)} class:visual-grid-option={presentation === 'visual-grid'} class:toggle-grid-option={presentation === 'toggle-grid'} aria-pressed={selectedIds.includes(item.id)} aria-label={presentation !== 'list' ? `${accessibleName(item)}, ${selectedIds.includes(item.id) ? 'assigned' : 'not assigned'}` : undefined} data-tooltip={presentation === 'visual-grid' ? item.name : undefined} onclick={() => toggle(item.id)}> <button type="button" class:selected={selectedIds.includes(item.id)} class:pending-reference={item.pending} class:with-icon={Boolean(item.showIcon || item.iconUrl)} class:visual-grid-option={presentation === 'visual-grid'} class:toggle-grid-option={presentation === 'toggle-grid'} aria-pressed={selectedIds.includes(item.id)} aria-label={presentation !== 'list' ? `${accessibleName(item)}, ${selectedIds.includes(item.id) ? 'assigned' : 'not assigned'}` : undefined} data-tooltip={presentation === 'visual-grid' ? item.name : undefined} disabled={item.disabled && !selectedIds.includes(item.id)} title={item.disabledReason} onclick={() => toggle(item.id)}>
{#if presentation === 'visual-grid' || item.showIcon || item.iconUrl}<ItemIcon src={item.iconUrl} alt="" className={`assignment-item-icon ${presentation === 'visual-grid' ? 'assignment-grid-icon' : 'assignment-result-icon'}`}/>{/if} {#if presentation === 'visual-grid' || item.showIcon || item.iconUrl}<ItemIcon src={item.iconUrl} alt="" className={`assignment-item-icon ${presentation === 'visual-grid' ? 'assignment-grid-icon' : 'assignment-result-icon'}`}/>{/if}
{#if presentation === 'toggle-grid'} {#if presentation === 'toggle-grid'}
<span>{item.name}</span> <span>{item.name}</span>
{:else if presentation === 'list'} {:else if presentation === 'list'}
<span>{item.name}{#if unitLabel(item)}<small class="assignment-item-unit">UNIT · {unitLabel(item)}</small>{/if}{#if item.pending}<small>PENDING · {item.proposerName ?? 'CONTRIBUTOR'}</small>{/if}</span><b>{selectedIds.includes(item.id) ? (mode === 'single' ? 'SELECTED' : 'ASSIGNED') : (mode === 'single' ? 'SELECT →' : 'ADD +')}</b> <span>{item.name}{#if unitLabel(item)}<small class="assignment-item-unit">UNIT · {unitLabel(item)}</small>{/if}{#if item.detail}<small>{item.detail}</small>{/if}{#if item.pending}<small>PENDING · {item.proposerName ?? 'CONTRIBUTOR'}</small>{/if}</span><b>{item.disabled && !selectedIds.includes(item.id) ? 'UNAVAILABLE' : selectedIds.includes(item.id) ? (mode === 'single' ? 'SELECTED' : 'ASSIGNED') : (mode === 'single' ? 'SELECT →' : 'ADD +')}</b>
{/if} {/if}
</button> </button>
{:else} {:else}
+19 -10
View File
@@ -8,6 +8,7 @@
meta: string; meta: string;
summary: string; summary: string;
iconUrl?: string; iconUrl?: string;
issue?: string;
}; };
let { let {
@@ -34,6 +35,12 @@
active === cell.id active === cell.id
|| (cell.id.startsWith('ability:') && active.startsWith('chip:') || (cell.id.startsWith('ability:') && active.startsWith('chip:')
&& Math.floor(Number(active.split(':')[1]) / 3) === Number(cell.id.split(':')[1])); && Math.floor(Number(active.split(':')[1]) / 3) === Number(cell.id.split(':')[1]));
const issueLabel = (issue: string) => {
if (/not configured|required/i.test(issue)) return 'ACTION REQUIRED';
if (/incomplete/i.test(issue)) return 'DATA INCOMPLETE';
return 'CHECK CONFIGURATION';
};
</script> </script>
<section class="loadout-matrix" aria-label="Loadout matrix"> <section class="loadout-matrix" aria-label="Loadout matrix">
@@ -48,19 +55,20 @@
type="button" type="button"
class="doctrine-cell" class="doctrine-cell"
class:active={selected(doctrine)} class:active={selected(doctrine)}
class:needs-attention={Boolean(doctrine.issue)}
aria-pressed={selected(doctrine)} aria-pressed={selected(doctrine)}
onclick={() => onselect(doctrine.id)} onclick={() => onselect(doctrine.id)}
> >
<ItemIcon src={doctrine.iconUrl} alt="" className="matrix-icon" /> <ItemIcon src={doctrine.iconUrl} alt="" className="matrix-icon" />
<span><small>{doctrine.label}</small><strong>{doctrine.name}</strong><em>{doctrine.summary}</em></span> <span><small>{doctrine.label}</small><strong>{doctrine.name}</strong><em>{doctrine.summary}</em>{#if doctrine.issue}<mark title={doctrine.issue} aria-hidden="true">{issueLabel(doctrine.issue)}</mark><span class="sr-only">{doctrine.issue}</span>{/if}</span>
<i aria-hidden="true"></i> <i aria-hidden="true"></i>
</button> </button>
<div class="supply-line"><span>SUPPLIES 3 ABILITIES</span></div> <div class="supply-line"><span>SUPPLIES 3 ABILITIES</span></div>
<div class="ability-cells"> <div class="ability-cells">
{#each abilities as cell} {#each abilities as cell}
<button type="button" class:active={selected(cell)} aria-pressed={selected(cell)} onclick={() => onselect(cell.id)}> <button type="button" class:active={selected(cell)} class:needs-attention={Boolean(cell.issue)} aria-pressed={selected(cell)} onclick={() => onselect(cell.id)}>
<ItemIcon src={cell.iconUrl} alt="" className="matrix-icon" /> <ItemIcon src={cell.iconUrl} alt="" className="matrix-icon" />
<span><small>{cell.label}</small><strong>{cell.name}</strong><em>{cell.summary}</em></span> <span><small>{cell.label}</small><strong>{cell.name}</strong><em>{cell.summary}</em>{#if cell.issue}<mark title={cell.issue} aria-hidden="true">{issueLabel(cell.issue)}</mark><span class="sr-only">{cell.issue}</span>{/if}</span>
</button> </button>
{/each} {/each}
</div> </div>
@@ -70,9 +78,9 @@
<small class="section-label">WEAPONS · PRIMARY + SECONDARY</small> <small class="section-label">WEAPONS · PRIMARY + SECONDARY</small>
<div class="equipment-cells"> <div class="equipment-cells">
{#each weapons as cell} {#each weapons as cell}
<button type="button" class:active={selected(cell)} aria-pressed={selected(cell)} onclick={() => onselect(cell.id)}> <button type="button" class:active={selected(cell)} class:needs-attention={Boolean(cell.issue)} aria-pressed={selected(cell)} onclick={() => onselect(cell.id)}>
<ItemIcon src={cell.iconUrl} alt="" className="matrix-icon" /> <ItemIcon src={cell.iconUrl} alt="" className="matrix-icon" />
<span><small>{cell.label}</small><strong>{cell.name}</strong><em>{cell.summary}</em></span> <span><small>{cell.label}</small><strong>{cell.name}</strong><em>{cell.summary}</em>{#if cell.issue}<mark title={cell.issue} aria-hidden="true">{issueLabel(cell.issue)}</mark><span class="sr-only">{cell.issue}</span>{/if}</span>
<i aria-hidden="true"></i> <i aria-hidden="true"></i>
</button> </button>
{/each} {/each}
@@ -83,9 +91,9 @@
<small class="section-label">GEAR · 6 POSITIONS</small> <small class="section-label">GEAR · 6 POSITIONS</small>
<div class="equipment-cells gear-cells"> <div class="equipment-cells gear-cells">
{#each gear as cell} {#each gear as cell}
<button type="button" class:active={selected(cell)} aria-pressed={selected(cell)} onclick={() => onselect(cell.id)}> <button type="button" class:active={selected(cell)} class:needs-attention={Boolean(cell.issue)} aria-pressed={selected(cell)} onclick={() => onselect(cell.id)}>
<ItemIcon src={cell.iconUrl} alt="" className="matrix-icon" /> <ItemIcon src={cell.iconUrl} alt="" className="matrix-icon" />
<span><small>{cell.label}</small><strong>{cell.name}</strong><em>{cell.summary}</em></span> <span><small>{cell.label}</small><strong>{cell.name}</strong><em>{cell.summary}</em>{#if cell.issue}<mark title={cell.issue} aria-hidden="true">{issueLabel(cell.issue)}</mark><span class="sr-only">{cell.issue}</span>{/if}</span>
<i aria-hidden="true"></i> <i aria-hidden="true"></i>
</button> </button>
{/each} {/each}
@@ -94,9 +102,9 @@
<div class="matrix-section protocol-section"> <div class="matrix-section protocol-section">
<small class="section-label">AGENT SYSTEM · ONE OS PROTOCOL</small> <small class="section-label">AGENT SYSTEM · ONE OS PROTOCOL</small>
<button type="button" class:active={selected(protocol)} aria-pressed={selected(protocol)} onclick={() => onselect(protocol.id)}> <button type="button" class:active={selected(protocol)} class:needs-attention={Boolean(protocol.issue)} aria-pressed={selected(protocol)} onclick={() => onselect(protocol.id)}>
<ItemIcon src={protocol.iconUrl} alt="" className="matrix-icon" /> <ItemIcon src={protocol.iconUrl} alt="" className="matrix-icon" />
<span><small>{protocol.label}</small><strong>{protocol.name}</strong><em>{protocol.summary}</em></span> <span><small>{protocol.label}</small><strong>{protocol.name}</strong><em>{protocol.summary}</em>{#if protocol.issue}<mark title={protocol.issue} aria-hidden="true">{issueLabel(protocol.issue)}</mark><span class="sr-only">{protocol.issue}</span>{/if}</span>
<i aria-hidden="true"></i> <i aria-hidden="true"></i>
</button> </button>
</div> </div>
@@ -113,9 +121,10 @@
:global(.matrix-icon){width:30px;height:30px;object-fit:contain} :global(.matrix-icon){width:30px;height:30px;object-fit:contain}
button>span{min-width:0;display:grid;gap:2px}button small,button em{overflow:hidden;color:var(--shd-text-dim);font-size:9px;font-style:normal;letter-spacing:.04em;text-overflow:ellipsis;white-space:nowrap}button strong{overflow:hidden;font-size:11px;text-overflow:ellipsis;white-space:nowrap}button>i{align-self:stretch;background:var(--quality-high-end)} button>span{min-width:0;display:grid;gap:2px}button small,button em{overflow:hidden;color:var(--shd-text-dim);font-size:9px;font-style:normal;letter-spacing:.04em;text-overflow:ellipsis;white-space:nowrap}button strong{overflow:hidden;font-size:11px;text-overflow:ellipsis;white-space:nowrap}button>i{align-self:stretch;background:var(--quality-high-end)}
button:hover,button:focus-visible{border-color:var(--shd-border-strong);background:var(--shd-surface-active);outline:none}button.active{border-color:var(--shd-builder-selection-border);background:var(--shd-builder-selection);box-shadow:inset 2px 0 var(--shd-orange)} button:hover,button:focus-visible{border-color:var(--shd-border-strong);background:var(--shd-surface-active);outline:none}button.active{border-color:var(--shd-builder-selection-border);background:var(--shd-builder-selection);box-shadow:inset 2px 0 var(--shd-orange)}
button.needs-attention{border-bottom-color:color-mix(in srgb,var(--shd-danger) 68%,var(--shd-border-soft))}button.needs-attention>i{background:var(--shd-danger)}button mark{width:fit-content;max-width:100%;overflow:hidden;margin-top:2px;padding:2px 4px;border:1px solid color-mix(in srgb,var(--shd-danger) 58%,transparent);background:#1d1212;color:var(--shd-danger);font-size:8px;font-weight:900;letter-spacing:.07em;line-height:1.2;text-overflow:ellipsis;text-transform:uppercase;white-space:nowrap}
.supply-line{height:22px;display:grid;place-items:center;background:linear-gradient(90deg,transparent 49.7%,var(--shd-orange) 49.7% 50.3%,transparent 50.3%)}.supply-line span{padding:0 6px;background:var(--shd-surface);color:var(--shd-orange);font-size:8px;font-weight:900;letter-spacing:.08em} .supply-line{height:22px;display:grid;place-items:center;background:linear-gradient(90deg,transparent 49.7%,var(--shd-orange) 49.7% 50.3%,transparent 50.3%)}.supply-line span{padding:0 6px;background:var(--shd-surface);color:var(--shd-orange);font-size:8px;font-weight:900;letter-spacing:.08em}
.ability-cells{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:3px}.ability-cells button{min-height:68px;grid-template-columns:25px minmax(0,1fr);padding:6px}.ability-cells :global(.matrix-icon){width:24px;height:24px} .ability-cells{display:grid;grid-template-columns: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} 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:1240px){.ability-cells{grid-template-columns:1fr}.ability-cells button{min-height:48px}}
@media(max-width:800px){.ability-cells{grid-template-columns:repeat(3,minmax(0,1fr))}} @media(max-width:800px){.ability-cells{grid-template-columns:repeat(3,minmax(0,1fr))}}
@@ -3,10 +3,12 @@
let { let {
result, result,
abilityNames = {} abilityNames = {},
resolverHint = 'Complete the required Specialization ability data.'
}: { }: {
result: SkillCalculation; result: SkillCalculation;
abilityNames?: Record<string, string>; abilityNames?: Record<string, string>;
resolverHint?: string;
} = $props(); } = $props();
const number = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }); const number = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 });
</script> </script>
@@ -17,10 +19,10 @@
<b>{result.status === 'OK' ? 'CALCULATED' : 'INSUFFICIENT DATA'}</b> <b>{result.status === 'OK' ? 'CALCULATED' : 'INSUFFICIENT DATA'}</b>
</header> </header>
{#if result.status === 'INSUFFICIENT_DATA'} {#if result.status === 'INSUFFICIENT_DATA'}
<div class="missing"> <div class="missing" role="status">
<strong>Skill calculation unavailable</strong> <strong>SKILL OUTPUT BLOCKED</strong>
<span>ISAC requires the following data:</span> <span>{result.missing[0]?.replaceAll(':', ' · ') ?? 'Missing ability data'}</span>
<ul>{#each result.missing as item}<li>{item.replaceAll(':', ' · ')}</li>{/each}</ul> <small>{result.missing.length > 1 ? `+${result.missing.length - 1} additional requirement${result.missing.length === 2 ? '' : 's'}. ` : ''}{resolverHint}</small>
</div> </div>
{:else} {:else}
<div class="combined"> <div class="combined">
@@ -39,19 +41,19 @@
{/each} {/each}
</div> </div>
{/if} {/if}
<footer>SKILLS CAN CRIT AND MULTI-SHOT · SKILLS DO NOT HEADSHOT · ENCOUNTER TRIGGERS FOLLOW THE PRIMARY WEAPON TIMELINE</footer> {#if result.status === 'OK'}<footer>SKILLS CAN CRIT AND MULTI-SHOT · SKILLS DO NOT HEADSHOT · ENCOUNTER TRIGGERS FOLLOW THE PRIMARY WEAPON TIMELINE</footer>{/if}
</article> </article>
<style> <style>
.skill-calculation{display:grid;gap:var(--shd-gap-md);padding:var(--shd-gap-md);border:1px solid var(--shd-border);border-top:2px solid var(--shd-info);background:var(--shd-surface)} .skill-calculation{display:grid;gap:var(--shd-gap-md);padding:var(--shd-gap-md);border:1px solid var(--shd-border);border-top:2px solid var(--shd-info);background:var(--shd-surface)}
.skill-calculation.incomplete{border-top-color:var(--shd-danger)} .skill-calculation.incomplete{gap:6px;padding:8px;border-top-color:var(--shd-danger)}
header{display:flex;justify-content:space-between;align-items:start;gap:var(--shd-gap-md)} header{display:flex;justify-content:space-between;align-items:start;gap:var(--shd-gap-md)}
header p,h3{margin:0}header p{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1.1px}h3{text-transform:uppercase;font-size:var(--shd-type-body);letter-spacing:.7px} header p,h3{margin:0}header p{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1.1px}h3{text-transform:uppercase;font-size:var(--shd-type-body);letter-spacing:.7px}
header>b{padding:5px 7px;border:1px solid var(--shd-info);color:var(--shd-info);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.incomplete header>b{border-color:var(--shd-danger);color:var(--shd-danger)} header>b{padding:5px 7px;border:1px solid var(--shd-info);color:var(--shd-info);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.incomplete header h3{font-size:10px}.incomplete header>b{padding:3px 5px;border-color:var(--shd-danger);color:var(--shd-danger);font-size:8px}
.combined{display:grid;grid-template-columns:repeat(3,1fr);border:1px solid var(--shd-border)}.combined span{display:grid;gap:4px;padding:9px;border-left:1px solid var(--shd-border)}.combined span:first-child{border-left:0}.combined b{color:var(--shd-info);font-size:20px} .combined{display:grid;grid-template-columns:repeat(3,1fr);border:1px solid var(--shd-border)}.combined span{display:grid;gap:4px;padding:9px;border-left:1px solid var(--shd-border)}.combined span:first-child{border-left:0}.combined b{color:var(--shd-info);font-size:20px}
small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px} small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}
.ability-results{display:grid;border:1px solid var(--shd-border)}.ability-results>div{display:grid;grid-template-columns:minmax(140px,1fr) repeat(3,auto);gap:var(--shd-gap-sm);padding:7px 8px;border-top:1px solid var(--shd-border);align-items:center}.ability-results>div:first-child{border-top:0}.ability-results>div.disabled{opacity:.55}.ability-results span{color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900} .ability-results{display:grid;border:1px solid var(--shd-border)}.ability-results>div{display:grid;grid-template-columns:minmax(140px,1fr) repeat(3,auto);gap:var(--shd-gap-sm);padding:7px 8px;border-top:1px solid var(--shd-border);align-items:center}.ability-results>div:first-child{border-top:0}.ability-results>div.disabled{opacity:.55}.ability-results span{color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900}
.missing{display:grid;gap:4px;padding:10px;border:1px solid var(--shd-danger);border-left-width:2px;background:#1d1212;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.missing strong{color:var(--shd-danger);text-transform:uppercase}.missing ul{margin:2px 0 0;padding-left:18px} .missing{display:grid;grid-template-columns:auto minmax(0,1fr);align-items:center;gap:3px 8px;padding:7px 8px;border:1px solid var(--shd-danger);border-left-width:2px;background:#171111}.missing strong{color:var(--shd-danger);font-size:9px;letter-spacing:.8px;white-space:nowrap}.missing>span{overflow:hidden;color:var(--shd-text);font-size:var(--shd-type-telemetry);font-weight:800;text-overflow:ellipsis;white-space:nowrap}.missing>small{grid-column:1/-1;color:var(--shd-text-dim);font-size:9px;letter-spacing:.35px}
footer{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-weight:800;letter-spacing:.6px} footer{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-weight:800;letter-spacing:.6px}
@media(max-width:700px){header,.combined{display:grid;grid-template-columns:1fr}.combined span{border-left:0;border-top:1px solid var(--shd-border)}.combined span:first-child{border-top:0}.ability-results>div{grid-template-columns:1fr 1fr}.ability-results strong{grid-column:1/-1}} @media(max-width:700px){header,.combined{display:grid;grid-template-columns:1fr}.skill-calculation.incomplete>header{display:flex}.combined span{border-left:0;border-top:1px solid var(--shd-border)}.combined span:first-child{border-top:0}.ability-results>div{grid-template-columns:1fr 1fr}.ability-results strong{grid-column:1/-1}}
</style> </style>
@@ -28,9 +28,19 @@
revisionLabel?: string; 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 }); const number = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 });
function format(value: number | undefined) { return value === undefined ? '—' : number.format(value); } 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);
</script> </script>
<article class:incomplete={result.status === 'insufficient'} class="weapon-calculation" aria-labelledby={`${result.weaponLabel}-calculation`} aria-live="polite"> <article class:incomplete={result.status === 'insufficient'} class="weapon-calculation" aria-labelledby={`${result.weaponLabel}-calculation`} aria-live="polite">
@@ -39,7 +49,11 @@
<b>{result.status === 'complete' ? 'CALCULATED' : 'INSUFFICIENT DATA'}</b> <b>{result.status === 'complete' ? 'CALCULATED' : 'INSUFFICIENT DATA'}</b>
</header> </header>
{#if result.status === 'insufficient'} {#if result.status === 'insufficient'}
<div class="missing"><strong>Calculation unavailable</strong><span>Complete the following required data:</span><ul>{#each result.missing ?? [] as item}<li>{item}</li>{/each}</ul></div> <div class="missing" role="status">
<strong>OUTPUT BLOCKED</strong>
<span>{systemRequirements[0] ?? `${loadoutRequirementCount} loadout requirement${loadoutRequirementCount === 1 ? '' : 's'}`}</span>
<small>{loadoutRequirementCount ? `${loadoutRequirementCount} loadout requirement${loadoutRequirementCount === 1 ? '' : 's'} remain. ${resolverHint}` : resolverHint}</small>
</div>
{:else} {:else}
<div class="damage-grid" aria-label={`${result.weaponName} damage estimates`}> <div class="damage-grid" aria-label={`${result.weaponName} damage estimates`}>
<span><small>BODY HIT</small><b>{format(result.bodyHit)}</b></span><span><small>CRITICAL BODY</small><b>{format(result.criticalBodyHit)}</b></span> <span><small>BODY HIT</small><b>{format(result.bodyHit)}</b></span><span><small>CRITICAL BODY</small><b>{format(result.criticalBodyHit)}</b></span>
@@ -63,14 +77,14 @@
</div> </div>
{/if} {/if}
{/if} {/if}
<footer><span>MODELED, BEFORE ENEMY MITIGATION</span>{#if result.revisionLabel}<span>{result.revisionLabel}</span>{/if}</footer> {#if result.status === 'complete'}<footer><span>MODELED, BEFORE ENEMY MITIGATION</span>{#if result.revisionLabel}<span>{result.revisionLabel}</span>{/if}</footer>{/if}
</article> </article>
<style> <style>
.weapon-calculation{display:grid;gap:var(--shd-gap-md);padding:var(--shd-gap-md);border:1px solid var(--shd-border);border-top:2px solid var(--shd-orange);background:var(--shd-surface)} .weapon-calculation{display:grid;gap:var(--shd-gap-md);padding:var(--shd-gap-md);border:1px solid var(--shd-border);border-top:2px solid var(--shd-orange);background:var(--shd-surface)}
.weapon-calculation.incomplete{border-top-color:var(--shd-danger)}header{display:flex;justify-content:space-between;gap:var(--shd-gap-md);align-items:start}header p{margin:0 0 3px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1.1px}h3{margin:0;font-size:var(--shd-type-body);letter-spacing:.7px;text-transform:uppercase}header>b{padding:5px 7px;border:1px solid #74552a;background:#1d180e;color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.8px;white-space:nowrap}.incomplete header>b{border-color:#563734;background:#1d1212;color:var(--shd-danger)} .weapon-calculation.incomplete{gap:6px;padding:8px;border-top-color:var(--shd-danger)}header{display:flex;justify-content:space-between;gap:var(--shd-gap-md);align-items:start}header p{margin:0 0 3px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1.1px}h3{margin:0;font-size:var(--shd-type-body);letter-spacing:.7px;text-transform:uppercase}header>b{padding:5px 7px;border:1px solid #74552a;background:#1d180e;color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.8px;white-space:nowrap}.incomplete header h3{font-size:10px}.incomplete header>b{padding:3px 5px;border-color:#563734;background:#1d1212;color:var(--shd-danger);font-size:8px}
.damage-grid,.dps-grid{display:grid;border:1px solid var(--shd-border)}.damage-grid{grid-template-columns:repeat(2,1fr)}.dps-grid{grid-template-columns:repeat(3,1fr)}.damage-grid span,.dps-grid span{display:grid;gap:4px;padding:9px;border-left:1px solid var(--shd-border);border-top:1px solid var(--shd-border)}.damage-grid span:nth-child(odd),.dps-grid span:first-child{border-left:0}.damage-grid span:nth-child(-n+2){border-top:0}.dps-grid b{color:var(--shd-orange);font-size:20px}.damage-grid b{font-size:var(--shd-type-body)}small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.missing{display:grid;gap:4px;padding:10px;border:1px solid #563734;border-left:2px solid var(--shd-danger);background:#1d1212;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.missing strong{color:var(--shd-danger);text-transform:uppercase;font-size:var(--shd-type-telemetry);letter-spacing:.8px}.missing ul{margin:2px 0 0;padding-left:18px}.missing li+li{margin-top:2px}footer{display:flex;justify-content:space-between;gap:var(--shd-gap-sm);color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-weight:800;letter-spacing:.6px} .damage-grid,.dps-grid{display:grid;border:1px solid var(--shd-border)}.damage-grid{grid-template-columns:repeat(2,1fr)}.dps-grid{grid-template-columns:repeat(3,1fr)}.damage-grid span,.dps-grid span{display:grid;gap:4px;padding:9px;border-left:1px solid var(--shd-border);border-top:1px solid var(--shd-border)}.damage-grid span:nth-child(odd),.dps-grid span:first-child{border-left:0}.damage-grid span:nth-child(-n+2){border-top:0}.dps-grid b{color:var(--shd-orange);font-size:20px}.damage-grid b{font-size:var(--shd-type-body)}small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.missing{display:grid;grid-template-columns:auto minmax(0,1fr);align-items:center;gap:3px 8px;padding:7px 8px;border:1px solid #563734;border-left:2px solid var(--shd-danger);background:#171111;color:var(--shd-text-muted)}.missing strong{color:var(--shd-danger);font-size:9px;letter-spacing:.8px;white-space:nowrap}.missing>span{overflow:hidden;color:var(--shd-text);font-size:var(--shd-type-telemetry);font-weight:800;text-overflow:ellipsis;white-space:nowrap}.missing>small{grid-column:1/-1;color:var(--shd-text-dim);font-size:9px;font-weight:700;letter-spacing:.35px}footer{display:flex;justify-content:space-between;gap:var(--shd-gap-sm);color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-weight:800;letter-spacing:.6px}
.stack-telemetry{display:grid;border:1px solid var(--shd-model-border);background:var(--shd-model-surface)}.stack-telemetry>header{display:flex;align-items:center;justify-content:space-between;padding:6px 8px;border-bottom:1px solid var(--shd-model-border);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.stack-telemetry>header span{color:var(--shd-info)}.stack-telemetry>header b{color:var(--shd-text-muted)}.stack-telemetry>div{display:grid;grid-template-columns:minmax(120px,1.2fr) .45fr .7fr .8fr .9fr .85fr .8fr;gap:7px;align-items:center;padding:7px 8px;border-top:1px solid var(--shd-border-soft);font-size:var(--shd-type-telemetry)}.stack-telemetry>div:first-of-type{border-top:0}.stack-telemetry>div>span,.stack-telemetry>div>small,.stack-telemetry>div>em,.stack-telemetry>div>b{font-size:inherit;font-style:normal;letter-spacing:.5px}.stack-telemetry>div>span{color:var(--shd-orange);font-weight:900}.stack-telemetry>div>em{color:var(--shd-text-muted)}.stack-telemetry>div>b{color:var(--shd-danger);text-align:right}.stack-telemetry>div>b.maintained{color:var(--shd-success)} .stack-telemetry{display:grid;border:1px solid var(--shd-model-border);background:var(--shd-model-surface)}.stack-telemetry>header{display:flex;align-items:center;justify-content:space-between;padding:6px 8px;border-bottom:1px solid var(--shd-model-border);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.stack-telemetry>header span{color:var(--shd-info)}.stack-telemetry>header b{color:var(--shd-text-muted)}.stack-telemetry>div{display:grid;grid-template-columns:minmax(120px,1.2fr) .45fr .7fr .8fr .9fr .85fr .8fr;gap:7px;align-items:center;padding:7px 8px;border-top:1px solid var(--shd-border-soft);font-size:var(--shd-type-telemetry)}.stack-telemetry>div:first-of-type{border-top:0}.stack-telemetry>div>span,.stack-telemetry>div>small,.stack-telemetry>div>em,.stack-telemetry>div>b{font-size:inherit;font-style:normal;letter-spacing:.5px}.stack-telemetry>div>span{color:var(--shd-orange);font-weight:900}.stack-telemetry>div>em{color:var(--shd-text-muted)}.stack-telemetry>div>b{color:var(--shd-danger);text-align:right}.stack-telemetry>div>b.maintained{color:var(--shd-success)}
@media(max-width:820px){.stack-telemetry>div{grid-template-columns:1fr 1fr}.stack-telemetry>div>strong{grid-column:1/-1}.stack-telemetry>div>b{text-align:left}} @media(max-width:820px){.stack-telemetry>div{grid-template-columns:1fr 1fr}.stack-telemetry>div>strong{grid-column:1/-1}.stack-telemetry>div>b{text-align:left}}
@media(max-width:620px){header{display:grid}.dps-grid{grid-template-columns:1fr}.dps-grid span{border-left:0;border-top:1px solid var(--shd-border)}.dps-grid span:first-child{border-top:0}footer{display:grid}.damage-grid span{min-height:44px}} @media(max-width:620px){header{display:grid}.weapon-calculation.incomplete>header{display:flex}.dps-grid{grid-template-columns:1fr}.dps-grid span{border-left:0;border-top:1px solid var(--shd-border)}.dps-grid span:first-child{border-top:0}footer{display:grid}.damage-grid span{min-height:44px}}
</style> </style>
@@ -7,11 +7,21 @@
import type { AiMechanicContext } from '$lib/ai/mechanic-authoring'; import type { AiMechanicContext } from '$lib/ai/mechanic-authoring';
import { import {
blankConditionalMechanicDraft, blankConditionalMechanicDraft,
currentHealthPercentBandPredicate,
eventFieldComparisonPredicate,
parseConditionalMechanicDraft, parseConditionalMechanicDraft,
readCurrentHealthPercentBand,
readEventFieldComparison,
readStatePoolThreshold,
statePoolThresholdPredicate,
type ConditionalMechanicClauseDraft, type ConditionalMechanicClauseDraft,
type ConditionalMechanicDraftV1, type ConditionalMechanicDraftV1,
type ConditionalMechanicOutcomeDraft, type ConditionalMechanicOutcomeDraft,
type ConditionalMechanicPoolDraft type ConditionalMechanicPoolDraft,
type EditablePredicateScalar,
type EventFieldComparison,
type PredicateComparisonOperator,
type StatePoolThresholdComparison
} from '$lib/calculations/mechanic-editor'; } from '$lib/calculations/mechanic-editor';
import { import {
formatMechanicProgram, formatMechanicProgram,
@@ -59,6 +69,11 @@
let recipeCount = $state(9); let recipeCount = $state(9);
let recipeWindow = $state(3); let recipeWindow = $state(3);
let recipeDistance = $state(10); 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 replaceConfirmation = $state(false);
let valueModelOverrides = $state<Record<string, OutcomeValueModel>>({}); let valueModelOverrides = $state<Record<string, OutcomeValueModel>>({});
@@ -90,17 +105,32 @@
{ value: 'direct_damage', label: 'DAMAGE' }, { value: 'direct_damage', label: 'DAMAGE' },
{ value: 'restoration', label: 'RESTORE' }, { value: 'restoration', label: 'RESTORE' },
{ value: 'resource_cost', label: 'COST' }, { value: 'resource_cost', label: 'COST' },
{ value: 'weapon_reload', label: 'WEAPON RELOAD' },
{ value: 'marker', label: 'MARKER' } { value: 'marker', label: 'MARKER' }
]; ];
const scopeOptions = $derived([ const scopeOptions = $derived([
{ value: 'self', label: 'SELF' }, { value: 'self', label: 'SELF' },
{ value: 'equipped_weapon', label: 'WEAPON' }, { value: 'equipped_weapon', label: 'WEAPON' },
{ value: 'inactive_weapon', label: 'INACTIVE WEAPON' },
{ value: 'target', label: 'TARGET' }, { value: 'target', label: 'TARGET' },
{ value: 'allies', label: 'ALLIES' }, { value: 'allies', label: 'ALLIES' },
{ value: 'self_and_allies', label: 'SELF + ALLIES' }, { value: 'self_and_allies', label: 'SELF + ALLIES' },
...(allowAssignedAbility ? [{ value: 'assigned_ability', label: 'ABILITY' }] : []), ...(allowAssignedAbility ? [{ value: 'assigned_ability', label: 'ABILITY' }] : []),
{ value: 'all_skills', label: 'ALL SKILLS' } { 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() const slug = (value: string) => value.toLocaleLowerCase()
.replace(/[^a-z0-9]+/g, '-') .replace(/[^a-z0-9]+/g, '-')
@@ -114,10 +144,119 @@
if (recipeKey === 'consecutive_same_target_hits') return { hitsRequired: recipeCount }; if (recipeKey === 'consecutive_same_target_hits') return { hitsRequired: recipeCount };
if (recipeKey === 'continuous_firing_duration') return { seconds: recipeWindow }; if (recipeKey === 'continuous_firing_duration') return { seconds: recipeWindow };
if (recipeKey === 'target_hit_within_distance' || recipeKey === 'within_distance') return { meters: recipeDistance }; 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 {}; 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<EventFieldComparison>
) {
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<StatePoolThresholdComparison>
) {
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) { function applyRecipe(replace = false) {
if (!recipeKey) return; if (!recipeKey) return;
const selectedRecipeKey = recipeKey; const selectedRecipeKey = recipeKey;
@@ -647,6 +786,19 @@
{#if ['target_hit_within_distance', 'within_distance'].includes(recipeKey)} {#if ['target_hit_within_distance', 'within_distance'].includes(recipeKey)}
<label class="field">METERS<input type="number" min="0" step="any" bind:value={recipeDistance}/></label> <label class="field">METERS<input type="number" min="0" step="any" bind:value={recipeDistance}/></label>
{/if} {/if}
{#if recipeKey === 'health_threshold_bands'}
<div class="health-band-config">
<p>Define strict “below Health” bands from strongest to weakest. Exact thresholds enter the next weaker band.</p>
{#each recipeHealthBands as band, index}
<div>
<label class="field">BELOW · HEALTH %<input type="number" min="0.01" max="100" step="any" bind:value={band.thresholdPercent}/></label>
<label class="field">BAND LABEL<input maxlength="80" bind:value={band.outcomeKey}/></label>
<button type="button" class="remove" onclick={() => recipeHealthBands = recipeHealthBands.filter((_, current) => current !== index)} disabled={recipeHealthBands.length === 1}>REMOVE</button>
</div>
{/each}
<button type="button" class="secondary compact-action" onclick={() => recipeHealthBands = [...recipeHealthBands,{thresholdPercent:Math.min(100,(recipeHealthBands.at(-1)?.thresholdPercent ?? 0) + 25),outcomeKey:`health-band-${recipeHealthBands.length + 1}`}]} disabled={recipeHealthBands.length >= 5}>ADD BAND +</button>
</div>
{/if}
<button type="button" class="secondary" disabled={!recipeKey} onclick={() => applyRecipe(false)}>ADD RECIPE BRANCH +</button> <button type="button" class="secondary" disabled={!recipeKey} onclick={() => applyRecipe(false)}>ADD RECIPE BRANCH +</button>
{#if rule.clauses.length && recipeKey} {#if rule.clauses.length && recipeKey}
<button type="button" class="danger-quiet" onclick={() => replaceConfirmation = true}>REPLACE PROGRAM</button> <button type="button" class="danger-quiet" onclick={() => replaceConfirmation = true}>REPLACE PROGRAM</button>
@@ -669,7 +821,13 @@
<label class="field">INITIAL<input type="number" value={pool.initial} oninput={(event) => updatePool(index,{initial:Number(event.currentTarget.value)})}/></label> <label class="field">INITIAL<input type="number" value={pool.initial} oninput={(event) => updatePool(index,{initial:Number(event.currentTarget.value)})}/></label>
<label class="field">MAX<input type="number" min="1" value={pool.max ?? 1} oninput={(event) => updatePool(index,{max:Number(event.currentTarget.value)})}/></label> <label class="field">MAX<input type="number" min="1" value={pool.max ?? 1} oninput={(event) => updatePool(index,{max:Number(event.currentTarget.value)})}/></label>
<label class="field">BEST CASE<input type="number" value={pool.bestCaseValue ?? pool.max ?? 1} oninput={(event) => updatePool(index,{bestCaseValue:Number(event.currentTarget.value)})}/></label> <label class="field">BEST CASE<input type="number" value={pool.bestCaseValue ?? pool.max ?? 1} oninput={(event) => updatePool(index,{bestCaseValue:Number(event.currentTarget.value)})}/></label>
<label class="field">EXPIRY · SECONDS<input type="number" min="0.01" step="any" value={pool.expiresAfterSeconds ?? ''} oninput={(event) => updatePool(index,{expiresAfterSeconds:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/></label> {#if pool.expiresAfterSecondsVariants}
{#each valueColumns as column}
<label class="field">EXPIRY · {column.label}<input type="number" min="0.01" step="any" value={pool.expiresAfterSecondsVariants?.[column.variantKey] ?? ''} oninput={(event) => updatePool(index,{expiresAfterSecondsVariants:{...pool.expiresAfterSecondsVariants,[column.variantKey]:Number(event.currentTarget.value)}})}/></label>
{/each}
{:else}
<label class="field">EXPIRY · SECONDS<input type="number" min="0.01" step="any" value={pool.expiresAfterSeconds ?? ''} oninput={(event) => updatePool(index,{expiresAfterSeconds:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/></label>
{/if}
<button type="button" class="remove" onclick={() => setRule({...rule,pools:rule.pools.filter((_,current) => current !== index)})}>REMOVE</button> <button type="button" class="remove" onclick={() => setRule({...rule,pools:rule.pools.filter((_,current) => current !== index)})}>REMOVE</button>
</div> </div>
{:else} {:else}
@@ -679,6 +837,9 @@
<div class="branch-stack"> <div class="branch-stack">
{#each rule.clauses as clause, clauseIndex} {#each rule.clauses as clause, clauseIndex}
{@const conditionBand = readCurrentHealthPercentBand(clause.if)}
{@const eventFieldCondition = readEventFieldComparison(clause.if)}
{@const statePoolCondition = readStatePoolThreshold(clause.if)}
<section class="branch"> <section class="branch">
<header> <header>
<div><span>{String(clauseIndex + 1).padStart(2, '0')}</span><label class="field">BRANCH LABEL<input value={clause.label ?? ''} oninput={(event) => updateClause(clauseIndex,{label:event.currentTarget.value,key:slug(event.currentTarget.value)})}/></label></div> <div><span>{String(clauseIndex + 1).padStart(2, '0')}</span><label class="field">BRANCH LABEL<input value={clause.label ?? ''} oninput={(event) => updateClause(clauseIndex,{label:event.currentTarget.value,key:slug(event.currentTarget.value)})}/></label></div>
@@ -689,6 +850,75 @@
<div class="branch-readout"><span>MATCH POLICY</span><strong>{reducerItems.find((item) => item.id === clause.reducer.kind)?.name ?? clause.reducer.kind}</strong></div> <div class="branch-readout"><span>MATCH POLICY</span><strong>{reducerItems.find((item) => item.id === clause.reducer.kind)?.name ?? clause.reducer.kind}</strong></div>
<label class="field">COOLDOWN · SECONDS<input type="number" min="0.01" step="any" value={clause.cooldownSeconds ?? ''} oninput={(event) => updateClause(clauseIndex,{cooldownSeconds:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/></label> <label class="field">COOLDOWN · SECONDS<input type="number" min="0.01" step="any" value={clause.cooldownSeconds ?? ''} oninput={(event) => updateClause(clauseIndex,{cooldownSeconds:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/></label>
</div> </div>
<div class="condition-controls">
<SegmentedChoice
legend="BRANCH CONDITION"
name={`${idPrefix}-condition-${clauseIndex}`}
options={conditionOptions(clause)}
value={conditionModel(clause)}
onchange={(values) => setConditionModel(clauseIndex,values[0])}
/>
{#if conditionBand}
<div class="health-boundaries">
<label class="field">FROM · HEALTH %<input type="number" min="0" max="100" step="any" value={conditionBand.minimum ?? ''} placeholder="0" oninput={(event) => updateHealthBand(clauseIndex,'minimum',event.currentTarget.value)}/><small>Inclusive; leave blank for zero.</small></label>
<label class="field">BELOW · HEALTH %<input type="number" min="0.01" max="100" step="any" value={conditionBand.maximum} oninput={(event) => updateHealthBand(clauseIndex,'maximum',event.currentTarget.value)}/><small>Exclusive; exact values do not match.</small></label>
</div>
{:else if eventFieldCondition}
<div class="predicate-fields event-predicate-fields">
<label class="field">EVENT FIELD PATH<input value={eventFieldCondition.path} placeholder="values.state" oninput={(event) => updateEventFieldComparison(clauseIndex,{path:event.currentTarget.value})}/></label>
<SegmentedChoice
legend="COMPARISON"
name={`${idPrefix}-event-operator-${clauseIndex}`}
options={predicateOperatorOptions}
value={eventFieldCondition.operator}
columns={6}
onchange={(values) => updateEventFieldComparison(clauseIndex,{operator:values[0] as PredicateComparisonOperator})}
/>
<SegmentedChoice
legend="VALUE TYPE"
name={`${idPrefix}-event-value-type-${clauseIndex}`}
options={predicateScalarTypeOptions}
value={predicateScalarType(eventFieldCondition.value)}
onchange={(values) => setEventFieldScalarType(clauseIndex,values[0])}
/>
{#if typeof eventFieldCondition.value === 'number'}
<label class="field">COMPARE VALUE<input type="number" step="any" value={eventFieldCondition.value} oninput={(event) => updateEventFieldComparison(clauseIndex,{value:Number(event.currentTarget.value)})}/></label>
{:else if typeof eventFieldCondition.value === 'boolean'}
<SegmentedChoice
legend="COMPARE VALUE"
name={`${idPrefix}-event-boolean-${clauseIndex}`}
options={[{value:'true',label:'TRUE'},{value:'false',label:'FALSE'}]}
value={String(eventFieldCondition.value)}
onchange={(values) => updateEventFieldComparison(clauseIndex,{value:values[0] === 'true'})}
/>
{:else}
<label class="field">COMPARE VALUE<input value={eventFieldCondition.value} placeholder="moving" oninput={(event) => updateEventFieldComparison(clauseIndex,{value:event.currentTarget.value})}/></label>
{/if}
</div>
{:else if statePoolCondition}
<div class="predicate-fields state-predicate-fields">
<div class="field"><span>STATE POOL</span><SearchableAssignment
items={rule.pools.map((pool) => ({id:pool.key,name:pool.label}))}
mode="single"
compact
subjectLabel="STATE POOL"
value={statePoolCondition.pool}
onvaluechange={(value) => updateStatePoolThreshold(clauseIndex,{pool:value})}
/></div>
<SegmentedChoice
legend="COMPARISON"
name={`${idPrefix}-state-operator-${clauseIndex}`}
options={predicateOperatorOptions}
value={statePoolCondition.operator}
columns={6}
onchange={(values) => updateStatePoolThreshold(clauseIndex,{operator:values[0] as PredicateComparisonOperator})}
/>
<label class="field">THRESHOLD<input type="number" step="any" value={statePoolCondition.value} oninput={(event) => updateStatePoolThreshold(clauseIndex,{value:Number(event.currentTarget.value)})}/></label>
</div>
{:else if conditionModel(clause) === 'advanced'}
<p class="advanced-condition-note">This saved condition remains active and is preserved unchanged. Use the generated mechanic preview to inspect it.</p>
{/if}
</div>
<div class="reducer-controls"> <div class="reducer-controls">
<SegmentedChoice <SegmentedChoice
legend="MATCH POLICY" legend="MATCH POLICY"
@@ -760,10 +990,28 @@
<label class="field">DESCRIPTION SLOT<input type="number" min="1" step="1" value={outcome.descriptionSlot ?? ''} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{descriptionSlot:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/></label> <label class="field">DESCRIPTION SLOT<input type="number" min="1" step="1" value={outcome.descriptionSlot ?? ''} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{descriptionSlot:event.currentTarget.value ? Number(event.currentTarget.value) : undefined})}/></label>
</div> </div>
<SegmentedChoice legend="TARGET SCOPE" name={`${idPrefix}-scope-${clauseIndex}-${outcomeIndex}`} options={scopeOptions} value={outcome.scope} columns={scopeOptions.length > 4 ? 4 : scopeOptions.length} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{scope:values[0] as MechanicOutcomeScope})}/> <SegmentedChoice legend="TARGET SCOPE" name={`${idPrefix}-scope-${clauseIndex}-${outcomeIndex}`} options={scopeOptions} value={outcome.scope} columns={scopeOptions.length > 4 ? 4 : scopeOptions.length} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{scope:values[0] as MechanicOutcomeScope})}/>
{#if outcome.scope === 'allies' || outcome.scope === 'self_and_allies' || outcome.scope === 'target'}
<div class="delivery-controls">
<SegmentedChoice
legend="AREA TARGETING"
name={`${idPrefix}-targeting-${clauseIndex}-${outcomeIndex}`}
options={[{value:'none',label:'NONE'},{value:'allies',label:'ALLIES IN RADIUS'},{value:'enemies',label:'ENEMIES IN RADIUS'}]}
value={outcome.targeting?.population ?? 'none'}
onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{
targeting: values[0] === 'none'
? undefined
: {kind:'within_radius',population:values[0] as 'allies'|'enemies',radiusMeters:outcome.targeting?.radiusMeters ?? 20}
})}
/>
{#if outcome.targeting}
<label class="field">RADIUS (METERS)<input type="number" min="0.01" step="any" value={outcome.targeting.radiusMeters} oninput={(event) => updateOutcome(clauseIndex,outcomeIndex,{targeting:{...outcome.targeting!,radiusMeters:Number(event.currentTarget.value)}})}/></label>
{/if}
</div>
{/if}
{#if outcome.kind === 'attribute_modifier'} {#if outcome.kind === 'attribute_modifier'}
<SearchableAssignment items={attributes} mode="single" compact subjectLabel="ATTRIBUTE" bind:value={outcome.attributeId}/> <SearchableAssignment items={attributes} mode="single" compact subjectLabel="ATTRIBUTE" bind:value={outcome.attributeId}/>
{:else if outcome.kind === 'restoration' || outcome.kind === 'resource_cost'} {:else if outcome.kind === 'restoration' || outcome.kind === 'resource_cost'}
<SegmentedChoice legend="RESOURCE" name={`${idPrefix}-resource-${clauseIndex}-${outcomeIndex}`} options={[{value:'health',label:'HEALTH'},{value:'armor',label:'ARMOR'},{value:'ammo',label:'AMMO'}]} value={outcome.resource ?? 'health'} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{resource:values[0] as 'health'|'armor'|'ammo'})}/> <SegmentedChoice legend="RESOURCE" name={`${idPrefix}-resource-${clauseIndex}-${outcomeIndex}`} options={[{value:'health',label:'HEALTH'},{value:'armor',label:'ARMOR'},{value:'ammo',label:'AMMO'},{value:'signature_ability_charge',label:'SIGNATURE CHARGE'}]} value={outcome.resource ?? 'health'} onchange={(values) => updateOutcome(clauseIndex,outcomeIndex,{resource:values[0] as 'health'|'armor'|'ammo'|'signature_ability_charge'})}/>
{/if} {/if}
{#if outcome.kind === 'restoration'} {#if outcome.kind === 'restoration'}
<div class="delivery-controls"> <div class="delivery-controls">
@@ -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{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} .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)} .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} .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{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} .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-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>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)} .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} .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)} .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)} .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)} .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} .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: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: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{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: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}} @media(prefers-reduced-motion:reduce){.remove,.danger-quiet{transition:none}}
</style> </style>
@@ -78,7 +78,7 @@ describe('AI mechanic authoring prompt', () => {
recipeKeys: [] recipeKeys: []
})); }));
expect(AI_MECHANIC_PROMPT_VERSION).toBe(5); expect(AI_MECHANIC_PROMPT_VERSION).toBe(6);
expect(input.canonicalCatalog.grammar.outcome.optional).toEqual( expect(input.canonicalCatalog.grammar.outcome.optional).toEqual(
expect.arrayContaining(['delivery', 'durationSeconds']) 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.stringContaining('Do not replace continuous delivery with a periodic reducer')
])); ]));
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain( expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain(
'Prompt contract version: 5' 'Prompt contract version: 6'
); );
const advertised = structuredClone( const advertised = structuredClone(
@@ -157,6 +157,33 @@ describe('AI mechanic authoring prompt', () => {
expect(parsed.ok).toBe(true); 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', () => { it('advertises Precise Trauma through target expiry without inventing target tick iteration', () => {
const skillDamageId = 'skill-damage-catalog-id'; const skillDamageId = 'skill-damage-catalog-id';
const input = JSON.parse(buildAiMechanicAuthoringInput({ const input = JSON.parse(buildAiMechanicAuthoringInput({
+11 -5
View File
@@ -1,8 +1,9 @@
import type { AiMechanicAuthoringRequest } from '$lib/ai/mechanic-authoring'; import type { AiMechanicAuthoringRequest } from '$lib/ai/mechanic-authoring';
import { CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES } from '$lib/calculations/mechanic-editor'; 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'; import type { AiMechanicAttribute } from './mechanic-authoring-context';
export const AI_MECHANIC_PROMPT_VERSION = 5; export const AI_MECHANIC_PROMPT_VERSION = 6;
const mechanicsGrammar = { const mechanicsGrammar = {
version: 1, version: 1,
@@ -17,9 +18,9 @@ const mechanicsGrammar = {
required: ['key', 'label', 'stateKind', 'scope', 'initial'], required: ['key', 'label', 'stateKind', 'scope', 'initial'],
stateKind: ['flag', 'count', 'stack', 'duration', 'scalar', 'target_mark', 'resource'], stateKind: ['flag', 'count', 'stack', 'duration', 'scalar', 'target_mark', 'resource'],
scope: ['agent', 'target'], scope: ['agent', 'target'],
optional: ['min', 'max', 'bestCaseValue', 'expiresAfterSeconds'], optional: ['min', 'max', 'bestCaseValue', 'expiresAfterSeconds', 'expiresAfterSecondsVariants'],
expirationSemantics: 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: 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.' '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'], mutationKinds: ['none', 'add', 'set', 'consume', 'derive', 'clear', 'transfer'],
entitySelectors: ['agent', 'event_actor', 'event_target', 'event_field'], entitySelectors: ['agent', 'event_actor', 'event_target', 'event_field'],
valueReferences: ['event', 'scenario', 'combat', 'equipment', 'attribute', 'reducer', 'pool'], 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: [ referenceShapes: [
{ ref: { source: 'event', path: 'values.distanceMeters' } }, { ref: { source: 'event', path: 'values.distanceMeters' } },
{ ref: { source: 'scenario', key: 'input-key' } }, { ref: { source: 'scenario', key: 'input-key' } },
{ ref: { source: 'combat', path: 'currentHealth', timing: 'current' } }, { ref: { source: 'combat', path: 'currentHealth', timing: 'current' } },
{ ref: { source: 'combat', path: 'currentHealthPercent', timing: 'current' } },
{ ref: { source: 'equipment', path: 'protocolTalentUpgrades' } }, { ref: { source: 'equipment', path: 'protocolTalentUpgrades' } },
{ ref: { source: 'attribute', attributeId: 'replace-with-an-exact-catalog-attribute-id' } }, { ref: { source: 'attribute', attributeId: 'replace-with-an-exact-catalog-attribute-id' } },
{ ref: { source: 'reducer' } }, { ref: { source: 'reducer' } },
@@ -56,7 +59,8 @@ const mechanicsGrammar = {
expressionShapes: [ expressionShapes: [
{ op: 'multiply', left: 3, right: { ref: { source: 'pool', pool: 'stacks' } } }, { op: 'multiply', left: 3, right: { ref: { source: 'pool', pool: 'stacks' } } },
{ op: 'min', left: { ref: { source: 'reducer' } }, right: 5 }, { 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: [ predicateShapes: [
{ kind: 'true' }, { 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.', '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:"<exact supplied catalog ID>"}. Never use an Attribute name or invent an ID.', 'Live Attribute references must use {source:"attribute",attributeId:"<exact supplied catalog ID>"}. 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%.', '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.', '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.', '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.', '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.',
+39 -4
View File
@@ -1,5 +1,6 @@
import { connect } from '@tursodatabase/database'; import { connect } from '@tursodatabase/database';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
import { import {
conditionalRuleDraftFromRows, conditionalRuleDraftFromRows,
loadConditionalRuleDraft, loadConditionalRuleDraft,
@@ -174,7 +175,7 @@ describe('conditional mechanic persistence adapter', () => {
stat_attribute_id TEXT, value REAL, value_expression_json TEXT, value_basis TEXT, stat_attribute_id TEXT, value REAL, value_expression_json TEXT, value_basis TEXT,
value_scaling TEXT, unit TEXT, damage_basis TEXT, application_phase TEXT, value_scaling TEXT, unit TEXT, damage_basis TEXT, application_phase TEXT,
combat_timing TEXT, resource_kind TEXT, delivery_mode 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 description_slot INTEGER, sort_order INTEGER, created_at TEXT, updated_at TEXT
)`); )`);
@@ -189,13 +190,24 @@ describe('conditional mechanic persistence adapter', () => {
scope: 'target', scope: 'target',
initial: 0, initial: 0,
min: 0, min: 0,
max: 1 max: 1,
expiresAfterSecondsVariants: {
I: 12,
II: 18
}
} as ConditionalMechanicDraftV1['pools'][number] & {
expiresAfterSecondsVariants: Record<string, number>;
}], }],
clauses: [{ clauses: [{
key: 'weapon-kill', key: 'weapon-kill',
label: 'Weapon kill', label: 'Weapon kill',
event: 'enemy_killed', event: 'enemy_killed',
if: { kind: 'true' }, if: {
kind: 'compare',
left: { source: 'combat', path: 'currentHealthPercent', timing: 'current' },
operator: 'lt',
right: 75
},
reducer: { kind: 'none' }, reducer: { kind: 'none' },
mutations: [{ mutations: [{
kind: 'set', kind: 'set',
@@ -213,6 +225,7 @@ describe('conditional mechanic persistence adapter', () => {
basis: 'flat', basis: 'flat',
delivery: 'continuous', delivery: 'continuous',
durationSeconds: 3, durationSeconds: 3,
targeting: { kind: 'within_radius', population: 'allies', radiusMeters: 40 },
variants: { variants: {
I: { value: 240 }, I: { value: 240 },
II: { value: 336 } II: { value: 336 }
@@ -275,6 +288,17 @@ describe('conditional mechanic persistence adapter', () => {
{ delivery_mode: 'continuous', delivery_duration_seconds: 3 }, { delivery_mode: 'continuous', delivery_duration_seconds: 3 },
{ delivery_mode: 'instant', delivery_duration_seconds: null } { 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( const loaded = await loadConditionalRuleDraft(
database, database,
'talent', 'talent',
@@ -284,15 +308,26 @@ describe('conditional mechanic persistence adapter', () => {
expect(loaded?.clauses[0].outcomes[0]).toMatchObject({ expect(loaded?.clauses[0].outcomes[0]).toMatchObject({
delivery: 'continuous', delivery: 'continuous',
durationSeconds: 3, durationSeconds: 3,
targeting: { kind: 'within_radius', population: 'allies', radiusMeters: 40 },
variants: { variants: {
I: { value: 240 }, I: { value: 240 },
II: { value: 336 } 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({ expect(loaded?.pools[0]).toMatchObject({
key: 'marked-target', key: 'marked-target',
stateKind: 'target_mark', stateKind: 'target_mark',
scope: 'target' scope: 'target',
expiresAfterSecondsVariants: {
I: 12,
II: 18
}
}); });
expect(loaded?.clauses[0].mutations).toEqual([{ expect(loaded?.clauses[0].mutations).toEqual([{
kind: 'set', kind: 'set',
+44 -15
View File
@@ -37,9 +37,11 @@ const applicationKind = (outcome: ConditionalMechanicOutcomeDraft) => {
if (outcome.kind === 'attribute_modifier') return 'active_modifier'; if (outcome.kind === 'attribute_modifier') return 'active_modifier';
if (outcome.kind === 'direct_damage') return 'direct_damage'; if (outcome.kind === 'direct_damage') return 'direct_damage';
if (outcome.kind === 'resource_cost') return 'resource_cost'; if (outcome.kind === 'resource_cost') return 'resource_cost';
if (outcome.kind === 'weapon_reload') return 'weapon_reload';
if (outcome.kind === 'marker') return 'marker'; if (outcome.kind === 'marker') return 'marker';
if (outcome.resource === 'armor') return 'restore_armor'; if (outcome.resource === 'armor') return 'restore_armor';
if (outcome.resource === 'ammo') return 'restore_ammo'; if (outcome.resource === 'ammo') return 'restore_ammo';
if (outcome.resource === 'signature_ability_charge') return 'restore_signature_ability_charge';
return 'restore_health'; 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 === 'active_modifier') return 'attribute_modifier';
if (row.application_kind === 'direct_damage') return 'direct_damage'; if (row.application_kind === 'direct_damage') return 'direct_damage';
if (row.application_kind === 'resource_cost') return 'resource_cost'; 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'; if (row.application_kind === 'marker') return 'marker';
return 'restoration'; return 'restoration';
}; };
const outcomeResource = (row: OutcomeRow): MechanicResource | undefined => { 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; return row.resource_kind;
} }
if (row.application_kind === 'restore_health') return 'health'; if (row.application_kind === 'restore_health') return 'health';
if (row.application_kind === 'restore_armor') return 'armor'; if (row.application_kind === 'restore_armor') return 'armor';
if (row.application_kind === 'restore_ammo') return 'ammo'; if (row.application_kind === 'restore_ammo') return 'ammo';
if (row.application_kind === 'restore_signature_ability_charge') return 'signature_ability_charge';
return undefined; return undefined;
}; };
@@ -74,6 +83,7 @@ const outcomeMetadataColumns = [
'resource_kind', 'resource_kind',
'delivery_mode', 'delivery_mode',
'delivery_duration_seconds', 'delivery_duration_seconds',
'targeting_json',
'state_pool_id', 'state_pool_id',
'min_state_value', 'min_state_value',
'max_state_value', 'max_state_value',
@@ -131,17 +141,25 @@ export function conditionalRuleDraftFromRows(
const pools = poolRows const pools = poolRows
.filter((row) => row.rule_id === rule.id) .filter((row) => row.rule_id === rule.id)
.sort((left, right) => Number(left.sort_order) - Number(right.sort_order)) .sort((left, right) => Number(left.sort_order) - Number(right.sort_order))
.map((row): ConditionalMechanicPoolDraft => ({ .map((row): ConditionalMechanicPoolDraft => {
key: String(row.state_key), const parameters = parseJson<Record<string, unknown>>(row.parameters_json, {});
label: String(row.label), const expiresAfterSecondsVariants = parameters.expiresAfterSecondsVariants;
stateKind: String(row.state_kind) as ConditionalMechanicPoolDraft['stateKind'], return {
scope: String(row.state_scope) as ConditionalMechanicPoolDraft['scope'], key: String(row.state_key),
initial: Number(row.initial_value), label: String(row.label),
...(row.minimum_value === null || row.minimum_value === undefined ? {} : { min: Number(row.minimum_value) }), stateKind: String(row.state_kind) as ConditionalMechanicPoolDraft['stateKind'],
...(row.maximum_value === null || row.maximum_value === undefined ? {} : { max: Number(row.maximum_value) }), scope: String(row.state_scope) as ConditionalMechanicPoolDraft['scope'],
...(row.best_case_value === null || row.best_case_value === undefined ? {} : { bestCaseValue: Number(row.best_case_value) }), initial: Number(row.initial_value),
...(row.duration_seconds === null || row.duration_seconds === undefined ? {} : { expiresAfterSeconds: Number(row.duration_seconds) }) ...(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<string, number> }
: {})
};
});
const poolKeyById = new Map(poolRows.map((row) => [String(row.id), String(row.state_key)])); const poolKeyById = new Map(poolRows.map((row) => [String(row.id), String(row.state_key)]));
const rowsByClause = new Map<string, OutcomeRow[]>(); const rowsByClause = new Map<string, OutcomeRow[]>();
for (const row of outcomeRows.filter((item) => item.rule_id === rule.id)) { for (const row of outcomeRows.filter((item) => item.rule_id === rule.id)) {
@@ -189,6 +207,9 @@ export function conditionalRuleDraftFromRows(
...(delivery === 'continuous' ...(delivery === 'continuous'
? { delivery, durationSeconds: durationSeconds as number } ? { 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.stat_attribute_id ? { attributeId: String(first.stat_attribute_id) } : {}),
...(first.state_pool_id && poolKeyById.has(String(first.state_pool_id)) ...(first.state_pool_id && poolKeyById.has(String(first.state_pool_id))
? { statePool: poolKeyById.get(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<string, string>(); const poolIds = new Map<string, string>();
for (const [index, pool] of draft.pools.entries()) { for (const [index, pool] of draft.pools.entries()) {
const expiresAfterSecondsVariants = (
pool as ConditionalMechanicPoolDraft & {
expiresAfterSecondsVariants?: Record<string, number>;
}
).expiresAfterSecondsVariants;
const poolId = randomUUID(); const poolId = randomUUID();
poolIds.set(pool.key, poolId); poolIds.set(pool.key, poolId);
await (await database.prepare(`INSERT INTO conditional_effect_state_pools await (await database.prepare(`INSERT INTO conditional_effect_state_pools
@@ -313,7 +339,9 @@ export async function saveConditionalRuleInTransaction(
pool.expiresAfterSeconds ?? null, pool.expiresAfterSeconds ?? null,
'clear_all', 'clear_all',
'refresh', 'refresh',
'{}', JSON.stringify(expiresAfterSecondsVariants
? { expiresAfterSecondsVariants }
: {}),
index + 1, index + 1,
now, now,
now now
@@ -356,9 +384,9 @@ export async function saveConditionalRuleInTransaction(
application_kind,effect_scope,stat_attribute_id,value,value_expression_json, application_kind,effect_scope,stat_attribute_id,value,value_expression_json,
value_basis,value_scaling,unit,damage_basis,application_phase,combat_timing, value_basis,value_scaling,unit,damage_basis,application_phase,combat_timing,
resource_kind,delivery_mode,delivery_duration_seconds, 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) created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run( VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(
randomUUID(), randomUUID(),
ruleId, ruleId,
clauseId, clauseId,
@@ -382,6 +410,7 @@ export async function saveConditionalRuleInTransaction(
outcome.resource ?? null, outcome.resource ?? null,
outcome.delivery ?? 'instant', outcome.delivery ?? 'instant',
outcome.durationSeconds ?? null, outcome.durationSeconds ?? null,
outcome.targeting === undefined ? null : JSON.stringify(outcome.targeting),
outcome.minStacks ?? null, outcome.minStacks ?? null,
outcome.maxStacks ?? null, outcome.maxStacks ?? null,
outcome.descriptionSlot ?? null, outcome.descriptionSlot ?? null,
+34
View File
@@ -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);
});
});
+17
View File
@@ -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;
}
}
@@ -15,6 +15,7 @@ import {
} from '$lib/server/ai/calculation-authoring-schema'; } from '$lib/server/ai/calculation-authoring-schema';
import { generateAiCalculationDraft } from '$lib/server/ai/calculation-authoring-service'; import { generateAiCalculationDraft } from '$lib/server/ai/calculation-authoring-service';
import { AI_CALCULATION_PROMPT_VERSION } from '$lib/server/ai/calculation-authoring-prompt'; 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 { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
@@ -26,7 +27,7 @@ export const POST: RequestHandler = async ({ cookies, request, url }) => {
'ai.calculation.generate' 'ai.calculation.generate'
); );
const origin = request.headers.get('origin'); const origin = request.headers.get('origin');
if (origin && origin !== url.origin) { if (!isAllowedShdBuildRequestOrigin(origin, url.origin)) {
return json( return json(
{ message: 'Cross-origin calculation authoring requests are not allowed.' }, { message: 'Cross-origin calculation authoring requests are not allowed.' },
{ status: 403 } { status: 403 }
@@ -15,6 +15,7 @@ import {
AiMechanicRateLimitError, AiMechanicRateLimitError,
reserveAiMechanicGeneration reserveAiMechanicGeneration
} from '$lib/server/ai/mechanic-authoring-rate-limit'; } from '$lib/server/ai/mechanic-authoring-rate-limit';
import { isAllowedShdBuildRequestOrigin } from '$lib/server/request-origin';
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
@@ -26,7 +27,7 @@ export const POST: RequestHandler = async ({ cookies, request, url }) => {
'ai.mechanic.generate' 'ai.mechanic.generate'
); );
const origin = request.headers.get('origin'); const origin = request.headers.get('origin');
if (origin && origin !== url.origin) { if (!isAllowedShdBuildRequestOrigin(origin, url.origin)) {
return json( return json(
{ message: 'Cross-origin mechanic authoring requests are not allowed.' }, { message: 'Cross-origin mechanic authoring requests are not allowed.' },
{ status: 403 } { status: 403 }
@@ -5,6 +5,7 @@ import {
parseMechanicGapReport, parseMechanicGapReport,
submitMechanicGapReport submitMechanicGapReport
} from '$lib/server/ai/mechanic-gap-report'; } from '$lib/server/ai/mechanic-gap-report';
import { isAllowedShdBuildRequestOrigin } from '$lib/server/request-origin';
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
@@ -16,7 +17,7 @@ export const POST: RequestHandler = async ({ cookies, request, url }) => {
'ai.mechanic.generate' 'ai.mechanic.generate'
); );
const origin = request.headers.get('origin'); 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 }); return json({ message: 'Cross-origin mechanic gap reports are not allowed.' }, { status: 403 });
} }
const contentLength = Number(request.headers.get('content-length') ?? 0); const contentLength = Number(request.headers.get('content-length') ?? 0);
+12 -7
View File
@@ -413,7 +413,8 @@
summary: selectedFocus summary: selectedFocus
? `${selectedFocus.primary_attribute} · ${selectedSpecializationPoints} / ${data.specializationProgression.availablePoints} POINTS` ? `${selectedFocus.primary_attribute} · ${selectedSpecializationPoints} / ${data.specializationProgression.availablePoints} POINTS`
: 'ROOT CHOICE · DEFINES ALL THREE ABILITIES', : '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 abilityCells = $derived.by((): MatrixCell[] => [0, 1, 2].map((index) => {
const ability = selectedFocus?.abilities[index]; const ability = selectedFocus?.abilities[index];
@@ -425,7 +426,8 @@
name: ability?.name ?? 'NOT ASSIGNED', name: ability?.name ?? 'NOT ASSIGNED',
meta: ability?.attack?.damage_type ?? '', meta: ability?.attack?.damage_type ?? '',
summary: chipNames.length ? chipNames.join(' · ') : `${configuredChips.length} / 3 SKILL CHIPS`, summary: chipNames.length ? chipNames.join(' · ') : `${configuredChips.length} / 3 SKILL CHIPS`,
iconUrl: ability?.iconUrl iconUrl: ability?.iconUrl,
issue: !ability ? 'ABILITY NOT CONFIGURED' : configuredChips.length < 3 ? `${3 - configuredChips.length} CHIP${configuredChips.length === 2 ? '' : 'S'} REQUIRED` : undefined
}; };
})); }));
const weaponCells = $derived.by((): MatrixCell[] => draft.weapons.map((selection, index) => { const weaponCells = $derived.by((): MatrixCell[] => draft.weapons.map((selection, index) => {
@@ -438,7 +440,8 @@
name: item?.name ?? 'EMPTY SLOT', name: item?.name ?? 'EMPTY SLOT',
meta: item?.category ?? '', meta: item?.category ?? '',
summary: item ? details || `T${selection.tier} · ${unitValue(selection.damage, '')} DAMAGE` : 'SELECT WEAPON', summary: item ? details || `T${selection.tier} · ${unitValue(selection.damage, '')} DAMAGE` : 'SELECT WEAPON',
iconUrl: weaponIcon(item?.category) iconUrl: weaponIcon(item?.category),
issue: item ? undefined : 'REQUIRED FOR CALCULATION'
}; };
})); }));
const gearCells = $derived.by((): MatrixCell[] => gearSlots.map((slot) => { const gearCells = $derived.by((): MatrixCell[] => gearSlots.map((slot) => {
@@ -455,7 +458,8 @@
name: item?.name ?? 'EMPTY SLOT', name: item?.name ?? 'EMPTY SLOT',
meta: item ? `T${item.tier}` : '', meta: item ? `T${item.tier}` : '',
summary: item ? details || `T${item.tier} · LEVEL ${selection.level}` : 'SELECT GEAR', summary: item ? details || `T${item.tier} · LEVEL ${selection.level}` : 'SELECT GEAR',
iconUrl: gearIcon(slot) iconUrl: gearIcon(slot),
issue: item ? undefined : 'REQUIRED FOR CALCULATION'
}; };
})); }));
const protocolCell = $derived<MatrixCell>({ const protocolCell = $derived<MatrixCell>({
@@ -466,7 +470,8 @@
summary: protocolItem() summary: protocolItem()
? `LEVEL ${draft.protocol.level} · ${draft.protocol.talentUpgrades} / 5 TALENT UPGRADES` ? `LEVEL ${draft.protocol.level} · ${draft.protocol.talentUpgrades} / 5 TALENT UPGRADES`
: 'SELECT ONE PROTOCOL', : 'SELECT ONE PROTOCOL',
iconUrl: protocolItem()?.iconUrl iconUrl: protocolItem()?.iconUrl,
issue: protocolItem() ? undefined : 'REQUIRED FOR CALCULATION'
}); });
const loadoutConfigured = $derived(Boolean( const loadoutConfigured = $derived(Boolean(
selectedFocus selectedFocus
@@ -764,8 +769,8 @@
<aside class="loadout-summary"> <aside class="loadout-summary">
<div class="summary-head"><div><p class="eyebrow"><span></span> LIVE SUMMARY</p><h2>AGENT STATS</h2></div><b>{summary.stats.length}</b></div> <div class="summary-head"><div><p class="eyebrow"><span></span> LIVE SUMMARY</p><h2>AGENT STATS</h2></div><b>{summary.stats.length}</b></div>
<div class="summary-core">{#each ['Firepower','Toughness','Engineering'] as name}{@const stat = summary.stats.find((entry) => attribute(entry.id)?.name === name)}<div><GameIcon name={name.toLowerCase() as GameIconName} decorative/><span>{name}<b>{stat ? unitValue(stat.value,stat.id) : '0'}</b></span></div>{/each}</div> <div class="summary-core">{#each ['Firepower','Toughness','Engineering'] as name}{@const stat = summary.stats.find((entry) => attribute(entry.id)?.name === name)}<div><GameIcon name={name.toLowerCase() as GameIconName} decorative/><span>{name}<b>{stat ? unitValue(stat.value,stat.id) : '0'}</b></span></div>{/each}</div>
<div class="builder-combat-results">{#each combatResults as result}<WeaponCalculationResult {result}/>{/each}</div> <div class="builder-combat-results">{#each combatResults as result}<WeaponCalculationResult {result} resolverHint="Use the warning markers in the loadout matrix."/>{/each}</div>
<div class="builder-combat-results"><SkillCalculationResult result={skillCombatResult} abilityNames={skillAbilityNames}/></div> <div class="builder-combat-results"><SkillCalculationResult result={skillCombatResult} abilityNames={skillAbilityNames} resolverHint="Open the marked doctrine or ability cell."/></div>
<div class="summary-scroll"> <div class="summary-scroll">
<div class="summary-section"><header><span>COMBINED ATTRIBUTES</span><b>{summary.stats.length}</b></header>{#each summary.stats as stat}<div class="summary-stat"><span>{attribute(stat.id)?.name}<small>{attribute(stat.id)?.category} · {stat.sourceCount} SOURCE{stat.sourceCount === 1 ? '' : 'S'}</small></span><b>{unitValue(stat.value,stat.id)}</b></div>{:else}<p>Equip and configure items to calculate attributes.</p>{/each}</div> <div class="summary-section"><header><span>COMBINED ATTRIBUTES</span><b>{summary.stats.length}</b></header>{#each summary.stats as stat}<div class="summary-stat"><span>{attribute(stat.id)?.name}<small>{attribute(stat.id)?.category} · {stat.sourceCount} SOURCE{stat.sourceCount === 1 ? '' : 'S'}</small></span><b>{unitValue(stat.value,stat.id)}</b></div>{:else}<p>Equip and configure items to calculate attributes.</p>{/each}</div>
<div class="summary-section"><header><span>ACTIVE SET BONUSES</span><b>{summary.activeBonuses.length}</b></header>{#each summary.activeBonuses as bonus}<div class="summary-bonus"><span><b>{bonus.name}</b><small>{bonus.pieces}-PIECE · {bonus.count} EQUIPPED</small></span><em>+{unitValue(bonus.value,bonus.statId)} {attribute(bonus.statId)?.name}</em></div>{:else}<p>Matching Brand Sets and Skill Chips activate bonuses here.</p>{/each}</div> <div class="summary-section"><header><span>ACTIVE SET BONUSES</span><b>{summary.activeBonuses.length}</b></header>{#each summary.activeBonuses as bonus}<div class="summary-bonus"><span><b>{bonus.name}</b><small>{bonus.pieces}-PIECE · {bonus.count} EQUIPPED</small></span><em>+{unitValue(bonus.value,bonus.statId)} {attribute(bonus.statId)?.name}</em></div>{:else}<p>Matching Brand Sets and Skill Chips activate bonuses here.</p>{/each}</div>
@@ -1,7 +1,10 @@
<script lang="ts"> <script lang="ts">
import PseudoScript from '$lib/components/PseudoScript.svelte'; import PseudoScript from '$lib/components/PseudoScript.svelte';
import { TRIGGER_ACTION_REGISTRY } from '$lib/calculations/conditional'; import { TRIGGER_ACTION_REGISTRY } from '$lib/calculations/conditional';
import { MECHANIC_EVENT_REGISTRY } from '$lib/calculations/mechanic'; import {
MECHANIC_COMBAT_REFERENCE_REGISTRY,
MECHANIC_EVENT_REGISTRY
} from '$lib/calculations/mechanic';
type AttributeVariable = { type AttributeVariable = {
id: string; id: string;
@@ -57,13 +60,14 @@
{ token: 'COOLDOWN', meaning: 'Blocks that branch from activating again for the configured duration.' }, { token: 'COOLDOWN', meaning: 'Blocks that branch from activating again for the configured duration.' },
{ token: 'WEIGHT', meaning: 'Applies a scenario-dependent contribution to a branch.' }, { token: 'WEIGHT', meaning: 'Applies a scenario-dependent contribution to a branch.' },
{ token: 'ADD / SET / CONSUME / CLEAR / TRANSFER', meaning: 'Changes a named state pool.' }, { token: 'ADD / SET / CONSUME / CLEAR / TRANSFER', meaning: 'Changes a named state pool.' },
{ token: 'APPLY', meaning: 'Emits an Attribute modifier, damage, restoration, resource cost, or marker.' } { token: 'APPLY / RELOAD', meaning: 'Emits an Attribute modifier, damage, restoration, resource cost, weapon action, or marker.' }
]; ];
const valueSources = [ const valueSources = [
{ syntax: 'EVENT.<path>', use: 'A value carried by the current event, such as target distance or critical state.' }, { syntax: 'EVENT.<path>', use: 'A value carried by the current event, such as target distance or critical state.' },
{ syntax: 'SCENARIO.<key>', use: 'A builder assumption supplied by Maximum Potential or Custom Scenario mode.' }, { syntax: 'SCENARIO.<key>', use: 'A builder assumption supplied by Maximum Potential or Custom Scenario mode.' },
{ syntax: 'COMBAT.<path>', use: 'The current calculated combat state, including current or maximum Health.' }, { syntax: 'COMBAT.currentHealth', use: MECHANIC_COMBAT_REFERENCE_REGISTRY.currentHealth.description },
{ syntax: 'COMBAT.currentHealthPercent', use: `${MECHANIC_COMBAT_REFERENCE_REGISTRY.currentHealthPercent.description} “Below” conditions use a strict < comparison.` },
{ syntax: 'PRE-EVENT COMBAT.<path>', use: 'Combat state captured before the current branch changes it.' }, { syntax: 'PRE-EVENT COMBAT.<path>', use: 'Combat state captured before the current branch changes it.' },
{ syntax: 'ATTRIBUTE[<id>]', use: 'The authoritative loadout total for a stable Attribute ID. Percentage Attributes use their displayed percentage value.' }, { syntax: 'ATTRIBUTE[<id>]', use: 'The authoritative loadout total for a stable Attribute ID. Percentage Attributes use their displayed percentage value.' },
{ syntax: 'EQUIPMENT.<path>', use: 'A value from the equipped item, including OS Protocol talent upgrades.' }, { syntax: 'EQUIPMENT.<path>', use: 'A value from the equipped item, including OS Protocol talent upgrades.' },
@@ -108,6 +112,7 @@
{ token: 'DIRECT DAMAGE', detail: 'Deals damage from a flat value or a configured percentage basis.' }, { token: 'DIRECT DAMAGE', detail: 'Deals damage from a flat value or a configured percentage basis.' },
{ token: 'RESTORATION', detail: 'Restores Health from a flat value or Health basis.' }, { token: 'RESTORATION', detail: 'Restores Health from a flat value or Health basis.' },
{ token: 'RESOURCE COST', detail: 'Consumes Health or another modeled resource in branch order.' }, { token: 'RESOURCE COST', detail: 'Consumes Health or another modeled resource in branch order.' },
{ token: 'WEAPON RELOAD', detail: 'Reloads the inactive weapon after the configured delay. A new activation refreshes a pending reload timer.' },
{ token: 'MARKER', detail: 'Emits a typed state marker without changing a numeric Attribute.' } { token: 'MARKER', detail: 'Emits a typed state marker without changing a numeric Attribute.' }
]; ];
+117 -36
View File
@@ -20,7 +20,7 @@ import {
numberedTalentOutcomes numberedTalentOutcomes
} from '$lib/calculations/talent-description'; } from '$lib/calculations/talent-description';
type SharedEffect = { slot: number; statId?: string; valueI: number; valueII: number }; type SharedEffect = { slot: number; statId?: string; valueI: number; valueII?: number };
type TalentErrorArea = 'icon' | 'identity' | 'description' | 'mechanic' | 'outcomes'; type TalentErrorArea = 'icon' | 'identity' | 'description' | 'mechanic' | 'outcomes';
type TalentValidationError = { area: TalentErrorArea; message: string }; type TalentValidationError = { area: TalentErrorArea; message: string };
@@ -70,7 +70,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
FROM talents`)).get(), FROM talents`)).get(),
getPendingEntities(user.id, [ getPendingEntities(user.id, [
{ module: 'attributes', action: 'save', kind: 'stat', fields: ['unit'] }, { module: 'attributes', action: 'save', kind: 'stat', fields: ['unit'] },
{ module: 'talents', action: 'saveTalent', kind: 'talent', fields: ['category', 'descriptionTemplate', 'conditionalRule'] } { module: 'talents', action: 'saveTalent', kind: 'talent', fields: ['category', 'descriptionTemplate', 'conditionalRule', 'tierIIDocumented'] }
]) ])
]); ]);
const pendingStats = pending.filter((item) => item.kind === 'stat'); const pendingStats = pending.filter((item) => item.kind === 'stat');
@@ -79,7 +79,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
: filter === 'slot2' ? item.category === 'Weapon Talent 2' : filter === 'slot2' ? item.category === 'Weapon Talent 2'
: filter === 'backpack' ? item.category === 'Backpack' : filter === 'backpack' ? item.category === 'Backpack'
: filter === 'body-armor' ? item.category === 'Body Armor' : filter === 'body-armor' ? item.category === 'Body Armor'
: filter === 'exotic' ? false : true : filter === 'exotic' ? item.category === 'Exotic Weapon' : true
).filter((item) => ).filter((item) =>
!search || String(item.name).toLocaleLowerCase().includes(search.toLocaleLowerCase()) !search || String(item.name).toLocaleLowerCase().includes(search.toLocaleLowerCase())
).map((item) => ({ ).map((item) => ({
@@ -92,9 +92,16 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
conditionalRule: item.conditionalRule ?? null, conditionalRule: item.conditionalRule ?? null,
ownerWeaponId: null, ownerWeaponId: null,
ownerWeaponName: null, ownerWeaponName: null,
tiers: ['I', 'II'].map((tier) => ({ tier, description: String(item.descriptionTemplate || 'PENDING TALENT CONFIGURATION') })) tierIIDocumented: item.category !== 'Exotic Weapon' || String(item.tierIIDocumented ?? '') === '1',
tiers: (item.category === 'Exotic Weapon' && String(item.tierIIDocumented ?? '') !== '1' ? ['I'] : ['I', 'II'])
.map((tier) => ({ tier, description: String(item.descriptionTemplate || 'PENDING TALENT CONFIGURATION') }))
})); }));
const configuredTalents = await Promise.all(talents.map(async (talent) => ({ const configuredTalents = await Promise.all(talents.map(async (talent) => {
const talentTiers = tiers.filter((tier) => tier.talent_id === talent.id);
const weaponScope = weaponScopes.find((scope) => scope.talent_id === talent.id)?.scope;
const isExoticTalent = talent.category === 'Weapon' && weaponScope === 'Exotic';
const tierIIDocumented = !isExoticTalent || talentTiers.some((tier) => tier.tier === 'II');
return {
...talent, ...talent,
iconUrl: (() => { const icon = icons.find((item) => item.entity_id === talent.id); return icon ? gameDataIconUrl('talent', String(talent.id), icon.updated_at) : ''; })(), iconUrl: (() => { const icon = icons.find((item) => item.entity_id === talent.id); return icon ? gameDataIconUrl('talent', String(talent.id), icon.updated_at) : ''; })(),
category: talent.category === 'Vest' category: talent.category === 'Vest'
@@ -109,14 +116,21 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
? 'Weapon · Slot Unassigned' ? 'Weapon · Slot Unassigned'
: talent.category, : talent.category,
descriptionTemplate: templates.find((item) => item.talent_id === talent.id)?.template ?? '', descriptionTemplate: templates.find((item) => item.talent_id === talent.id)?.template ?? '',
conditionalRule: await loadConditionalRuleDraft(db, 'talent', String(talent.id), ['I', 'II']), conditionalRule: await loadConditionalRuleDraft(
db,
'talent',
String(talent.id),
tierIIDocumented ? ['I', 'II'] : ['I']
),
ownerWeaponId: exoticOwners.find((weapon) => weapon.exotic_talent_id === talent.id)?.id ?? null, ownerWeaponId: exoticOwners.find((weapon) => weapon.exotic_talent_id === talent.id)?.id ?? null,
ownerWeaponName: exoticOwners.find((weapon) => weapon.exotic_talent_id === talent.id)?.name ?? null, ownerWeaponName: exoticOwners.find((weapon) => weapon.exotic_talent_id === talent.id)?.name ?? null,
tiers: tiers.filter((tier) => tier.talent_id === talent.id).map((tier) => ({ tierIIDocumented,
tiers: talentTiers.map((tier) => ({
...tier, ...tier,
effects: effects.filter((effect) => effect.talent_tier_id === tier.id) effects: effects.filter((effect) => effect.talent_tier_id === tier.id)
})) }))
}))); };
}));
return { return {
talents: [...pendingTalents, ...configuredTalents], talents: [...pendingTalents, ...configuredTalents],
stats: [...pendingStats, ...stats], stats: [...pendingStats, ...stats],
@@ -161,7 +175,7 @@ const renderDescription = (
const effect = effects.find((item) => item.slot === Number(expression)); const effect = effects.find((item) => item.slot === Number(expression));
return effect return effect
? displayValue( ? displayValue(
tier === 'I' ? effect.valueI : effect.valueII, tier === 'I' ? effect.valueI : Number(effect.valueII),
effect.statId ? units.get(effect.statId) : undefined effect.statId ? units.get(effect.statId) : undefined
) )
: placeholder; : placeholder;
@@ -186,16 +200,20 @@ export const actions: Actions = {
throw error; throw error;
} }
const name = String(form.get('name') ?? '').trim(), category = String(form.get('category') ?? ''); const name = String(form.get('name') ?? '').trim(), category = String(form.get('category') ?? '');
const isExotic = category === 'Exotic Weapon';
const tierIIDocumented = !isExotic || String(form.get('tierIIDocumented') ?? '') === '1';
const descriptionTemplate = String(form.get('descriptionTemplate') ?? '').trim(); const descriptionTemplate = String(form.get('descriptionTemplate') ?? '').trim();
const db = await getDatabase(); const db = await getDatabase();
const conditionalRuleRaw = form.get('conditionalRule'); const conditionalRuleRaw = form.get('conditionalRule');
let conditionalRule; let conditionalRule;
try { try {
const activeStats = await (await db.prepare('SELECT id FROM stat_attributes WHERE active=1')).all(); const activeStats = await (await db.prepare('SELECT id FROM stat_attributes WHERE active=1')).all();
conditionalRule = parseConditionalRulePayload(conditionalRuleRaw, [ conditionalRule = parseConditionalRulePayload(conditionalRuleRaw, tierIIDocumented
{ key: 'tierI', variantKey: 'I' }, ? [
{ key: 'tierII', variantKey: 'II' } { key: 'tierI', variantKey: 'I' },
], { { key: 'tierII', variantKey: 'II' }
]
: [{ key: 'tierI', variantKey: 'I' }], {
allowedAttributeIds: new Set(activeStats.map((row) => String(row.id))), allowedAttributeIds: new Set(activeStats.map((row) => String(row.id))),
allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY)) allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY))
}); });
@@ -206,32 +224,57 @@ export const actions: Actions = {
}]); }]);
} }
const cooldownSeconds = conditionalRule.clauses.find((clause) => clause.cooldownSeconds)?.cooldownSeconds ?? null; const cooldownSeconds = conditionalRule.clauses.find((clause) => clause.cooldownSeconds)?.cooldownSeconds ?? null;
const durationSeconds = conditionalRule.pools.find((pool) => pool.expiresAfterSeconds)?.expiresAfterSeconds ?? null; const durationForTier = (tier: 'I' | 'II') => {
const maxStacks = conditionalRule.pools.length const pool = conditionalRule.pools.find((item) =>
? Math.max(...conditionalRule.pools.map((pool) => pool.max ?? 1)) item.expiresAfterSeconds !== undefined
|| item.expiresAfterSecondsVariants?.[tier] !== undefined);
return pool?.expiresAfterSecondsVariants?.[tier] ?? pool?.expiresAfterSeconds ?? null;
};
const durationSeconds = durationForTier('I');
const stackPools = conditionalRule.pools.filter((pool) => pool.stateKind === 'stack');
const maxStacks = stackPools.length
? Math.max(...stackPools.map((pool) => pool.max ?? 1))
: null; : null;
const descriptionOutcomes = numberedTalentOutcomes(conditionalRule); const descriptionOutcomes = numberedTalentOutcomes(conditionalRule);
const sharedEffects: SharedEffect[] = descriptionOutcomes const sharedEffects: SharedEffect[] = descriptionOutcomes
.map((effect) => ({ .map((effect) => ({
slot: Number(effect.descriptionSlot), slot: Number(effect.descriptionSlot),
...(effect.kind === 'attribute_modifier' && effect.attributeId ? { statId: effect.attributeId } : {}), ...(effect.attributeId ? { statId: effect.attributeId } : {}),
valueI: effect.variants.I.value, valueI: effect.variants.I.value,
valueII: effect.variants.II.value ...(tierIIDocumented ? { valueII: effect.variants.II.value } : {})
})); }));
const validationErrors: TalentValidationError[] = []; const validationErrors: TalentValidationError[] = [];
const requiresCompleteMechanic = conditionalRule.simulationReadiness !== 'captured_only'; const requiresCompleteMechanic = isExotic || conditionalRule.simulationReadiness !== 'captured_only';
if (name.length < 2 || !['Weapon Talent 1', 'Weapon Talent 2', 'Backpack', 'Body Armor'].includes(category)) { if (name.length < 2 || !['Weapon Talent 1', 'Weapon Talent 2', 'Exotic Weapon', 'Backpack', 'Body Armor'].includes(category)) {
validationErrors.push({ area: 'identity', message: 'Provide a valid talent name and category. Exotic talents are managed from their owning weapon.' }); validationErrors.push({ area: 'identity', message: 'Provide a valid talent name and category.' });
} }
if (!descriptionTemplate) validationErrors.push({ area: 'description', message: 'Enter a shared description template.' }); if (!descriptionTemplate) validationErrors.push({ area: 'description', message: 'Enter a shared description template.' });
if (requiresCompleteMechanic && !conditionalRule.clauses.length) { if (requiresCompleteMechanic && !conditionalRule.clauses.length) {
validationErrors.push({ area: 'mechanic', message: 'Add at least one mechanic branch or mark the talent CAPTURE ONLY.' }); validationErrors.push({
area: 'mechanic',
message: isExotic
? 'Exotic talents require at least one modeled Tier I mechanic branch.'
: 'Add at least one mechanic branch or mark the talent CAPTURE ONLY.'
});
} }
if (requiresCompleteMechanic && !sharedEffects.length) { if (requiresCompleteMechanic && !sharedEffects.length) {
validationErrors.push({ area: 'outcomes', message: 'Add at least one numbered value outcome or mark the talent CAPTURE ONLY.' }); validationErrors.push({
area: 'outcomes',
message: isExotic
? 'Exotic talents require at least one numbered Tier I value outcome.'
: 'Add at least one numbered value outcome or mark the talent CAPTURE ONLY.'
});
} }
if (sharedEffects.some((effect) => !Number.isFinite(effect.valueI) || !Number.isFinite(effect.valueII))) { if (sharedEffects.some((effect) =>
validationErrors.push({ area: 'outcomes', message: 'Every numbered outcome needs valid Tier I and Tier II values.' }); !Number.isFinite(effect.valueI)
|| (tierIIDocumented && !Number.isFinite(effect.valueII))
)) {
validationErrors.push({
area: 'outcomes',
message: tierIIDocumented
? 'Every numbered outcome needs valid Tier I and Tier II values.'
: 'Every numbered outcome needs a valid Tier I value.'
});
} }
const missingSlots = missingTalentDescriptionSlots(descriptionTemplate, descriptionOutcomes); const missingSlots = missingTalentDescriptionSlots(descriptionTemplate, descriptionOutcomes);
if (requiresCompleteMechanic && missingSlots.length) { if (requiresCompleteMechanic && missingSlots.length) {
@@ -241,7 +284,11 @@ export const actions: Actions = {
}); });
validationErrors.push({ area: 'outcomes', message: 'Add or renumber outcomes to match every numbered description placeholder.' }); validationErrors.push({ area: 'outcomes', message: 'Add or renumber outcomes to match every numbered description placeholder.' });
} }
if (requiresCompleteMechanic && descriptionTemplate.includes('{duration}') && durationSeconds === null) { if (
requiresCompleteMechanic
&& descriptionTemplate.includes('{duration}')
&& (durationForTier('I') === null || (tierIIDocumented && durationForTier('II') === null))
) {
validationErrors.push({ area: 'description', message: 'The description uses {duration}, but no duration is defined.' }); validationErrors.push({ area: 'description', message: 'The description uses {duration}, but no duration is defined.' });
validationErrors.push({ area: 'mechanic', message: 'Define a duration for the {duration} description placeholder.' }); validationErrors.push({ area: 'mechanic', message: 'Define a duration for the {duration} description placeholder.' });
} }
@@ -249,8 +296,21 @@ export const actions: Actions = {
validationErrors.push({ area: 'description', message: 'The description uses {max_stacks}, but no maximum stack count is defined.' }); validationErrors.push({ area: 'description', message: 'The description uses {max_stacks}, but no maximum stack count is defined.' });
validationErrors.push({ area: 'mechanic', message: 'Define a pool maximum for the {max_stacks} description placeholder.' }); validationErrors.push({ area: 'mechanic', message: 'Define a pool maximum for the {max_stacks} description placeholder.' });
} }
const ownerWeapon = id
? await (await db.prepare('SELECT id,name FROM weapons WHERE exotic_talent_id=?')).get(id)
: null;
if (ownerWeapon && !isExotic) {
validationErrors.push({
area: 'identity',
message: `This talent is owned by ${String(ownerWeapon.name)} and must remain an Exotic Weapon talent.`
});
}
if (validationErrors.length) return reject(400, validationErrors); if (validationErrors.length) return reject(400, validationErrors);
const storedCategory = category === 'Body Armor' ? 'Vest' : category.startsWith('Weapon Talent') ? 'Weapon' : category; const storedCategory = category === 'Body Armor'
? 'Vest'
: category.startsWith('Weapon Talent') || isExotic
? 'Weapon'
: category;
if (await (await db.prepare('SELECT id FROM talents WHERE name=? COLLATE NOCASE AND category=? AND id<>?')).get(name, storedCategory, id || 'new')) { if (await (await db.prepare('SELECT id FROM talents WHERE name=? COLLATE NOCASE AND category=? AND id<>?')).get(name, storedCategory, id || 'new')) {
return reject(409, [{ area: 'identity', message: 'That talent already exists in this category.' }]); return reject(409, [{ area: 'identity', message: 'That talent already exists in this category.' }]);
} }
@@ -270,6 +330,9 @@ export const actions: Actions = {
icon: await gameDataIconMetadata(db, 'talent', id), icon: await gameDataIconMetadata(db, 'talent', id),
template: await (await db.prepare('SELECT template FROM talent_description_templates WHERE talent_id=?')).get(id), template: await (await db.prepare('SELECT template FROM talent_description_templates WHERE talent_id=?')).get(id),
tiers: await (await db.prepare('SELECT tier,description FROM talent_tiers WHERE talent_id=? ORDER BY tier')).all(id), tiers: await (await db.prepare('SELECT tier,description FROM talent_tiers WHERE talent_id=? ORDER BY tier')).all(id),
weaponScope: await (await db.prepare('SELECT scope FROM talent_weapon_scopes WHERE talent_id=?')).get(id),
weaponSlot: await (await db.prepare('SELECT slot FROM weapon_talent_slots WHERE talent_id=?')).get(id),
ownerWeapon,
conditional: await conditionalRuleSnapshot(db, 'talent', id) conditional: await conditionalRuleSnapshot(db, 'talent', id)
} : null; } : null;
await db.exec('BEGIN'); await db.exec('BEGIN');
@@ -279,25 +342,38 @@ export const actions: Actions = {
if (storedCategory === 'Weapon') { if (storedCategory === 'Weapon') {
await (await db.prepare(`INSERT INTO talent_weapon_scopes(talent_id,scope) VALUES(?,?) await (await db.prepare(`INSERT INTO talent_weapon_scopes(talent_id,scope) VALUES(?,?)
ON CONFLICT(talent_id) DO UPDATE SET scope=excluded.scope`)) ON CONFLICT(talent_id) DO UPDATE SET scope=excluded.scope`))
.run(talentId, 'Standard'); .run(talentId, isExotic ? 'Exotic' : 'Standard');
await (await db.prepare(`INSERT INTO weapon_talent_slots(talent_id,slot) VALUES(?,?) if (isExotic) {
ON CONFLICT(talent_id) DO UPDATE SET slot=excluded.slot`)) await (await db.prepare('DELETE FROM weapon_talent_slots WHERE talent_id=?')).run(talentId);
.run(talentId, category === 'Weapon Talent 1' ? 1 : 2); } else {
await (await db.prepare(`INSERT INTO weapon_talent_slots(talent_id,slot) VALUES(?,?)
ON CONFLICT(talent_id) DO UPDATE SET slot=excluded.slot`))
.run(talentId, category === 'Weapon Talent 1' ? 1 : 2);
}
} else { } else {
await (await db.prepare('DELETE FROM talent_weapon_scopes WHERE talent_id=?')).run(talentId); await (await db.prepare('DELETE FROM talent_weapon_scopes WHERE talent_id=?')).run(talentId);
await (await db.prepare('DELETE FROM weapon_talent_slots WHERE talent_id=?')).run(talentId); await (await db.prepare('DELETE FROM weapon_talent_slots WHERE talent_id=?')).run(talentId);
} }
await (await db.prepare(`INSERT INTO talent_description_templates(talent_id,template,updated_at) VALUES(?,?,?) await (await db.prepare(`INSERT INTO talent_description_templates(talent_id,template,updated_at) VALUES(?,?,?)
ON CONFLICT(talent_id) DO UPDATE SET template=excluded.template,updated_at=excluded.updated_at`)).run(talentId, descriptionTemplate, now); ON CONFLICT(talent_id) DO UPDATE SET template=excluded.template,updated_at=excluded.updated_at`)).run(talentId, descriptionTemplate, now);
for (const tier of ['I', 'II'] as const) { if (!tierIIDocumented) {
const description = renderDescription(descriptionTemplate, sharedEffects, units, tier, durationSeconds, maxStacks); await (await db.prepare("DELETE FROM talent_tiers WHERE talent_id=? AND tier='II'")).run(talentId);
}
for (const tier of (tierIIDocumented ? ['I', 'II'] : ['I']) as Array<'I' | 'II'>) {
const description = renderDescription(descriptionTemplate, sharedEffects, units, tier, durationForTier(tier), maxStacks);
await (await db.prepare(`INSERT INTO talent_tiers(id,talent_id,tier,description,trigger_text,effect_text,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?) await (await db.prepare(`INSERT INTO talent_tiers(id,talent_id,tier,description,trigger_text,effect_text,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)
ON CONFLICT(talent_id,tier) DO UPDATE SET description=excluded.description,updated_at=excluded.updated_at`)).run(randomUUID(), talentId, tier, description, '', '', now, now); ON CONFLICT(talent_id,tier) DO UPDATE SET description=excluded.description,updated_at=excluded.updated_at`)).run(randomUUID(), talentId, tier, description, '', '', now, now);
const tierRow = await (await db.prepare('SELECT id FROM talent_tiers WHERE talent_id=? AND tier=?')).get(talentId, tier); const tierRow = await (await db.prepare('SELECT id FROM talent_tiers WHERE talent_id=? AND tier=?')).get(talentId, tier);
const tierId = String(tierRow?.id); const tierId = String(tierRow?.id);
await (await db.prepare('DELETE FROM talent_tier_effects WHERE talent_tier_id=?')).run(tierId); await (await db.prepare('DELETE FROM talent_tier_effects WHERE talent_tier_id=?')).run(tierId);
const storedLegacyAttributes = new Set<string>();
for (const outcome of descriptionOutcomes) { for (const outcome of descriptionOutcomes) {
if (outcome.kind !== 'attribute_modifier' || !outcome.attributeId) continue; if (outcome.kind !== 'attribute_modifier' || !outcome.attributeId) continue;
// Conditional mechanics can apply the same attribute in several
// mutually exclusive branches. The legacy projection has one row
// per attribute and is not the runtime source for modeled talents.
if (storedLegacyAttributes.has(outcome.attributeId)) continue;
storedLegacyAttributes.add(outcome.attributeId);
await (await db.prepare('INSERT INTO talent_tier_effects(id,talent_tier_id,stat_attribute_id,value,created_at) VALUES(?,?,?,?,?)')).run( await (await db.prepare('INSERT INTO talent_tier_effects(id,talent_tier_id,stat_attribute_id,value,created_at) VALUES(?,?,?,?,?)')).run(
randomUUID(), randomUUID(),
tierId, tierId,
@@ -319,6 +395,11 @@ export const actions: Actions = {
record: await auditRecord('talents', talentId), record: await auditRecord('talents', talentId),
icon: await gameDataIconMetadata(db, 'talent', talentId), icon: await gameDataIconMetadata(db, 'talent', talentId),
category, category,
tierIIDocumented,
weaponScope: await (await db.prepare('SELECT scope FROM talent_weapon_scopes WHERE talent_id=?')).get(talentId),
weaponSlot: await (await db.prepare('SELECT slot FROM weapon_talent_slots WHERE talent_id=?')).get(talentId),
ownerWeapon: await (await db.prepare('SELECT id,name FROM weapons WHERE exotic_talent_id=?')).get(talentId),
tiers: await (await db.prepare('SELECT tier,description FROM talent_tiers WHERE talent_id=? ORDER BY tier')).all(talentId),
descriptionTemplate, descriptionTemplate,
conditional: await conditionalRuleSnapshot(db, 'talent', talentId) conditional: await conditionalRuleSnapshot(db, 'talent', talentId)
} }
+67 -9
View File
@@ -11,7 +11,11 @@
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor'; import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
import { tick } from 'svelte'; import { tick } from 'svelte';
let { data, form } = $props(); let { data, form } = $props();
const categoryOptions = ['Weapon Talent 1', 'Weapon Talent 2', 'Backpack', 'Body Armor'].map((value) => ({ value, label: value })); const categoryOptions = ['Weapon Talent 1', 'Weapon Talent 2', 'Exotic Weapon', 'Backpack', 'Body Armor'].map((value) => ({ value, label: value }));
const tierIIDocumentationOptions = [
{ value: 'documented', label: 'DOCUMENTED' },
{ value: 'not_documented', label: 'NOT YET DOCUMENTED' }
];
let talent = $state<any>(null); let talent = $state<any>(null);
let handledResultToken = $state(''); let handledResultToken = $state('');
let iconResetToken = $state(0); let iconResetToken = $state(0);
@@ -44,7 +48,7 @@
pools: [], pools: [],
clauses: [] clauses: []
}); });
const blankTalent = () => ({ id: '', name: '', iconUrl: '', category: 'Weapon Talent 1', descriptionTemplate: '', tierIDescription: '', tierIIDescription: '', conditionalRule: blankRule(), modelingOpen: false }); const blankTalent = () => ({ id: '', name: '', iconUrl: '', category: 'Weapon Talent 1', descriptionTemplate: '', tierIDescription: '', tierIIDescription: '', tierIIDocumented: true, tierIIDocumentation: 'documented', conditionalRule: blankRule(), modelingOpen: false });
const editTalent = (row: any) => { const editTalent = (row: any) => {
const tier = (name: string) => row.tiers.find((item: any) => item.tier === name) ?? { description: '', effects: [] }; const tier = (name: string) => row.tiers.find((item: any) => item.tier === name) ?? { description: '', effects: [] };
const one = tier('I'), two = tier('II'); const one = tier('I'), two = tier('II');
@@ -53,10 +57,51 @@
descriptionTemplate: row.descriptionTemplate || one.description, descriptionTemplate: row.descriptionTemplate || one.description,
tierIDescription: one.description, tierIDescription: one.description,
tierIIDescription: two.description, tierIIDescription: two.description,
tierIIDocumented: row.category !== 'Exotic Weapon' || Boolean(row.tierIIDocumented),
tierIIDocumentation: row.category === 'Exotic Weapon' && !row.tierIIDocumented ? 'not_documented' : 'documented',
conditionalRule: row.conditionalRule ?? blankRule(), conditionalRule: row.conditionalRule ?? blankRule(),
modelingOpen: Boolean(row.conditionalRule?.clauses?.length) modelingOpen: Boolean(row.conditionalRule?.clauses?.length)
}; };
}; };
const setTierIIDocumented = (documented: boolean) => {
if (!talent) return;
talent.tierIIDocumented = documented;
talent.tierIIDocumentation = documented ? 'documented' : 'not_documented';
talent.conditionalRule = {
...talent.conditionalRule,
pools: talent.conditionalRule.pools.map((pool: any) => {
if (!pool.expiresAfterSecondsVariants) return pool;
const tierI = pool.expiresAfterSecondsVariants.I;
return {
...pool,
expiresAfterSecondsVariants: documented
? { ...pool.expiresAfterSecondsVariants, I: tierI, II: pool.expiresAfterSecondsVariants.II ?? tierI }
: { I: tierI }
};
}),
clauses: talent.conditionalRule.clauses.map((clause: any) => ({
...clause,
outcomes: clause.outcomes.map((outcome: any) => ({
...outcome,
variants: documented
? {
...outcome.variants,
I: outcome.variants.I,
II: structuredClone(outcome.variants.II ?? outcome.variants.I ?? { value: 0 })
}
: { I: outcome.variants.I }
}))
}))
};
};
const setCategory = (category: string) => {
if (!talent) return;
talent.category = category;
if (category !== 'Exotic Weapon' && !talent.tierIIDocumented) setTierIIDocumented(true);
};
const valueColumns = () => talent?.category === 'Exotic Weapon' && !talent.tierIIDocumented
? [{ key: 'tierI', variantKey: 'I', label: 'TIER I' }]
: [{ key: 'tierI', variantKey: 'I', label: 'TIER I' }, { key: 'tierII', variantKey: 'II', label: 'TIER II' }];
const configured = (tiers: Array<{ tier: unknown }>, name: string) => tiers.some((item) => item.tier === name); const configured = (tiers: Array<{ tier: unknown }>, name: string) => tiers.some((item) => item.tier === name);
const tierDescription = (tiers: Array<{ tier: unknown; description?: unknown }>, name: string) => { const tierDescription = (tiers: Array<{ tier: unknown; description?: unknown }>, name: string) => {
const description = tiers.find((item) => item.tier === name)?.description; const description = tiers.find((item) => item.tier === name)?.description;
@@ -70,16 +115,22 @@
}; };
const mechanicValue = (value: string | number, singular: string) => `${value} ${Number(value) === 1 ? singular : `${singular}s`}`; const mechanicValue = (value: string | number, singular: string) => `${value} ${Number(value) === 1 ? singular : `${singular}s`}`;
const cooldownLine = (value: unknown) => value === null || value === undefined || value === '' ? '' : `Talent Cooldown: ${mechanicValue(value as string | number, 'second')}`; const cooldownLine = (value: unknown) => value === null || value === undefined || value === '' ? '' : `Talent Cooldown: ${mechanicValue(value as string | number, 'second')}`;
const preview = (tier: 'I' | 'II') => talent.descriptionTemplate.replace(/\{(\d+|duration|max_stacks)\}/g, (placeholder: string, expression: string) => { const preview = (tier: 'I' | 'II') => {
const duration = talent.conditionalRule.pools.find((pool: any) => pool.expiresAfterSeconds)?.expiresAfterSeconds; if (tier === 'II' && talent.category === 'Exotic Weapon' && !talent.tierIIDocumented) return 'NOT YET DOCUMENTED';
const maxStacks = talent.conditionalRule.pools.length ? Math.max(...talent.conditionalRule.pools.map((pool: any) => pool.max ?? 1)) : null; return talent.descriptionTemplate.replace(/\{(\d+|duration|max_stacks)\}/g, (placeholder: string, expression: string) => {
const durationPool = talent.conditionalRule.pools.find((pool: any) =>
pool.expiresAfterSeconds !== undefined || pool.expiresAfterSecondsVariants?.[tier] !== undefined);
const duration = durationPool?.expiresAfterSecondsVariants?.[tier] ?? durationPool?.expiresAfterSeconds;
const stackPools = talent.conditionalRule.pools.filter((pool: any) => pool.stateKind === 'stack');
const maxStacks = stackPools.length ? Math.max(...stackPools.map((pool: any) => pool.max ?? 1)) : null;
if (expression === 'duration') return duration == null ? placeholder : mechanicValue(duration, 'second'); if (expression === 'duration') return duration == null ? placeholder : mechanicValue(duration, 'second');
if (expression === 'max_stacks') return maxStacks == null ? placeholder : mechanicValue(maxStacks, 'stack'); if (expression === 'max_stacks') return maxStacks == null ? placeholder : mechanicValue(maxStacks, 'stack');
const effect = talent.conditionalRule.clauses const effect = talent.conditionalRule.clauses
.flatMap((clause: any) => clause.outcomes) .flatMap((clause: any) => clause.outcomes)
.find((outcome: any) => outcome.descriptionSlot === Number(expression)); .find((outcome: any) => outcome.descriptionSlot === Number(expression));
return effect ? displayValue(effect, tier) : placeholder; return effect ? displayValue(effect, tier) : placeholder;
}); });
};
$effect(() => { $effect(() => {
if (!form?.resultToken || form.resultToken === handledResultToken) return; if (!form?.resultToken || form.resultToken === handledResultToken) return;
handledResultToken = form.resultToken; handledResultToken = form.resultToken;
@@ -103,6 +154,7 @@
<div class="editor-title"><div><p class="eyebrow">{talent.id ? 'MODIFY TALENT' : 'NEW TALENT'}</p><h3>{talent.id ? talent.name : 'TALENT DEFINITION'}</h3></div><button aria-label="Close talent editor" onclick={() => talent = null}>×</button></div> <div class="editor-title"><div><p class="eyebrow">{talent.id ? 'MODIFY TALENT' : 'NEW TALENT'}</p><h3>{talent.id ? talent.name : 'TALENT DEFINITION'}</h3></div><button aria-label="Close talent editor" onclick={() => talent = null}>×</button></div>
<PendingForm action="?/saveTalent" enctype="multipart/form-data"> <PendingForm action="?/saveTalent" enctype="multipart/form-data">
<input type="hidden" name="id" value={talent.id}/><input type="hidden" name="conditionalRule" value={JSON.stringify(talent.conditionalRule)}/> <input type="hidden" name="id" value={talent.id}/><input type="hidden" name="conditionalRule" value={JSON.stringify(talent.conditionalRule)}/>
<input type="hidden" name="tierIIDocumented" value={talent.tierIIDocumented ? '1' : '0'}/>
{#if talentErrors.length} {#if talentErrors.length}
<div class="validation-summary" role="alert" tabindex="-1" bind:this={validationSummary}> <div class="validation-summary" role="alert" tabindex="-1" bind:this={validationSummary}>
<strong>CHECK {new Set(talentErrors.map((error) => error.area)).size} HIGHLIGHTED {new Set(talentErrors.map((error) => error.area)).size === 1 ? 'AREA' : 'AREAS'}</strong> <strong>CHECK {new Set(talentErrors.map((error) => error.area)).size} HIGHLIGHTED {new Set(talentErrors.map((error) => error.area)).size === 1 ? 'AREA' : 'AREAS'}</strong>
@@ -120,10 +172,16 @@
</div> </div>
<div id="talent-identity" class="talent-identity" class:validation-area-error={hasErrors(['identity'])}> <div id="talent-identity" class="talent-identity" class:validation-area-error={hasErrors(['identity'])}>
<label class="field">NAME<input name="name" bind:value={talent.name} required aria-invalid={hasErrors(['identity']) ? 'true' : undefined}/></label> <label class="field">NAME<input name="name" bind:value={talent.name} required aria-invalid={hasErrors(['identity']) ? 'true' : undefined}/></label>
<SegmentedChoice legend="CATEGORY / SLOT" name="category" options={categoryOptions} bind:value={talent.category} required/> <SegmentedChoice legend="CATEGORY / SLOT" name="category" options={categoryOptions} bind:value={talent.category} onchange={(values) => setCategory(values[0])} required/>
{#each errorsFor('identity') as error}<small class="field-error">{error.message}</small>{/each} {#each errorsFor('identity') as error}<small class="field-error">{error.message}</small>{/each}
</div> </div>
</div> </div>
{#if talent.category === 'Exotic Weapon'}
<div class="form-grid">
<SegmentedChoice legend="TIER II DOCUMENTATION" name="tierIIDocumentation" options={tierIIDocumentationOptions} bind:value={talent.tierIIDocumentation} onchange={(values) => setTierIIDocumented(values[0] === 'documented')} required/>
<p class="console-note">{talent.tierIIDocumented ? 'Tier I and Tier II values will be modeled and saved.' : 'Tier I is required. Tier II will remain absent until its game values are documented.'}</p>
</div>
{/if}
<label id="talent-description" class="field template-field" class:validation-area-error={hasErrors(['description'])}>SHARED DESCRIPTION TEMPLATE<textarea name="descriptionTemplate" bind:value={talent.descriptionTemplate} placeholder={'Hitting an enemy {1} times grants +{2} for {duration}...'} required aria-invalid={hasErrors(['description']) ? 'true' : undefined}></textarea><small>Use {'{1}'}, {'{2}'}, and so on for effect values. Use {'{duration}'} and {'{max_stacks}'} for defined mechanics. Cooldown is displayed separately.</small>{#each errorsFor('description') as error}<small class="field-error">{error.message}</small>{/each}</label> <label id="talent-description" class="field template-field" class:validation-area-error={hasErrors(['description'])}>SHARED DESCRIPTION TEMPLATE<textarea name="descriptionTemplate" bind:value={talent.descriptionTemplate} placeholder={'Hitting an enemy {1} times grants +{2} for {duration}...'} required aria-invalid={hasErrors(['description']) ? 'true' : undefined}></textarea><small>Use {'{1}'}, {'{2}'}, and so on for effect values. Use {'{duration}'} and {'{max_stacks}'} for defined mechanics. Cooldown is displayed separately.</small>{#each errorsFor('description') as error}<small class="field-error">{error.message}</small>{/each}</label>
<div class="tier-preview"><div><span>TIER I PREVIEW</span><p>{preview('I') || 'Description preview'}</p></div><div><span>TIER II PREVIEW</span><p>{preview('II') || 'Description preview'}</p></div></div> <div class="tier-preview"><div><span>TIER I PREVIEW</span><p>{preview('I') || 'Description preview'}</p></div><div><span>TIER II PREVIEW</span><p>{preview('II') || 'Description preview'}</p></div></div>
<details id="talent-mechanic" class="modeling-disclosure" class:validation-area-error={hasErrors(['mechanic', 'outcomes'])} bind:open={talent.modelingOpen}> <details id="talent-mechanic" class="modeling-disclosure" class:validation-area-error={hasErrors(['mechanic', 'outcomes'])} bind:open={talent.modelingOpen}>
@@ -134,7 +192,7 @@
<div class="modeling-content"> <div class="modeling-content">
<MechanicProgramEditor <MechanicProgramEditor
attributes={data.stats} attributes={data.stats}
valueColumns={[{key:'tierI',variantKey:'I',label:'TIER I'},{key:'tierII',variantKey:'II',label:'TIER II'}]} valueColumns={valueColumns()}
idPrefix={`talent-${talent.id || 'new'}`} idPrefix={`talent-${talent.id || 'new'}`}
aiContext={{ aiContext={{
sourceKind: 'talent', sourceKind: 'talent',
@@ -157,4 +215,4 @@
{/if} {/if}
<RegistryPagination pagination={data.pagination} search={data.search}/> <RegistryPagination pagination={data.pagination} search={data.search}/>
<div class="dashboard-table talent-dashboard-table"><div class="dashboard-row talent-row table-heading"><SortableColumnHeader label="TALENT" column="name" sort={data.sort}/><SortableColumnHeader label="CATEGORY" column="category" sort={data.sort}/><SortableColumnHeader label="TIER I EFFECT" column="tier1" sort={data.sort}/><SortableColumnHeader label="TIER II EFFECT" column="tier2" sort={data.sort}/><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>{#each data.talents as item}<div class="dashboard-row talent-row" class:pending-record={item.pending} class:registry-row-editing={String(talent?.id ?? '') === String(item.id)}><div class="registry-icon-identity"><ItemIcon src={item.iconUrl} alt=""/><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : item.category === 'Exotic Weapon' ? `OWNED BY ${item.ownerWeaponName ?? 'UNASSIGNED WEAPON'}` : 'Tiered talent'}</small>{#if cooldownLine(item.cooldown_seconds)}<small class="talent-cooldown">{cooldownLine(item.cooldown_seconds)}</small>{/if}</div></div><span>{item.category}</span><p class="talent-description" class:unconfigured={!configured(item.tiers, 'I')}>{tierDescription(item.tiers, 'I')}</p><p class="talent-description" class:unconfigured={!configured(item.tiers, 'II')}>{tierDescription(item.tiers, 'II')}</p><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : item.conditionalRule?.clauses?.length ? 'MODELED' : 'CONFIG REQUIRED'}</b>{#if item.pending}<span></span>{:else if item.category === 'Exotic Weapon'}{#if item.ownerWeaponId}<a class="row-action" href={`/isac/weapons?weapon=${item.ownerWeaponId}`}>EDIT WEAPON</a>{:else}<span></span>{/if}{:else}<button aria-expanded={String(talent?.id ?? '') === String(item.id)} aria-controls={`talent-editor-${item.id}`} onclick={() => editTalent(item)}>EDIT</button>{/if}</div>{#if String(talent?.id ?? '') === String(item.id)}<RegistryInlineEditor id={`talent-editor-${item.id}`} label={`Edit ${item.name}`}>{@render talentEditor()}</RegistryInlineEditor>{/if}{/each}</div> <div class="dashboard-table talent-dashboard-table"><div class="dashboard-row talent-row table-heading"><SortableColumnHeader label="TALENT" column="name" sort={data.sort}/><SortableColumnHeader label="CATEGORY" column="category" sort={data.sort}/><SortableColumnHeader label="TIER I EFFECT" column="tier1" sort={data.sort}/><SortableColumnHeader label="TIER II EFFECT" column="tier2" sort={data.sort}/><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>{#each data.talents as item}<div class="dashboard-row talent-row" class:pending-record={item.pending} class:registry-row-editing={String(talent?.id ?? '') === String(item.id)}><div class="registry-icon-identity"><ItemIcon src={item.iconUrl} alt=""/><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : item.category === 'Exotic Weapon' ? `OWNED BY ${item.ownerWeaponName ?? 'UNASSIGNED WEAPON'}` : 'Tiered talent'}</small>{#if cooldownLine(item.cooldown_seconds)}<small class="talent-cooldown">{cooldownLine(item.cooldown_seconds)}</small>{/if}</div></div><span>{item.category}</span><p class="talent-description" class:unconfigured={!configured(item.tiers, 'I')}>{tierDescription(item.tiers, 'I')}</p><p class="talent-description" class:unconfigured={!configured(item.tiers, 'II')}>{item.category === 'Exotic Weapon' && !item.tierIIDocumented ? 'NOT YET DOCUMENTED' : tierDescription(item.tiers, 'II')}</p><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : item.conditionalRule?.clauses?.length ? 'MODELED' : 'CONFIG REQUIRED'}</b>{#if item.pending}<span></span>{:else}<button aria-expanded={String(talent?.id ?? '') === String(item.id)} aria-controls={`talent-editor-${item.id}`} onclick={() => editTalent(item)}>EDIT</button>{/if}</div>{#if String(talent?.id ?? '') === String(item.id)}<RegistryInlineEditor id={`talent-editor-${item.id}`} label={`Edit ${item.name}`}>{@render talentEditor()}</RegistryInlineEditor>{/if}{/each}</div>
+50 -161
View File
@@ -6,15 +6,8 @@ import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { getPagination, getRegistrySearch, getRegistrySort } from '$lib/server/pagination'; import { getPagination, getRegistrySearch, getRegistrySort } from '$lib/server/pagination';
import { auditRecord, recordIsacAudit } from '$lib/server/audit'; import { auditRecord, recordIsacAudit } from '$lib/server/audit';
import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconValidationError, readIconMutation } from '$lib/server/game-data-icons'; import { gameDataIconUrl } from '$lib/server/game-data-icons';
import { getPendingEntities } from '$lib/server/data-moderation'; import { getPendingEntities } from '$lib/server/data-moderation';
import {
conditionalRuleSnapshot,
loadConditionalRuleDraft,
parseConditionalRulePayload,
saveConditionalRuleInTransaction
} from '$lib/server/conditional-effects';
import { MECHANIC_RECIPE_REGISTRY } from '$lib/calculations/mechanic-recipes';
import { WEAPON_CATEGORIES } from '$lib/weapons/category-bonuses'; import { WEAPON_CATEGORIES } from '$lib/weapons/category-bonuses';
import { normalizeWeaponModSlotPayload, validateWeaponModSlots, WEAPON_MOD_SLOT_TYPES } from '$lib/weapons/mod-slots'; import { normalizeWeaponModSlotPayload, validateWeaponModSlots, WEAPON_MOD_SLOT_TYPES } from '$lib/weapons/mod-slots';
@@ -22,26 +15,6 @@ const categories: readonly string[] = WEAPON_CATEGORIES;
const damageTypes = ['Blast', 'Pierce', 'Shredding']; const damageTypes = ['Blast', 'Pierce', 'Shredding'];
const rangeNames = ['damage'] as const; const rangeNames = ['damage'] as const;
const modSlotTypes = WEAPON_MOD_SLOT_TYPES; const modSlotTypes = WEAPON_MOD_SLOT_TYPES;
type ExoticEffect = { statId: string | null; valueI: number; valueII: number };
const mechanicValue = (value: number, singular: string) => `${value} ${value === 1 ? singular : `${singular}s`}`;
const renderTalentDescription = (template: string, effects: ExoticEffect[], units: Map<string, unknown>, tier: 'I' | 'II', duration: number | null, maxStacks: number | null) =>
template.replace(/\{(\d+|duration|max_stacks)\}/g, (placeholder, expression) => {
if (expression === 'duration') return duration === null ? placeholder : mechanicValue(duration, 'second');
if (expression === 'max_stacks') return maxStacks === null ? placeholder : mechanicValue(maxStacks, 'stack');
const effect = effects[Number(expression) - 1];
if (!effect) return placeholder;
const value = tier === 'I' ? effect.valueI : effect.valueII;
const unitValue = effect.statId ? units.get(effect.statId) : '';
const unit = unitValue === 'flat' ? '' : String(unitValue ?? '');
return unit === 'Shots' ? `${value} ${unit}` : `${value}${unit}`;
});
const mechanicOutcomes = (rule: ReturnType<typeof parseConditionalRulePayload>) =>
rule.clauses.flatMap((clause) => clause.outcomes);
const descriptionOutcomes = (rule: ReturnType<typeof parseConditionalRulePayload>) =>
mechanicOutcomes(rule).filter((outcome) => outcome.descriptionSlot)
.sort((left, right) => Number(left.descriptionSlot) - Number(right.descriptionSlot));
async function bonusProfileSnapshot(category: string) { async function bonusProfileSnapshot(category: string) {
const database = await getDatabase(); const database = await getDatabase();
@@ -87,7 +60,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const whereArgs = [...(requestedWeaponId ? [requestedWeaponId] : []), ...(!requestedWeaponId && search ? [pattern] : [])]; const whereArgs = [...(requestedWeaponId ? [requestedWeaponId] : []), ...(!requestedWeaponId && search ? [pattern] : [])];
const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM weapons ${where}`)).get(...whereArgs); const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM weapons ${where}`)).get(...whereArgs);
const pagination = getPagination(url, Number(filtered?.total ?? 0)); const pagination = getPagination(url, Number(filtered?.total ?? 0));
const [weapons, tierRanges, modSlots, talentOptions, augmentEffects, categoryBonuses, talent1Options, talent2Options, stats, exoticTemplates, talentIcons, pending] = await Promise.all([ const [weapons, tierRanges, modSlots, talentOptions, augmentEffects, categoryBonuses, talent1Options, talent2Options, exoticTalentOptions, stats, talentIcons, pending] = await Promise.all([
(await database.prepare(`SELECT * FROM weapons ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`)).all(...whereArgs, pagination.perPage, pagination.offset), (await database.prepare(`SELECT * FROM weapons ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`)).all(...whereArgs, pagination.perPage, pagination.offset),
(await database.prepare('SELECT * FROM weapon_tier_ranges ORDER BY tier')).all(), (await database.prepare('SELECT * FROM weapon_tier_ranges ORDER BY tier')).all(),
(await database.prepare('SELECT * FROM weapon_mod_slots ORDER BY tier,slot_type')).all(), (await database.prepare('SELECT * FROM weapon_mod_slots ORDER BY tier,slot_type')).all(),
@@ -107,15 +80,19 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
JOIN weapon_talent_slots ON weapon_talent_slots.talent_id=talents.id JOIN weapon_talent_slots ON weapon_talent_slots.talent_id=talents.id
WHERE talents.active=1 AND talents.category='Weapon' AND talent_weapon_scopes.scope='Standard' AND weapon_talent_slots.slot=2 WHERE talents.active=1 AND talents.category='Weapon' AND talent_weapon_scopes.scope='Standard' AND weapon_talent_slots.slot=2
ORDER BY talents.name COLLATE NOCASE`)).all(), ORDER BY talents.name COLLATE NOCASE`)).all(),
(await database.prepare(`SELECT talents.id,talents.name,weapons.id AS owner_weapon_id,weapons.name AS owner_weapon_name,
EXISTS(SELECT 1 FROM talent_tiers WHERE talent_tiers.talent_id=talents.id AND talent_tiers.tier='II') AS tier_ii_documented
FROM talents
JOIN talent_weapon_scopes ON talent_weapon_scopes.talent_id=talents.id
LEFT JOIN weapons ON weapons.exotic_talent_id=talents.id
WHERE talents.active=1 AND talents.category='Weapon' AND talent_weapon_scopes.scope='Exotic'
ORDER BY talents.name COLLATE NOCASE`)).all(),
(await database.prepare("SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE")).all(), (await database.prepare("SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE")).all(),
(await database.prepare(`SELECT talent_description_templates.talent_id,talent_description_templates.template,talents.name,
talents.cooldown_seconds,talents.duration_seconds,talents.max_stacks
FROM talent_description_templates JOIN talents ON talents.id=talent_description_templates.talent_id`)).all(),
(await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='talent'")).all(), (await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='talent'")).all(),
getPendingEntities(user.id, [ getPendingEntities(user.id, [
{ module: 'attributes', action: 'save', kind: 'attribute', fields: ['unit'] }, { module: 'attributes', action: 'save', kind: 'attribute', fields: ['unit'] },
{ module: 'talents', action: 'saveTalent', kind: 'talent', fields: ['category'] }, { module: 'talents', action: 'saveTalent', kind: 'talent', fields: ['category'] },
{ module: 'weapons', action: 'save', kind: 'weapon', fields: ['category', 'nativeTier', 'damageType', 'isExotic', 'rpm', 'magazine', 'reloadSpeed', 'accuracy', 'stability', 'talent1Ids', 'talent2Ids', 'modSlots', 'exoticTalentName', 'exoticDescriptionTemplate', 'exoticConditionalRule'] } { module: 'weapons', action: 'save', kind: 'weapon', fields: ['category', 'nativeTier', 'damageType', 'isExotic', 'rpm', 'magazine', 'reloadSpeed', 'accuracy', 'stability', 'talent1Ids', 'talent2Ids', 'modSlots', 'exoticTalentId'] }
]) ])
]); ]);
const pendingTalents = pending.filter((item) => item.kind === 'talent'); const pendingTalents = pending.filter((item) => item.kind === 'talent');
@@ -141,13 +118,13 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
native_tier: Number(weapon.nativeTier), native_tier: Number(weapon.nativeTier),
damage_type: weapon.damageType, damage_type: weapon.damageType,
is_exotic: weapon.isExotic ? 1 : 0, is_exotic: weapon.isExotic ? 1 : 0,
exotic_talent_id: String(weapon.exoticTalentId ?? ''),
reload_speed: weapon.reloadSpeed, reload_speed: weapon.reloadSpeed,
tiers: [], tiers: [],
modSlots: (() => { try { return JSON.parse(String(weapon.modSlots ?? '[]')); } catch { return []; } })(), modSlots: (() => { try { return JSON.parse(String(weapon.modSlots ?? '[]')); } catch { return []; } })(),
talent1Ids: Array.isArray(weapon.talent1Ids) ? weapon.talent1Ids : [weapon.talent1Ids].filter(Boolean), talent1Ids: Array.isArray(weapon.talent1Ids) ? weapon.talent1Ids : [weapon.talent1Ids].filter(Boolean),
talent2Ids: Array.isArray(weapon.talent2Ids) ? weapon.talent2Ids : [weapon.talent2Ids].filter(Boolean), talent2Ids: Array.isArray(weapon.talent2Ids) ? weapon.talent2Ids : [weapon.talent2Ids].filter(Boolean),
augmentEffects: [], augmentEffects: []
exoticTalent: null
})).sort((left, right) => { })).sort((left, right) => {
const valueFor = (weapon: any) => sort.key === 'class' ? `${weapon.category}:${weapon.native_tier}` const valueFor = (weapon: any) => sort.key === 'class' ? `${weapon.category}:${weapon.native_tier}`
: sort.key === 'fixed' ? Number(weapon.rpm ?? 0) : sort.key === 'fixed' ? Number(weapon.rpm ?? 0)
@@ -162,34 +139,32 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
: String(leftValue).localeCompare(String(rightValue), undefined, { sensitivity: 'base', numeric: true }); : String(leftValue).localeCompare(String(rightValue), undefined, { sensitivity: 'base', numeric: true });
return (sort.direction === 'asc' ? result : -result) || String(left.name).localeCompare(String(right.name), undefined, { sensitivity: 'base' }); return (sort.direction === 'asc' ? result : -result) || String(left.name).localeCompare(String(right.name), undefined, { sensitivity: 'base' });
}); });
const configuredWeapons = await Promise.all(weapons.map(async (weapon) => { const configuredWeapons = weapons.map((weapon) => ({
const talentId = typeof weapon.exotic_talent_id === 'string' ? weapon.exotic_talent_id : '';
const conditionalRule = talentId
? await loadConditionalRuleDraft(database, 'talent', talentId, ['I', 'II'])
: null;
return {
...weapon, ...weapon,
tiers: tierRanges.filter((range) => range.weapon_id === weapon.id), tiers: tierRanges.filter((range) => range.weapon_id === weapon.id),
modSlots: [0,1,2].map((tier) => ({ tier, slots: modSlots.filter((slot) => slot.weapon_id === weapon.id && Number(slot.tier) === tier).map((slot) => String(slot.slot_type)) })), modSlots: [0,1,2].map((tier) => ({ tier, slots: modSlots.filter((slot) => slot.weapon_id === weapon.id && Number(slot.tier) === tier).map((slot) => String(slot.slot_type)) })),
talent1Ids: talentOptions.filter((option) => option.weapon_id === weapon.id && Number(option.talent_slot) === 1).map((option) => String(option.talent_id)), talent1Ids: talentOptions.filter((option) => option.weapon_id === weapon.id && Number(option.talent_slot) === 1).map((option) => String(option.talent_id)),
talent2Ids: talentOptions.filter((option) => option.weapon_id === weapon.id && Number(option.talent_slot) === 2).map((option) => String(option.talent_id)), talent2Ids: talentOptions.filter((option) => option.weapon_id === weapon.id && Number(option.talent_slot) === 2).map((option) => String(option.talent_id)),
augmentEffects: augmentEffects.filter((effect) => effect.weapon_id === weapon.id), augmentEffects: augmentEffects.filter((effect) => effect.weapon_id === weapon.id),
baseBonuses: categoryBonuses.filter((effect) => effect.weapon_category === weapon.category), baseBonuses: categoryBonuses.filter((effect) => effect.weapon_category === weapon.category)
exoticTalent: talentId ? { }));
id: talentId,
iconUrl: (() => { const icon = talentIcons.find((item) => item.entity_id === talentId); return icon ? gameDataIconUrl('talent', talentId, icon.updated_at) : ''; })(),
name: String(exoticTemplates.find((item) => item.talent_id === talentId)?.name ?? ''),
cooldown_seconds: exoticTemplates.find((item) => item.talent_id === talentId)?.cooldown_seconds ?? '',
duration_seconds: exoticTemplates.find((item) => item.talent_id === talentId)?.duration_seconds ?? '',
max_stacks: exoticTemplates.find((item) => item.talent_id === talentId)?.max_stacks ?? '',
descriptionTemplate: String(exoticTemplates.find((item) => item.talent_id === talentId)?.template ?? ''),
conditionalRule
} : null
}}));
return { return {
weapons: [...pendingWeapons, ...configuredWeapons], weapons: [...pendingWeapons, ...configuredWeapons],
talent1Options: [...pendingTalents.filter((item) => item.category === 'Weapon Talent 1'), ...talent1Options].map(talentOptionWithIcon), talent1Options: [...pendingTalents.filter((item) => item.category === 'Weapon Talent 1'), ...talent1Options].map(talentOptionWithIcon),
talent2Options: [...pendingTalents.filter((item) => item.category === 'Weapon Talent 2'), ...talent2Options].map(talentOptionWithIcon), talent2Options: [...pendingTalents.filter((item) => item.category === 'Weapon Talent 2'), ...talent2Options].map(talentOptionWithIcon),
exoticTalentOptions: exoticTalentOptions.map((talent) => {
const talentId = String(talent.id);
const icon = talentIcons.find((item) => String(item.entity_id) === talentId);
return {
id: talentId,
name: String(talent.name),
ownerWeaponId: talent.owner_weapon_id ? String(talent.owner_weapon_id) : '',
ownerWeaponName: talent.owner_weapon_name ? String(talent.owner_weapon_name) : '',
tierIIDocumented: Boolean(talent.tier_ii_documented),
showIcon: true,
iconUrl: icon ? gameDataIconUrl('talent', talentId, icon.updated_at) : ''
};
}),
stats: [...pending.filter((item) => item.kind === 'attribute'), ...stats], stats: [...pending.filter((item) => item.kind === 'attribute'), ...stats],
summary: { ...summary, total: Number(summary?.total ?? 0) + pending.filter((item) => item.kind === 'weapon').length, pending: pending.filter((item) => item.kind === 'weapon').length }, summary: { ...summary, total: Number(summary?.total ?? 0) + pending.filter((item) => item.kind === 'weapon').length, pending: pending.filter((item) => item.kind === 'weapon').length },
pagination, pagination,
@@ -257,9 +232,6 @@ export const actions: Actions = {
save: async ({ request, cookies }) => { save: async ({ request, cookies }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage'); const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const form = await request.formData(); const form = await request.formData();
let exoticIconMutation;
try { exoticIconMutation = await readIconMutation(form); }
catch (error) { if (error instanceof GameDataIconValidationError) return fail(400, { weaponError: error.message }); throw error; }
const id = String(form.get('id') ?? ''); const id = String(form.get('id') ?? '');
const name = String(form.get('name') ?? '').normalize('NFKC').trim(); const name = String(form.get('name') ?? '').normalize('NFKC').trim();
const category = String(form.get('category') ?? ''); const category = String(form.get('category') ?? '');
@@ -280,38 +252,8 @@ export const actions: Actions = {
return normalizeWeaponModSlotPayload(JSON.parse(String(form.get('modSlots') ?? '[]'))); return normalizeWeaponModSlotPayload(JSON.parse(String(form.get('modSlots') ?? '[]')));
} catch { return []; } } catch { return []; }
})(); })();
const existingExoticTalentId = isExotic ? String(form.get('exoticTalentId') ?? '') : ''; const exoticTalentId = isExotic ? String(form.get('exoticTalentId') ?? '') : '';
const exoticTalentName = isExotic ? String(form.get('exoticTalentName') ?? '').normalize('NFKC').trim() : '';
const exoticDescriptionTemplate = isExotic ? String(form.get('exoticDescriptionTemplate') ?? '').trim() : '';
const database = await getDatabase(); const database = await getDatabase();
let exoticConditionalRule = null;
if (isExotic) {
const rawRule = form.get('exoticConditionalRule');
try {
const activeStats = await (await database.prepare('SELECT id FROM stat_attributes WHERE active=1')).all();
exoticConditionalRule = parseConditionalRulePayload(rawRule, [
{ key: 'tierI', variantKey: 'I' },
{ key: 'tierII', variantKey: 'II' }
], {
allowedAttributeIds: new Set(activeStats.map((row) => String(row.id))),
allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY))
});
} catch (error) {
return fail(400, { weaponError: error instanceof Error ? error.message : 'The exotic talent mechanic is invalid.' });
}
}
const exoticCooldownSeconds = exoticConditionalRule?.clauses.find((clause) => clause.cooldownSeconds)?.cooldownSeconds ?? null;
const exoticDurationSeconds = exoticConditionalRule?.pools.find((pool) => pool.expiresAfterSeconds)?.expiresAfterSeconds ?? null;
const exoticMaxStacks = exoticConditionalRule?.pools.length
? Math.max(...exoticConditionalRule.pools.map((pool) => pool.max ?? 1))
: null;
const exoticEffects: ExoticEffect[] = exoticConditionalRule ? descriptionOutcomes(exoticConditionalRule)
.filter((outcome) => outcome.kind === 'attribute_modifier')
.map((outcome) => ({
statId: outcome.attributeId ?? null,
valueI: outcome.variants.I.value,
valueII: outcome.variants.II.value
})) : [];
if (name.length < 2 || name.length > 80) return fail(400, { weaponError: 'Weapon names must be 2–80 characters.' }); if (name.length < 2 || name.length > 80) return fail(400, { weaponError: 'Weapon names must be 2–80 characters.' });
if (!categories.includes(category) || !damageTypes.includes(damageType) || !Number.isInteger(nativeTier) || nativeTier < 0 || nativeTier > 2) { if (!categories.includes(category) || !damageTypes.includes(damageType) || !Number.isInteger(nativeTier) || nativeTier < 0 || nativeTier > 2) {
@@ -329,15 +271,7 @@ export const actions: Actions = {
} }
const modSlotError = validateWeaponModSlots(nativeTier, modSlots); const modSlotError = validateWeaponModSlots(nativeTier, modSlots);
if (modSlotError) return fail(400, { weaponError: modSlotError }); if (modSlotError) return fail(400, { weaponError: modSlotError });
if (isExotic && (exoticTalentName.length < 2 || !exoticDescriptionTemplate || !exoticConditionalRule)) { if (isExotic && !exoticTalentId) return fail(400, { weaponError: 'Select a registered exotic talent for this weapon.' });
return fail(400, { weaponError: 'Exotic weapons require a named talent, description template, and modeled conditional mechanic.' });
}
if (isExotic) {
const placeholders = [...exoticDescriptionTemplate.matchAll(/\{(\d+)\}/g)].map((match) => Number(match[1]));
if (placeholders.some((index) => index < 1 || index > exoticEffects.length)) return fail(400, { weaponError: 'The exotic talent description references a missing effect.' });
if (exoticDescriptionTemplate.includes('{duration}') && exoticDurationSeconds === null) return fail(400, { weaponError: 'The exotic talent description uses {duration}, but no duration is defined.' });
if (exoticDescriptionTemplate.includes('{max_stacks}') && exoticMaxStacks === null) return fail(400, { weaponError: 'The exotic talent description uses {max_stacks}, but no maximum stack count is defined.' });
}
const tiers = []; const tiers = [];
try { try {
@@ -374,22 +308,23 @@ export const actions: Actions = {
AND weapon_talent_slots.slot=? AND talents.id IN (${ids.map(() => '?').join(',')})`)).get(slot, ...ids); AND weapon_talent_slots.slot=? AND talents.id IN (${ids.map(() => '?').join(',')})`)).get(slot, ...ids);
if (Number(validTalents?.count) !== ids.length) return fail(400, { weaponError: `One or more selected Talent ${slot} options are unavailable or assigned to the wrong slot.` }); if (Number(validTalents?.count) !== ids.length) return fail(400, { weaponError: `One or more selected Talent ${slot} options are unavailable or assigned to the wrong slot.` });
} }
const statIds = [...new Set([ const statIds = [...new Set(augmentEffects.map((effect) => effect.statId))];
...augmentEffects.map((effect) => effect.statId),
...exoticEffects.map((effect) => effect.statId).filter((value): value is string => Boolean(value))
])];
const validStats = await (await database.prepare(`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${statIds.map(() => '?').join(',')})`)).get(...statIds); const validStats = await (await database.prepare(`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${statIds.map(() => '?').join(',')})`)).get(...statIds);
if (Number(validStats?.count) !== statIds.length) return fail(400, { weaponError: 'One or more tactical augmentation attributes are unavailable.' }); if (Number(validStats?.count) !== statIds.length) return fail(400, { weaponError: 'One or more tactical augmentation attributes are unavailable.' });
if (isExotic) { const selectedExoticTalent = isExotic
if (existingExoticTalentId) { ? await (await database.prepare(`SELECT talents.id,talents.name,weapons.id AS owner_weapon_id,
const owned = id ? await (await database.prepare('SELECT id FROM weapons WHERE id=? AND exotic_talent_id=?')).get(id, existingExoticTalentId) : null; EXISTS(SELECT 1 FROM talent_tiers WHERE talent_tiers.talent_id=talents.id AND talent_tiers.tier='II') AS tier_ii_documented
if (!owned) return fail(400, { weaponError: 'The submitted exotic talent is not owned by this weapon.' }); FROM talents
const shared = await (await database.prepare('SELECT id FROM weapons WHERE exotic_talent_id=? AND id<>?')).get(existingExoticTalentId, id || 'new'); JOIN talent_weapon_scopes ON talent_weapon_scopes.talent_id=talents.id
if (shared) return fail(409, { weaponError: 'That exotic talent is already owned by another weapon and cannot be shared.' }); LEFT JOIN weapons ON weapons.exotic_talent_id=talents.id
} WHERE talents.id=? AND talents.active=1 AND talents.category='Weapon'
if (await (await database.prepare("SELECT id FROM talents WHERE name=? COLLATE NOCASE AND category='Weapon' AND id<>?")).get(exoticTalentName, existingExoticTalentId || 'new')) { AND talent_weapon_scopes.scope='Exotic'`)).get(exoticTalentId)
return fail(409, { weaponError: 'An exotic talent with that name already exists.' }); : null;
} if (isExotic && !selectedExoticTalent) {
return fail(400, { weaponError: 'The selected exotic talent is unavailable or is not classified for exotic weapons.' });
}
if (selectedExoticTalent?.owner_weapon_id && String(selectedExoticTalent.owner_weapon_id) !== id) {
return fail(409, { weaponError: 'That exotic talent is already assigned to another weapon.' });
} }
if (id && !await (await database.prepare('SELECT id FROM weapons WHERE id=?')).get(id)) { if (id && !await (await database.prepare('SELECT id FROM weapons WHERE id=?')).get(id)) {
return fail(404, { weaponError: 'Weapon not found.' }); return fail(404, { weaponError: 'Weapon not found.' });
@@ -405,71 +340,25 @@ export const actions: Actions = {
const weaponId = id || randomUUID(); const weaponId = id || randomUUID();
const now = new Date().toISOString(); const now = new Date().toISOString();
const exoticTalentId = isExotic ? existingExoticTalentId || randomUUID() : null;
const before = id ? { const before = id ? {
record: await auditRecord('weapons', id), record: await auditRecord('weapons', id),
tiers: await (await database.prepare('SELECT * FROM weapon_tier_ranges WHERE weapon_id=? ORDER BY tier')).all(id), tiers: await (await database.prepare('SELECT * FROM weapon_tier_ranges WHERE weapon_id=? ORDER BY tier')).all(id),
talents: await (await database.prepare('SELECT * FROM weapon_talent_options WHERE weapon_id=? ORDER BY talent_slot')).all(id), talents: await (await database.prepare('SELECT * FROM weapon_talent_options WHERE weapon_id=? ORDER BY talent_slot')).all(id),
augmentEffects: await (await database.prepare('SELECT * FROM weapon_augmentation_effects WHERE weapon_id=? ORDER BY sort_order')).all(id), augmentEffects: await (await database.prepare('SELECT * FROM weapon_augmentation_effects WHERE weapon_id=? ORDER BY sort_order')).all(id),
modSlots: await (await database.prepare('SELECT * FROM weapon_mod_slots WHERE weapon_id=? ORDER BY tier,slot_type')).all(id), modSlots: await (await database.prepare('SELECT * FROM weapon_mod_slots WHERE weapon_id=? ORDER BY tier,slot_type')).all(id)
exoticTalent: existingExoticTalentId ? {
record: await (await database.prepare('SELECT * FROM talents WHERE id=?')).get(existingExoticTalentId),
icon: await gameDataIconMetadata(database, 'talent', existingExoticTalentId),
template: await (await database.prepare('SELECT template FROM talent_description_templates WHERE talent_id=?')).get(existingExoticTalentId),
tiers: await (await database.prepare('SELECT * FROM talent_tiers WHERE talent_id=? ORDER BY tier')).all(existingExoticTalentId),
conditional: await conditionalRuleSnapshot(database, 'talent', existingExoticTalentId)
} : null
} : null; } : null;
await database.exec('BEGIN'); await database.exec('BEGIN');
try { try {
if (isExotic && exoticTalentId) {
if (existingExoticTalentId) {
await (await database.prepare("UPDATE talents SET name=?,category='Weapon',cooldown_seconds=?,duration_seconds=?,max_stacks=?,active=1,updated_at=? WHERE id=?")).run(exoticTalentName, exoticCooldownSeconds, exoticDurationSeconds, exoticMaxStacks, now, exoticTalentId);
} else {
await (await database.prepare("INSERT INTO talents(id,name,category,cooldown_seconds,duration_seconds,max_stacks,active,created_at,updated_at) VALUES(?,?,'Weapon',?,?,?,1,?,?)")).run(exoticTalentId, exoticTalentName, exoticCooldownSeconds, exoticDurationSeconds, exoticMaxStacks, now, now);
}
await (await database.prepare(`INSERT INTO talent_weapon_scopes(talent_id,scope) VALUES(?,'Exotic')
ON CONFLICT(talent_id) DO UPDATE SET scope='Exotic'`)).run(exoticTalentId);
await (await database.prepare('DELETE FROM weapon_talent_slots WHERE talent_id=?')).run(exoticTalentId);
await (await database.prepare(`INSERT INTO talent_description_templates(talent_id,template,updated_at) VALUES(?,?,?)
ON CONFLICT(talent_id) DO UPDATE SET template=excluded.template,updated_at=excluded.updated_at`)).run(exoticTalentId, exoticDescriptionTemplate, now);
const effectStatIds = exoticEffects.map((effect) => effect.statId).filter((value): value is string => Boolean(value));
const unitRows = effectStatIds.length
? await (await database.prepare(`SELECT id,unit FROM stat_attributes WHERE id IN (${effectStatIds.map(() => '?').join(',')})`)).all(...effectStatIds)
: [];
const units = new Map(unitRows.map((row) => [String(row.id), row.unit]));
for (const talentTier of ['I', 'II'] as const) {
const description = renderTalentDescription(exoticDescriptionTemplate, exoticEffects, units, talentTier, exoticDurationSeconds, exoticMaxStacks);
await (await database.prepare(`INSERT INTO talent_tiers(id,talent_id,tier,description,trigger_text,effect_text,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(talent_id,tier) DO UPDATE SET description=excluded.description,updated_at=excluded.updated_at`))
.run(randomUUID(), exoticTalentId, talentTier, description, '', '', now, now);
const tierRow = await (await database.prepare('SELECT id FROM talent_tiers WHERE talent_id=? AND tier=?')).get(exoticTalentId, talentTier);
const tierId = String(tierRow?.id);
await (await database.prepare('DELETE FROM talent_tier_effects WHERE talent_tier_id=?')).run(tierId);
for (const effect of exoticEffects.filter((outcome) => outcome.statId)) {
await (await database.prepare('INSERT INTO talent_tier_effects(id,talent_tier_id,stat_attribute_id,value,created_at) VALUES(?,?,?,?,?)'))
.run(randomUUID(), tierId, effect.statId, talentTier === 'I' ? effect.valueI : effect.valueII, now);
}
}
await saveConditionalRuleInTransaction(database, 'talent', exoticTalentId, exoticConditionalRule!);
await applyIconMutation(database, 'talent', exoticTalentId, exoticIconMutation, operator.id);
}
if (id) { if (id) {
await (await database.prepare(`UPDATE weapons SET name=?,category=?,is_exotic=?,native_tier=?,damage_type=?,rpm=?,magazine=?, await (await database.prepare(`UPDATE weapons SET name=?,category=?,is_exotic=?,native_tier=?,damage_type=?,rpm=?,magazine=?,
reload_speed=?,accuracy=?,stability=?,exotic_talent_id=?,active=1,updated_at=? WHERE id=?`)) reload_speed=?,accuracy=?,stability=?,exotic_talent_id=?,active=1,updated_at=? WHERE id=?`))
.run(name, category, isExotic ? 1 : 0, nativeTier, damageType, fixed.rpm, fixed.magazine, fixed.reloadSpeed, fixed.accuracy, fixed.stability, exoticTalentId, now, id); .run(name, category, isExotic ? 1 : 0, nativeTier, damageType, fixed.rpm, fixed.magazine, fixed.reloadSpeed, fixed.accuracy, fixed.stability, isExotic ? exoticTalentId : null, now, id);
} else { } else {
await (await database.prepare(`INSERT INTO weapons(id,name,category,is_exotic,native_tier,damage_type,rpm,magazine,reload_speed, await (await database.prepare(`INSERT INTO weapons(id,name,category,is_exotic,native_tier,damage_type,rpm,magazine,reload_speed,
accuracy,stability,exotic_talent_id,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)) accuracy,stability,exotic_talent_id,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`))
.run(weaponId, name, category, isExotic ? 1 : 0, nativeTier, damageType, fixed.rpm, fixed.magazine, fixed.reloadSpeed, fixed.accuracy, fixed.stability, exoticTalentId, now, now); .run(weaponId, name, category, isExotic ? 1 : 0, nativeTier, damageType, fixed.rpm, fixed.magazine, fixed.reloadSpeed, fixed.accuracy, fixed.stability, isExotic ? exoticTalentId : null, now, now);
} }
const previousExoticTalentId = typeof before?.record?.exotic_talent_id === 'string' ? before.record.exotic_talent_id : ''; await (await database.prepare('DELETE FROM weapon_tier_ranges WHERE weapon_id=?')).run(weaponId);
if (!isExotic && previousExoticTalentId) {
const anotherOwner = await (await database.prepare('SELECT id FROM weapons WHERE exotic_talent_id=? AND id<>?')).get(previousExoticTalentId, weaponId);
if (!anotherOwner) await (await database.prepare('UPDATE talents SET active=0,updated_at=? WHERE id=?')).run(now, previousExoticTalentId);
}
await (await database.prepare('DELETE FROM weapon_tier_ranges WHERE weapon_id=?')).run(weaponId);
for (const tier of tiers) { for (const tier of tiers) {
const core = sharedCoreRanges.find((range) => Number(range.tier) === tier.tier); const core = sharedCoreRanges.find((range) => Number(range.tier) === tier.tier);
await (await database.prepare(`INSERT INTO weapon_tier_ranges(weapon_id,tier,damage_min,damage_max,firepower_min, await (await database.prepare(`INSERT INTO weapon_tier_ranges(weapon_id,tier,damage_min,damage_max,firepower_min,
@@ -488,7 +377,7 @@ export const actions: Actions = {
await (await database.prepare('INSERT INTO weapon_augmentation_effects(id,weapon_id,stat_attribute_id,value,sort_order) VALUES(?,?,?,?,?)')) await (await database.prepare('INSERT INTO weapon_augmentation_effects(id,weapon_id,stat_attribute_id,value,sort_order) VALUES(?,?,?,?,?)'))
.run(randomUUID(), weaponId, effect.statId, effect.value, effect.order); .run(randomUUID(), weaponId, effect.statId, effect.value, effect.order);
} }
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'weapon.updated' : 'weapon.created', entityId: weaponId, before, after: { record: await auditRecord('weapons', weaponId), tiers, modSlots, talent1Ids, talent2Ids, augmentEffects, exoticTalent: isExotic ? { id: exoticTalentId, name: exoticTalentName, icon: await gameDataIconMetadata(database, 'talent', String(exoticTalentId)), descriptionTemplate: exoticDescriptionTemplate, conditional: await conditionalRuleSnapshot(database, 'talent', String(exoticTalentId)) } : null } }); await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'weapon.updated' : 'weapon.created', entityId: weaponId, before, after: { record: await auditRecord('weapons', weaponId), tiers, modSlots, talent1Ids, talent2Ids, augmentEffects, exoticTalent: selectedExoticTalent ? { id: String(selectedExoticTalent.id), name: String(selectedExoticTalent.name), tierIIDocumented: Boolean(selectedExoticTalent.tier_ii_documented) } : null } });
await database.exec('COMMIT'); await database.exec('COMMIT');
} catch (error) { } catch (error) {
await database.exec('ROLLBACK'); await database.exec('ROLLBACK');
+30 -50
View File
@@ -7,10 +7,7 @@
import KpiFilters from '$lib/components/KpiFilters.svelte'; import KpiFilters from '$lib/components/KpiFilters.svelte';
import GameIcon from '$lib/components/GameIcon.svelte'; import GameIcon from '$lib/components/GameIcon.svelte';
import type { GameIconName } from '$lib/game-icons'; import type { GameIconName } from '$lib/game-icons';
import IconUploadField from '$lib/components/IconUploadField.svelte';
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte'; import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
import MechanicProgramEditor from '$lib/components/isac/MechanicProgramEditor.svelte';
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
import { WEAPON_CATEGORIES } from '$lib/weapons/category-bonuses'; import { WEAPON_CATEGORIES } from '$lib/weapons/category-bonuses';
import { import {
excludedTierZeroWeaponModSlot, excludedTierZeroWeaponModSlot,
@@ -26,16 +23,10 @@
const iconName = (value: string): GameIconName => value.toLowerCase() as GameIconName; const iconName = (value: string): GameIconName => value.toLowerCase() as GameIconName;
const damageTypeOptions = damageTypes.map((value) => ({ value, label: value, icon: iconName(value), iconOnly: true })); const damageTypeOptions = damageTypes.map((value) => ({ value, label: value, icon: iconName(value), iconOnly: true }));
const blankTier = (tier: number) => ({ tier, damage_min: 0, damage_max: 0 }); const blankTier = (tier: number) => ({ tier, damage_min: 0, damage_max: 0 });
const blankConditionalRule = (): ConditionalMechanicDraftV1 => ({ const blank = () => ({ id: '', name: '', category: 'AR', is_exotic: 0, native_tier: '0', damage_type: 'Pierce', rpm: 0, magazine: 0, reload_speed: 0, accuracy: 0, stability: 0, exotic_talent_id: '', active: 1, tiers: [blankTier(0), blankTier(1), blankTier(2)], modSlots: [0,1,2].map((tier) => ({ tier, slots: [] })), sharedModSlots: [], t0ExcludedSlot: '', talent1Ids: [], talent2Ids: [], augmentEffects: [{ stat_attribute_id: '', value: 0, sort_order: 1 }] });
version:1,simulationReadiness:'captured_only',scenarioInputs:[],pools:[],
clauses:[{key:'primary',label:'Primary',event:'weapon_hit',if:{kind:'true'},reducer:{kind:'none'},mutations:[],outcomes:[{key:'effect-1',label:'Effect 1',phase:'while_active',kind:'attribute_modifier',scope:'equipped_weapon',attributeId:'',basis:'flat',perStack:true,descriptionSlot:1,variants:{I:{value:0},II:{value:0}}}]}]
});
const blankExoticTalent = () => ({ id: '', name: '', iconUrl: '', descriptionTemplate: '', conditionalRule: blankConditionalRule() });
const blank = () => ({ id: '', name: '', category: 'AR', is_exotic: 0, native_tier: '0', damage_type: 'Pierce', rpm: 0, magazine: 0, reload_speed: 0, accuracy: 0, stability: 0, exotic_talent_id: '', exoticTalent: blankExoticTalent(), active: 1, tiers: [blankTier(0), blankTier(1), blankTier(2)], modSlots: [0,1,2].map((tier) => ({ tier, slots: [] })), sharedModSlots: [], t0ExcludedSlot: '', talent1Ids: [], talent2Ids: [], augmentEffects: [{ stat_attribute_id: '', value: 0, sort_order: 1 }] });
let editing = $state<any | null>(null); let editing = $state<any | null>(null);
let bonusEditing = $state<any | null>(null); let bonusEditing = $state<any | null>(null);
let handledResultToken = $state(''); let handledResultToken = $state('');
let iconResetToken = $state(0);
let openedRequestedWeapon = $state(false); let openedRequestedWeapon = $state(false);
const edit = (weapon: any) => { const edit = (weapon: any) => {
const tiers = [0, 1, 2].map((tier) => { const tiers = [0, 1, 2].map((tier) => {
@@ -44,7 +35,7 @@
}); });
const modSlots = [0,1,2].map((tier) => ({ tier, slots: [...(weapon.modSlots?.find((row: any) => Number(row.tier) === tier)?.slots ?? [])] })); const modSlots = [0,1,2].map((tier) => ({ tier, slots: [...(weapon.modSlots?.find((row: any) => Number(row.tier) === tier)?.slots ?? [])] }));
const sharedSlots = sharedWeaponModSlots(modSlots); const sharedSlots = sharedWeaponModSlots(modSlots);
editing = { ...weapon, native_tier: String(weapon.native_tier), tiers, modSlots, sharedModSlots: sharedSlots, t0ExcludedSlot: excludedTierZeroWeaponModSlot(sharedSlots, modSlots), talent1Ids: [...weapon.talent1Ids], talent2Ids: [...weapon.talent2Ids], augmentEffects: weapon.augmentEffects.map((effect: any) => ({ ...effect })), exoticTalent: weapon.exoticTalent ? { ...weapon.exoticTalent, conditionalRule: weapon.exoticTalent.conditionalRule ?? blankConditionalRule() } : blankExoticTalent() }; editing = { ...weapon, native_tier: String(weapon.native_tier), tiers, modSlots, sharedModSlots: sharedSlots, t0ExcludedSlot: excludedTierZeroWeaponModSlot(sharedSlots, modSlots), talent1Ids: [...weapon.talent1Ids], talent2Ids: [...weapon.talent2Ids], augmentEffects: weapon.augmentEffects.map((effect: any) => ({ ...effect })) };
bonusEditing = null; bonusEditing = null;
}; };
const editBonusProfile = (profile: any) => { const editBonusProfile = (profile: any) => {
@@ -70,25 +61,20 @@
const setExotic = (isExotic: boolean) => { const setExotic = (isExotic: boolean) => {
if (!editing) return; if (!editing) return;
editing.is_exotic = isExotic ? 1 : 0; editing.is_exotic = isExotic ? 1 : 0;
if (isExotic && !editing.exoticTalent) editing.exoticTalent = blankExoticTalent(); if (!isExotic) editing.exotic_talent_id = '';
}; };
const mechanicValue = (value: string | number, singular: string) => `${value} ${Number(value) === 1 ? singular : `${singular}s`}`; const exoticTalentOptions = () => data.exoticTalentOptions.map((talent: any) => {
const exoticOutcomeValue = (effect: any, tier: 'I' | 'II') => { const assignedElsewhere = Boolean(talent.ownerWeaponId && talent.ownerWeaponId !== editing?.id);
const value = effect?.variants?.[tier]?.value; return {
if (value === '' || value === null || value === undefined) return ''; ...talent,
if (effect.kind !== 'attribute_modifier') return String(value); detail: `${talent.ownerWeaponName
const stat = data.stats.find((item) => item.id === effect.attributeId); ? `ASSIGNED TO ${String(talent.ownerWeaponName).toLocaleUpperCase()}`
const unit = stat?.unit === 'flat' ? '' : stat?.unit ?? ''; : 'AVAILABLE FOR ASSIGNMENT'} · TIER II ${talent.tierIIDocumented ? 'DOCUMENTED' : 'NOT YET DOCUMENTED'}`,
return unit === 'Shots' ? `${value} ${unit}` : `${value}${unit}`; disabled: assignedElsewhere,
}; disabledReason: assignedElsewhere
const exoticPreview = (tier: 'I' | 'II') => editing.exoticTalent.descriptionTemplate.replace(/\{(\d+|duration|max_stacks)\}/g, (placeholder: string, expression: string) => { ? `Already assigned to ${talent.ownerWeaponName}`
const duration = editing.exoticTalent.conditionalRule.pools.find((pool: any) => pool.expiresAfterSeconds)?.expiresAfterSeconds; : ''
const maxStacks = editing.exoticTalent.conditionalRule.pools.length ? Math.max(...editing.exoticTalent.conditionalRule.pools.map((pool: any) => pool.max ?? 1)) : null; };
if (expression === 'duration') return duration == null ? placeholder : mechanicValue(duration, 'second');
if (expression === 'max_stacks') return maxStacks == null ? placeholder : mechanicValue(maxStacks, 'stack');
const available = editing.exoticTalent.conditionalRule.clauses.flatMap((clause: any) => clause.outcomes);
const value = exoticOutcomeValue(available.find((outcome: any) => outcome.descriptionSlot === Number(expression)), tier);
return value || placeholder;
}); });
const addAugmentEffect = () => { const addAugmentEffect = () => {
if (editing && editing.augmentEffects.length < 2) editing.augmentEffects = [...editing.augmentEffects, { stat_attribute_id: '', value: 0, sort_order: 2 }]; if (editing && editing.augmentEffects.length < 2) editing.augmentEffects = [...editing.augmentEffects, { stat_attribute_id: '', value: 0, sort_order: 2 }];
@@ -105,10 +91,9 @@
if (requested) edit(requested); if (requested) edit(requested);
openedRequestedWeapon = true; openedRequestedWeapon = true;
} }
if (form?.weaponSaved && form.resultToken && form.resultToken !== handledResultToken) { if (form?.weaponSaved && form.resultToken && form.resultToken !== handledResultToken) {
handledResultToken = form.resultToken; handledResultToken = form.resultToken;
if (form.weaponMode === 'add') iconResetToken += 1; editing = form.weaponMode === 'add' ? blank() : null;
editing = form.weaponMode === 'add' ? blank() : null;
} }
if (form?.weaponBonusProfileSaved && form.resultToken && form.resultToken !== handledResultToken) { if (form?.weaponBonusProfileSaved && form.resultToken && form.resultToken !== handledResultToken) {
handledResultToken = form.resultToken; handledResultToken = form.resultToken;
@@ -162,24 +147,19 @@
<label class="field exotic-toggle">EXOTIC STATUS<span title="Exotic weapon"><input name="isExotic" type="checkbox" value="1" checked={Number(editing.is_exotic) === 1} onchange={(event) => setExotic(event.currentTarget.checked)}/><GameIcon name="exotic" decorative/><b class="sr-only">EXOTIC WEAPON</b></span></label> <label class="field exotic-toggle">EXOTIC STATUS<span title="Exotic weapon"><input name="isExotic" type="checkbox" value="1" checked={Number(editing.is_exotic) === 1} onchange={(event) => setExotic(event.currentTarget.checked)}/><GameIcon name="exotic" decorative/><b class="sr-only">EXOTIC WEAPON</b></span></label>
</div> </div>
{#if Number(editing.is_exotic)} {#if Number(editing.is_exotic)}
<input type="hidden" name="exoticTalentId" value={editing.exoticTalent.id}/>
<input type="hidden" name="exoticConditionalRule" value={JSON.stringify(editing.exoticTalent.conditionalRule)}/>
<section class="weapon-exotic-talent"> <section class="weapon-exotic-talent">
<div class="selection-head"><div><span>DEDICATED EXOTIC TALENT</span><small>OWNED EXCLUSIVELY BY THIS WEAPON · TIER II VALUES REQUIRED</small></div><b>{editing.exoticTalent.id ? 'LINKED RECORD' : 'NEW RECORD'}</b></div> <div class="selection-head"><div><span>DEDICATED EXOTIC TALENT</span><small>SELECT ONE TALENT FROM THE EXOTIC WEAPON REGISTRY</small></div><b>{editing.exotic_talent_id ? '1 / 1 ASSIGNED' : 'REQUIRED'}</b></div>
<div class="exotic-icon-identity"><IconUploadField currentUrl={editing.exoticTalent.iconUrl} label="EXOTIC TALENT ICON" resetToken={iconResetToken}/><div class="form-grid exotic-talent-identity"><label class="field">TALENT NAME<input name="exoticTalentName" bind:value={editing.exoticTalent.name} maxlength="80" required/></label><label class="field">SHARED DESCRIPTION TEMPLATE<textarea name="exoticDescriptionTemplate" bind:value={editing.exoticTalent.descriptionTemplate} placeholder={'Hits grant +{1} for {duration}…'} required></textarea><small>Use numbered expressions for effect values and named expressions for duration and stacks.</small></label></div></div> <SearchableAssignment
<p class="talent-template-note">DESCRIPTION EXPRESSIONS · {'{duration}'} and {'{max_stacks}'} render the defined mechanics. Cooldown is displayed separately as a standardized Talent Cooldown line.</p> items={exoticTalentOptions()}
<div class="tier-preview"><div><span>TIER I PREVIEW</span><p>{exoticPreview('I') || 'Description preview'}</p></div><div><span>TIER II PREVIEW</span><p>{exoticPreview('II') || 'Description preview'}</p></div></div> fieldName="exoticTalentId"
<MechanicProgramEditor mode="single"
attributes={data.stats} subjectLabel="EXOTIC TALENT"
valueColumns={[{key:'tierI',variantKey:'I',label:'TIER I'},{key:'tierII',variantKey:'II',label:'TIER II'}]} searchPlaceholder="Search exotic talents…"
idPrefix={`weapon-exotic-${editing.exoticTalent.id || 'new'}`} emptySelectionLabel="NO EXOTIC TALENT ASSIGNED"
aiContext={{ bind:value={editing.exotic_talent_id}
sourceKind: 'exotic_weapon_talent',
sourceName: editing?.exoticTalent?.name ?? '',
sourceDescription: editing?.exoticTalent?.descriptionTemplate ?? ''
}}
bind:rule={editing.exoticTalent.conditionalRule}
/> />
<p class="weapon-mod-slot-note"><b>REFERENCE ONLY</b><span>Create and model exotic talents in the Talent registry. An exotic talent can belong to one weapon, and Tier II may remain not yet documented.</span></p>
{#if data.exoticTalentOptions.length === 0}<p class="error">Create an active Exotic Weapon talent in the Talent registry before saving this weapon.</p>{/if}
</section> </section>
{/if} {/if}
<div class="selection-head"><div><span>FIXED WEAPON ATTRIBUTES</span><small>UNCHANGED ACROSS TIERS</small></div><b>BASE HANDLING</b></div> <div class="selection-head"><div><span>FIXED WEAPON ATTRIBUTES</span><small>UNCHANGED ACROSS TIERS</small></div><b>BASE HANDLING</b></div>
@@ -224,7 +204,7 @@
{#if data.talent1Options.length === 0 || data.talent2Options.length === 0}<p class="error">Create and classify at least one active Talent 1 and Talent 2 weapon talent before adding weapons.</p>{/if} {#if data.talent1Options.length === 0 || data.talent2Options.length === 0}<p class="error">Create and classify at least one active Talent 1 and Talent 2 weapon talent before adding weapons.</p>{/if}
{#if data.stats.length === 0}<p class="error">Create an active attribute before configuring tactical augmentation 01.</p>{/if} {#if data.stats.length === 0}<p class="error">Create an active attribute before configuring tactical augmentation 01.</p>{/if}
{#if form?.weaponError}<p class="error">{form.weaponError}</p>{/if} {#if form?.weaponError}<p class="error">{form.weaponError}</p>{/if}
<div class="form-actions split-actions"><div>{#if editing.id}<button class="moderation-action danger weapon-archive-action" formaction="?/archive" name="id" value={editing.id}><b aria-hidden="true">⌁</b> ARCHIVE WEAPON</button>{/if}</div><div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary" disabled={data.talent1Options.length === 0 || data.talent2Options.length === 0 || data.stats.length === 0}>SAVE WEAPON →</button></div></div> <div class="form-actions split-actions"><div>{#if editing.id}<button class="moderation-action danger weapon-archive-action" formaction="?/archive" name="id" value={editing.id}><b aria-hidden="true">⌁</b> ARCHIVE WEAPON</button>{/if}</div><div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary" disabled={data.talent1Options.length === 0 || data.talent2Options.length === 0 || data.stats.length === 0 || (Number(editing.is_exotic) === 1 && !editing.exotic_talent_id)}>SAVE WEAPON →</button></div></div>
</PendingForm> </PendingForm>
</div> </div>
{/snippet} {/snippet}