Imported from zunath/SWLOR_NWN (
AGENTS.md). Install upstream withnpx skills add zunath/SWLOR_NWN. Copyright stays with the author.
Agent Rules
This file is the shared rule set for all coding agents. Codex reads it natively; Claude Code imports it through CLAUDE.md. Keep cross-agent rules here rather than in agent-specific files.
Documentation
- Do not add task-specific README files or README writeups for routine fixes, world-content changes, or PR work unless the user explicitly requests documentation. Keep implementation summaries and validation results in the PR description and conversation.
Agent Skills
- Agent skills are canonical in
.codex/skills/and mirrored to.claude/skills/so both Codex and Claude Code discover them. Theagents/openai.yamlfiles are Codex-only interface metadata and are not mirrored. - Edit skills only in
.codex/skills/. Never hand-edit.claude/skills/. - After adding, changing, or deleting any skill file, run
powershell -ExecutionPolicy Bypass -File tools/SyncAgentSkills.ps1to refresh the mirror. Use-CheckOnlyto verify sync without writing. - Keep skill instructions and descriptions agent-neutral: write "Use when adding a beast...", not "Use when Codex/Claude needs to...".
Read-Only Areas
- The Unified solution (
C:\Projects\unified) is read-only reference material. Do not make changes to it.
Pull Requests and Submodules
- When a parent-repository pull request changes a git submodule pointer, publishing is not complete until every modified submodule also has its own pull request. Push the submodule branch, open its companion pull request against the branch corresponding to the parent pull request's base branch, and link the parent and companion pull requests in both descriptions. Do not treat a pushed submodule branch by itself as a complete handoff.
- When a pull request has review findings, addressing them means resolving the review threads, not just replying to them. After pushing the fix, mark each thread resolved (
gh api graphqlwith theresolveReviewThreadmutation against the thread id fromreviewThreads). A reply alone leaves the thread open and the review still looks unaddressed. This applies to bot reviewers such as CodeRabbit and the Codex connector as well as human ones. - Resolve a thread only after the finding is actually handled: fixed and pushed, or replied to with a concrete reason it does not apply. Never resolve a thread by dismissing it silently.
- Before calling pull request work done, verify zero unresolved threads on the parent pull request and on every companion submodule pull request.
Background Processes
- Do not start background jobs, watchers, dev servers, publish tasks, or long-lived helper processes unless the user explicitly asks for them or they are strictly required for the current task. Prefer foreground commands with bounded timeouts. If a long-lived process is necessary, record what was started, track its PID when available, stop it before handing off, and report the cleanup. Do not use
Start-Process, shell backgrounding, persistent REPL helpers, or detached commands to continue work after the turn unless the user has explicitly approved that behavior.
Conversations
- Authored gameplay dialogue lives only in
SWLOR.Game.Server/ConversationData/*.conversation.json. Edit these graphs directly; never create duplicate DLG sources or regenerate existing graphs from legacy files. Module/dlg/dmfi_universal.dlg.jsonis the sole native module exception because DMFI wands call it through NWN. Legacy-format test fixtures are frozen import/editor samples, not gameplay sources.- Preserve NPC
ConversationIDs and route graph interactions throughdialog_start. The module resref identifies a SWLOR graph without requiring a DLG resource. FollowSWLOR.Game.Server/Readmes/Conversations.md.
Chat Commands
- Player-facing chat commands must use
.Permissions(AuthorizationLevel.All), notAuthorizationLevel.Playeralone, unless the command is deliberately meant to exclude DMs/Admins.AuthorizationLevel.Player-only silently fails for DM-possessed or DM-authorization accounts with the same generic "Invalid chat command" message used for unregistered commands, which makes it look like the command was never wired up instead of a permissions gap.
Tests
- Building or testing
SWLOR.Game.Serverfires a Windows post-build deploy (SWLOR.CLI.exe -o) that is slow and unnecessary for verification. Always skip it by passing-p:RunPostBuildEvent=Neveron builds, and use a build-once/test-many flow. - Build a single time, then run only the relevant tests without rebuilding:
dotnet build SWLOR.Game.Server.Tests\SWLOR.Game.Server.Tests.csproj -p:RunPostBuildEvent=Never, followed bydotnet test --no-build --filter "FullyQualifiedName~<RelevantTestClass>". Use|to combine multiple filters. - Do not run the full unfiltered suite (
dotnet testwith no--filter). It takes many minutes and is not a handoff requirement. Filtered runs covering the systems a change touches are the expected verification, including for the final pre-handoff check. A small or localized change — one creature's stats, one recipe, one definition, one bug fix — is verified by the tests that guard it and nothing more. - Run the full suite only when the user asks for it, or when a change is genuinely broad enough to plausibly affect unrelated systems (shared services, combat/stat infrastructure, enum or 2DA-wide edits, generator output). If unsure whether a change qualifies, run the filtered tests and say which ones you ran instead of reaching for the full suite.
Naming
- Do not use internal initiative, milestone, or phase labels such as
CombatUpgradein production code identifiers, filenames, namespaces, classes, methods, or comments. Use domain terms that describe gameplay behavior, such as ability targeting, ability effects, Leadership, Devices, or the specific system being changed.
Toolset Option Lists
- Builder-facing dropdowns, galleries, and searchable choice lists must never expose raw 2DA placeholder or sentinel rows such as
DELETED,USER,UNUSED,INVALID*,Bio_reserved,cep_reserved,Padding, or numberedNULLslots. Route generic 2DA options throughTwoDaChoicePolicy, declare table-specific required columns inTwoDaLookupTables, and fail closed when the metadata needed to prove a row is valid is unavailable. Add corpus or focused regression coverage whenever a new 2DA-backed option source is introduced.
Stat-Driven Gameplay
- Shared combat, ability, and status-effect infrastructure must not special-case specific perk types or perk-specific status-effect classes to unlock gameplay behavior. Model perk-driven behavior as
StatTypeadjustments, then have shared systems read those stats. Direct perk checks are only appropriate for ownership, unlock, purchase, UI, or progression gates. StatTypeclassification, polarity, or category decisions must be declared withStatTypeAttributeon the enum entry. Do not add largeif/switchlists elsewhere to infer stat meaning; shared systems should read the enum metadata instead.- Attack Deflection, Shield Deflection, and Guard are separate combat mechanics. Attack Deflection and Shield Deflection are attack-roll outcomes that negate the hit and do not stack with each other; Guard is a damage-stage outcome that reduces damage and increases enmity. Do not implement one by reusing the state, stats, logs, or triggers of another.
NPC Hit Point Budgets
- A stat skin's
NPCHPis the NPC's final maximum HP budget. NWN storesHitPointsas base HP, then derives maximum HP by applying the Constitution modifier (SWLOR Vitality) once per class level, Toughness once per level, and 20 HP for each Epic Toughness feat. Do not set UTCHitPointsdirectly toNPCHP: setCurrentHitPointsandMaxHitPointstoNPCHP, and setHitPointstoNPCHPminus those native bonuses. - Apply runtime NPC HP budgets through
Stat.SetNPCMaxHitPointsonly, after the raw Vitality score has been finalized.ObjectPlugin.SetMaxHitPointswrites native base HP and therefore must not receive anNPCHPfinal budget directly. - After adding or restatting NPCHP-backed creatures, run
powershell -ExecutionPolicy Bypass -File tools/NormalizeNpcHitPoints.ps1. Use-CheckOnlyin audits.NPCEnemyBalanceAuditTests.AllNpcHpBudgets_AccountForNativeVitalityAndToughnessRulesprotects the complete corpus.
Player Identity
- Player-facing surfaces must use the
PlayerNameservice instead of raw player names. For live player objects, usePlayerName.GetDisplayName(observer, target)orPlayerName.GetColoredDisplayName(observer, target). For offline/persisted player records, usePlayerName.GetDisplayNameByPlayerId(observer, playerId, fallbackName). - Do not expose raw
GetName(player),Player.Name,dbPlayer.Name,GetPCPlayerName, public CD keys, or account names in ordinary player-facing UI, dialogs, nearby broadcasts, combat/status logs, HoloNet-style broadcasts, market/civic/property lists, or generated public object names. - Unnamed player characters use a stable unknown display descriptor. Blank descriptors are generated once from the persisted original appearance/species and base stats during migration or login, and fall back to a generic humanoid descriptor if species or stats cannot be resolved. Descriptor generation, descriptor persistence, and descriptor fallback lookup belong in the
PlayerDescriptorservice;PlayerNameshould consume descriptors while remaining responsible for observer-specific name resolution. Self-targeted/namereplaces that descriptor and permanently discards the generated one. - Self-targeted
/namesets the player's unknown display description. This remains an unnamed/unknown identity and must continue to render with the unknown gray name token. If the observer has not named the target, show only the gray descriptor. If the observer has named the target, show the assigned name plus the gray descriptor in brackets by default, such asJoe Blow [A Seedy Individual]; non-DM players may hide descriptors for named targets in Settings, in which case they see only their assigned name. Staff observers should always see the canonical character name plus the gray descriptor in brackets, such asJoe Smith [A Seedy Individual]. /nameinput is limited to 64 characters and must reject player-entered color tokens. Color styling for known, unknown, and staff-facing name displays is controlled by thePlayerNameservice.- Property and ship permission management is a narrow exception because it grants persistent access to real character records. These screens may search canonical character names as well as observer-known names, and should display
PlayerName.GetKnownNameOrFallbackByPlayerId(observer, playerId, fallbackName)so fake/known names are preserved when present and canonical names are available when no known name exists. - Server logs and audit trails must retain raw/canonical player identity for moderation and traceability. Raw/canonical player identity is also acceptable for DM/admin-only tools, persisted ownership fields, and messages shown only to that same player. Public custom names deliberately entered by players, such as renamed properties or droids, may remain visible.
Economy-Restricted Items
- Player-facing item search and economy surfaces (quest contract objective search, and any future market-style blueprint pickers) must not show NPC-only, creature, or internal items.
Item.IsEconomyRestrictedis the single source of truth;Cache.IsItemSearchableByResrefconsumes it. Never hardcode resref lists to exclude items — extend the shared classifier or flag the blueprint. - Creature-equipment base item types (creature weapons and
CreatureItem"stat skins") and items whose name carries the reserved[NPC]/(NPCprefix are excluded automatically, as are blueprints with no real inventory icon. - For an NPC-only item that a normal player item is otherwise indistinguishable from — a real base type, a real icon, and no
[NPC]name (e.g. the "Specialist" NPC weapons, "Republic Special Forces Rifle") — set theNO_ECONOMYlocal variable to1on the blueprint. This is the explicit opt-out the runtime classifier reads. Prefer this over broadening name/base-type heuristics, which risk hiding legitimate player items. - If a genuinely new NPC naming convention or creature base type is introduced, update the pattern/base-type set in
Item.IsEconomyRestricted(not a resref list).EconomyRestrictedItemTestsguards that every[NPC]/(NPCblueprint stays covered; keep it green. - Any item blueprint that players cannot obtain through some source must carry the
NO_ECONOMYflag.EconomyObtainabilityCoverageTestsenforces this: it scans everyutiblueprint, subtracts every obtainable source (loot, stores, placed containers, recipe outputs/components, refining, fishing, quest rewards viaAddItemReward, training store, starting gear, andCreateItemOnObject/CopyItemAndModifyliterals), and requires the remainder (excluding creature/[NPC]items the runtime already handles) to be flagged. When it fails on a new item, either wire the item to a real player source or runpython tools/FlagNpcEconomyItems.pyto stamp it. New flags require a module repack on deploy. If you add a genuinely new item-acquisition mechanism, extend the obtainable extraction in both the tool and the test.
Design Bible
- Follow
SWLOR.Game.Server/Readmes/DesignBibleWorkbookRules.mdwhen editing any Design Bible workbook. - Never edit a Design Bible workbook with
openpyxl(or any library that rewrites the whole workbook without recalculating formulas). It discards the cached formula-result values on every formula cell: the perk sync tests still pass (text tabs have no formulas), but formula-backed tabs silently lose their cached numbers and break tests such asNPCEnemyBalanceAuditTests. These workbooks are Google Sheets exports that store text as inline strings, so edit the target cells surgically at the zip/XML level (cells look like<c r="G31" s="..." t="inlineStr"><is><t>TEXT</t></is></c>) and repackage copying every other zip entry byte-for-byte, so untouched sheets keep their cached values. Thetools/UpdateCombatUpgradeAudit.ps1 -RefreshLocalBibleformatter preserves caches and is safe to run afterward. - After editing
design/bible/SWLOR Design Bible - Combat Upgrade.xlsx, runpowershell -ExecutionPolicy Bypass -File tools/UpdateCombatUpgradeAudit.ps1 -RefreshLocalBibleto refreshSWLOR.Game.Server/Readmes/CombatUpgradeBiblePerkManifest.csvandSWLOR.Game.Server/Readmes/CombatUpgradePerkAudit.csvfrom the local workbook.
Full Rebuild Changes
- For rebuild-era changes covered by a planned full character rebuild, do not add one-off player migrations solely to remove or refund deleted perks, blueprints, skills, or similar character-build data. Rely on the full rebuild path unless the change affects persistent data that survives rebuild or server/world state outside character builds.
- Until the combat-upgrade migration set ships, fold additional combat-upgrade migration work into the existing in-flight combat-upgrade migrations instead of adding new numbered migration files. Add new numbered migrations only after the prior migration version has shipped, or when a change must run separately because of execution timing.
TLK Entries
- New custom TLK strings must use a pre-existing empty TLK slot or gap before appending new IDs at the end of
SWLOR_Haks/sw_tlk/sw_tlk.tlk.json. - NWN custom TLK references in 2DA files use
16777216 + tlkId. When moving or adding a TLK entry, update every 2DA/reference to the matching custom strref. - After editing
sw_tlk.tlk.json, regeneratesw_tlk.tlkbefore building or handing off the change.
Recast Groups
RecastGroupshort names are player-facing and limited to 14 characters. Never auto-truncate or use partial-word fragments; choose a meaningful short label and make generators/scripts fail if one is missing.
Ability Definitions
-
Each distinct gameplay ability must have its own
*AbilityDefinition.csfile and matchingIAbilityListDefinitionclass named for that ability. Do not group unrelated abilities into broad definition files such as creature, combat, NPC, or package-level collections. Multiple ranks of the same ability may live in that ability's own definition file. -
Ability-specific targeting metadata must be declared through the ability definition builder/detail pattern. Do not maintain separate explicit production lists of abilities for targeting behavior; shared targeting systems should consume the cached ability definitions.
-
An active ability presents a manual target cursor only when it is a single-target hostile cast or an aimed area. Queued weapon abilities (fire on the wearer's next landed auto-attack) and self-centered area abilities must NOT prompt for a target: in
feat.2dathey useTARGETSELF=1withHostileFeatcleared, and in C# they must not callRequiresTarget()(ConfigureWeaponAbilityalready skips it forIsQueuedWeaponAbility). -
Aimed vs self-centered is decided by the area's shape, and the shape must match the Design Bible wording. An ability whose Bible description says "in a line" or "in a cone" is aimed: the player chooses the direction, so it needs a cursor. An ability that damages "enemies within Nm" (naming only a radius) is self-centered: it always originates on the caster and needs no cursor.
-
RequiresTarget()means a real target object is mandatory; it is not a cursor flag. Ground- or direction-aimed areas must declare targeting metadata and useAbilityDetail.RequiresLocationTarget, while leavingRequiresTargetfalse so empty-ground casts work. An area whose Bible explicitly requires a selected creature may callRequiresTarget(), but the builder must never infer that requirement from shape alone.CanUseAbilityvalidates location targets separately and appliesMaxRangeonly when the definition explicitly callsHasMaxRange—the default 5m object range must never become an implicit area-placement limit.Bible wording AbilityTargetingShapeTypeCursor feat.2daC# targeting spell "in a line" Rect(sizeX = length, sizeY = width)yes TARGETSELFblank,HostileFeat=1real Spell, per rank"in a cone" Cone(sizeX = length, sizeY = width)yes TARGETSELFblank,HostileFeat=1real Spell, per rank"to enemies within Nm" Sphere(sizeX = radius, sizeY =0)no TARGETSELF=1,HostileFeatblankreal Spell, per rankState the size in the description. The generator reads the numbers out of the Bible line — "in an 8m x 2.5m line" and "enemies within 3m" produce those exact sizes — and only falls back to an archetype default when the line names none. A description that omits its size silently inherits a default that may not be what you intended.
Two traps in that parsing, both of which shipped bugs before:
- A radius area is recognised by the noun, not by "within Nm" alone: "enemies within 5m", "all targets within 6m" and "hostile targets within 5m" are areas. A bare "within Nm" is not, because the Bible also uses it for an ally buff ("allies within 5m"), a leash range ("while within 20m") and a placement range ("a field within 15m"). The singular form is also excluded — "one enemy within 5m" is a reach check, not a shape.
- A
line/coneis only recognised in the "in a line" form or immediately after a stated size ("8m x 2.5m line"). A bare mention of the word does not count, because "anchors a defensive line" is a radius buff. Matching the literal phrase alone used to miss the sized form entirely and infer a self-centered Sphere for an aimed line — losing the cursor.
Earthshatter I/IIis the reference implementation for an aimed line. -
Every rank of an area ability needs its own
spells.2darow and its ownSpellenum value. Never leaveSpell.Invalidon a rank that declares a realAbilityTargetingShapeType. The two failure modes differ, and only one of them is loud:- Targeting metadata that exists but carries
Spell.Invalidis rejected at load —AbilityTargeting.ValidateTargetingthrowsInvalidOperationException. - Passing
Spell.InvalidtoConfigureWeaponAbilitynever reaches that check, becauseApplyTargetingMetadataskips building targeting metadata at all. The ability ends up with noTargeting, so it silently loses both its cursor and its ground area marker with nothing thrown or logged. This is how several ranks shipped broken, and it is whatAreaAbilityTargetingTestsguards.
- Targeting metadata that exists but carries
-
tools/GenerateWeaponArchetypeImplementation.pyencodes the table above, andSWLOR.Game.Server.Tests/Perks/AreaAbilityTargetingTests.csenforces it by reflecting over everyIAbilityListDefinitionand cross-checkingfeat.2da. Keep that test green rather than adding explicit per-ability lists. After changing any of this, rebuild the haks and repack the module so the change deploys.
Ability Icons
- Before adding, changing, generating, or renaming ability, feat, spell, or status-effect icons, read
SWLOR.Game.Server/Readmes/IconStandards.mdand follow it as the source of truth for artwork, semantic category, rank badges, and resource naming. - Gameplay icon resrefs must be short, meaningful abbreviations within NWN's 16-character resource limit. Do not use opaque hash, collision, or generator suffixes such as random-looking letters or digits after the meaningful abbreviation.
- After adding or changing an ability icon referenced by
SWLOR_Haks/sw_2da/feat.2daorSWLOR_Haks/sw_2da/spells.2da, runpowershell -ExecutionPolicy Bypass -File tools/GenerateCooldownIcons.ps1 -Forceto regenerate thepr0_throughpr5_cooldown icon variants. This script must use ImageMagick output; do not replace it with a custom TGA writer. - After adding, changing, generating, or renaming any gameplay icon manifest entry or gameplay icon resource, run
powershell -ExecutionPolicy Bypass -File tools/UpdateGameplayIconStandards.ps1 -AuditOnlyand fix every failure before handing off the work.
Ability VFX
- Before choosing or changing perk, ability, status-effect, trap, or scripted creature VFX, consult
SWLOR.Game.Server/Readmes/VisualEffectSelection.mdandSWLOR.Game.Server/Readmes/VisualEffectReference.csv. Pick VFX by gameplay moment, visual group, colors, location, and screenshot reference rather than by constant name alone. - Use the CSV
CSharpEnumvalue in C# code. UseBEAMentries withEffectBeam,FNFentries for location/area bursts,IMPorCOMentries for target impact feedback,DURentries for persistent auras or field markers, andEYESentries only when the eye/head cue is the intended player-facing signal.
Ability Damage
- When an ability applies
EffectDamagewithApplyEffectToObject, wrap that call inAssignCommand(source, () => ApplyEffectToObject(...))using the damage source as the command object so the damage appears in the player's combat log.
