# Action System

This is the most complex subsystem and the one we've already broken once. Read carefully before editing anything that touches actions.

## The three registration paths

`[verified]` from `Source/Scripts/SkyrimNetApi.psc:27-31` (Papyrus path), `SkyrimNet.log:14123-14266` (CustomActionManager loader), `CppAPI/PublicAPI.h:93-97` (C++ path), and on-disk YAML inspection.

### 1. Papyrus registration

A mod's Papyrus script calls:

```papyrus
SkyrimNetApi.RegisterAction(
    string actionName,             ; UPPERCASED in the registry - "OpenTrade" → "OPENTRADE"
    string actionDescription,
    string eligibilityScriptName,
    string eligibilityFunctionName,
    string executionScriptName,
    string executionFunctionName,
    string triggeringEventTypesCsv,
    string actionType,             ; "PAPYRUS" or "PAPYRUS_CUSTOM"
    int    priority,
    string parameterSchemaJson,
    string customCategory,
    string tagsCsv
)
```

The C++ DLL keeps a registry of these. When the LLM emits `ACTION: OPENTRADE PARAMS: {...}`, the DLL:
1. Looks up `OPENTRADE` in the registry.
2. Calls the eligibility function via Papyrus (`Action_IsEligible` style), waits up to `dialogue.eligibilityCheckTimeoutMs` (= 2500ms).
3. If eligible, calls the execution function with the parameters.

**Shipped Papyrus actions** (from `Source/Scripts/skynet_Library.psc:130-315`):
`OpenTrade`, `AccompanyTarget`, `StopAccompanying`, `WaitHere`, `Gesture` (with anim enum), `RentRoom`, `CompanionFollow`, `CompanionWait`, `CompanionInventory`, `CompanionGiveTask`.

`[verified]` `SkyrimNet.log:16393` shows: `ActionLibrary::RegisterPapyrusAction for action: OPENTRADE`.

### 2. YAML registration

A mod can drop YAML files into `SKSE/Plugins/SkyrimNet/config/actions/*.yaml` *in its own mod folder*. SkyrimNet's `CustomActionManager::Initialize` scans across all installed mods at load.

**YAML actions preserve case** in the registry (unlike Papyrus actions). So `OpenTrade` stays `OpenTrade`.

**Schema** - sample from `SeverActions - SkyrimNet Action Pack/.../config/actions/attacktarget.yaml:1-37`:
```yaml
customCategory: Combat
name: AttackTarget
description: ...
questEditorId: ...        # the Skyrim Quest holding the function
scriptName: ...           # Papyrus script attached to the quest
executionFunctionName: ...
parameterMapping:
  - parameterName: target
    decorator: get_nearby_actor   # or other decorator → Skyrim object
enabled: true
defaultPriority: 50
eligibilityRules:
  - decorator: is_in_combat
    operator: equals
    value: true
  # AND/OR combinations supported
```

**Contributing mods at last load** `[verified]` from `SkyrimNet.log`:
- **`SeverActions - SkyrimNet Action Pack`** - ~80 generic actions (Combat/Travel/Economy/etc.). The big one.
- **`IntelEngine`** - 15 actions (likely intelligence/observation-themed).
- **`OStimNet_v0.9.2`** - ~10 actions, prefixed `tton_*` (sex/affection - adult mod).

### 3. C++ registration

External SKSE plugins can call `PublicRegisterCPPAction()` from `CppAPI/PublicAPI.h:93-97` to register C++-implemented actions directly. Avoids the Papyrus VM round-trip; useful for performance-critical or low-level actions.

`[unknown]` whether any currently-loaded mod uses this path in this install.

---

## Action categories (the wrapper layer)

Actions can be grouped under categories defined in `cat_*.yaml` files. Categories let the LLM "intent-pick" without seeing all leaf actions.

**Known categories** `[verified]` from logs:
`Combat`, `Communication`, `Travel`, `Economy`, `Items`, `Magic`, `Outfit`, `Crafting`, `Scheduling`, `Command` (companion), `Arrest`.

**Two-stage flow:**
1. GM (or native action selector) emits `ACTION: Communication PARAMS: {"intent": "express gratitude..."}` - picks the *category* and states the intent in natural language.
2. C++ orchestrator fires a second LLM call using `prompts/native_action_selector_drilldown.prompt`, which sees only the leaf actions under that category plus the intent. Picks the specific leaf.

This pattern reduces cognitive load: instead of choosing among ~105 actions, the model picks one of ~10 categories then one of ~5-15 leaves.

---

## The `ACTION:` parser grammar

`[verified]` from log behavior at `ActionManager.cpp:1783` and `:1814`.

**Required form (start of a line):**
```
ACTION: ActionName
ACTION: ActionName PARAMS: {"param": "value", ...}
ACTION: None
```

**Strict requirements:**
- The literal **`ACTION: `** prefix (with the colon and space) at the start of a line.
- Action name must match a registered action **exactly** (case-sensitive, except Papyrus actions are uppercase in the registry).
- `PARAMS:` if present must be valid JSON.
- The action line must be on its own line, after any dialogue text (with `embed_actions_in_dialogue: true`).

**No leniency:**
- Bare action names (e.g. `OPENTRADE` alone) are NOT recognized. They fall through to dialogue text.
- No fuzzy matching. No alternate prefixes (`Action:`, `ACT:`, `[ACTION]`).
- Logged when not found: `[ActionManager.cpp:1783] ParseEmbeddedAction: No ACTION: line found in response`.
- Logged when found: `[ActionManager.cpp:1814] ParseEmbeddedAction: Successfully parsed action 'X' with params: {...}`.

**Strip behavior:** `FilterActionLines` (`ActionManager.cpp:1840`) removes the recognized `ACTION:` line from dialogue text *before* TTS, so it isn't spoken aloud. **It only strips lines it parsed successfully** - malformed lines (the bug #2 case) stay in the text and get spoken.

---

## The two action selectors - how they relate

| Aspect | `gamemaster_action_selector` | `native_action_selector` |
|---|---|---|
| **Scope** | scene-level orchestration | per-NPC, post-dialogue attribution |
| **When** | GM polling tick, player input | after Dialogue agent produces text |
| **Picks from** | StartConversation / ContinueConversation / Narrate / None / native actions | the eligible-actions registry, two-stage |
| **Variant** | `gamemaster_evaluation` | `action_evaluation` |
| **Model** | claude-sonnet (this install) | eva (this install) |
| **`max_tokens`** | 256 | 500 |
| **Output** | one ACTION line | one ACTION line (after drilldown: two LLM calls total) |

They are **complementary, not competitive**:
- The GM directs *who speaks and what about*.
- The native action selector classifies *what physically/mechanically happens* once an NPC has spoken.

**With `embed_actions_in_dialogue: true`:** the Dialogue agent itself can emit an `ACTION:` line inline, which **bypasses** the native action selector entirely. Faster, but more prone to format errors → bug #2 territory.

---

## Where custom actions actually live (resolved)

We initially looked for them in `mods/SkyrimNet/` itself and didn't find them. They live in **other mods' folders**:

```
/home/dafit/Games/Skyrim/nimmersky/mods/SeverActions - SkyrimNet Action Pack/SKSE/Plugins/SkyrimNet/config/actions/*.yaml
/home/dafit/Games/Skyrim/nimmersky/mods/IntelEngine/SKSE/Plugins/SkyrimNet/config/actions/*.yaml
/home/dafit/Games/Skyrim/nimmersky/mods/OStimNet_v0.9.2/SKSE/Plugins/SkyrimNet/config/actions/*.yaml
```

`[hypothesis]` - paths inferred from the contributing-mod names in the log; the agent didn't enumerate the actual files. Confirm by `find /home/dafit/Games/Skyrim/nimmersky/mods/ -name "*.yaml" -path "*/config/actions/*"`.

This is also why "export from web UI" doesn't work cleanly - the UI sees actions in its registry but doesn't know they came from neighbor mods' folders, and can't reach back across mod boundaries to dump them.

**Path forward for git-tracking:** symlink each contributing mod's `config/actions/` into a `nimmersky/skyrimnet/contributed-actions/{modname}/` archive, or write a one-shot collator script that copies them out.

---

## The malformed-marker problem (bug #2)

The Dialogue model occasionally emits `OPENTRADE` (bare uppercase token) instead of `ACTION: OpenTrade`. The parser ignores the bare token, doesn't strip it, and it gets TTS'd as plain text - Arcadia speaks "OPENTRADE" out loud.

**Why it happens:** `submodules/user_final_instructions/0750_embedded_actions.prompt:6-9` lists actions to the LLM as bullets:
```
**Available Actions:**
- `OPENTRADE` - Use ONLY if ...
- `OFFERQUEST` - ...
```
The model sometimes emits the bare bullet name instead of the full `ACTION: OPENTRADE` line. The bullet list format encourages the mistake.

**Why it became visible only after fixing bug #1:** the over-firing GM loop gave the Dialogue model 3-5 retries per scene; bug #2 was statistically masked. Fixing the loop removed the retries, exposing bug #2.

See [`bugs-and-fixes.md`](bugs-and-fixes.md) for fix candidates.
