You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

731 lines
32 KiB

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PF2e Perception &amp; Stealth</title>
<link rel="stylesheet" href="global.css">
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
<style>
:root {
--c-observed: #193a14;
--c-observed-hi: #5ec454;
--c-observed-border: #3a7c32;
--c-hidden: #3e2e06;
--c-hidden-hi: #f0c040;
--c-hidden-border: #a07a10;
--c-undetected: #3e1a06;
--c-undetected-hi: #f07830;
--c-undetected-border: #a04010;
--c-unnoticed: #300c0c;
--c-unnoticed-hi: #f04040;
--c-unnoticed-border: #8a1010;
--c-concealed: #0e2234;
--c-concealed-hi: #50a8e8;
--c-concealed-border: #2060a0;
--c-invisible: #180e30;
--c-invisible-hi: #9070e0;
--c-invisible-border: #5030b0;
}
aside.open { width: 270px; min-width: 270px; border-right-width: 1px; }
.aside-inner { width: 270px; padding: 16px 14px; }
.diagram-wrap .mermaid { min-width: 600px; }
.cover-box {
margin-top: 14px; padding: 10px 12px;
background: var(--surface2); border: 1px solid var(--border); border-radius: 5px;
font-size: 0.76rem; line-height: 1.7;
}
.cover-box h3 { color: var(--accent); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.1em; margin-bottom: 6px; }
.cover-box strong { color: var(--accent); }
.ccard.observed { background: var(--c-observed); border-color: var(--c-observed-border); }
.ccard.observed h3{ color: var(--c-observed-hi); }
.ccard.observed .tag { background: #0d2a0a; color: var(--c-observed-hi); }
.ccard.hidden { background: var(--c-hidden); border-color: var(--c-hidden-border); }
.ccard.hidden h3 { color: var(--c-hidden-hi); }
.ccard.hidden .tag{ background: #2a1e04; color: var(--c-hidden-hi); }
.ccard.undetected { background: var(--c-undetected); border-color: var(--c-undetected-border); }
.ccard.undetected h3 { color: var(--c-undetected-hi); }
.ccard.undetected .tag{ background: #2a1004; color: var(--c-undetected-hi); }
.ccard.unnoticed { background: var(--c-unnoticed); border-color: var(--c-unnoticed-border); }
.ccard.unnoticed h3 { color: var(--c-unnoticed-hi); }
.ccard.unnoticed .tag{ background: #200808; color: var(--c-unnoticed-hi); }
.ccard.concealed { background: var(--c-concealed); border-color: var(--c-concealed-border); }
.ccard.concealed h3 { color: var(--c-concealed-hi); }
.ccard.concealed .tag{ background: #081422; color: var(--c-concealed-hi); }
.ccard.invisible { background: var(--c-invisible); border-color: var(--c-invisible-border); }
.ccard.invisible h3 { color: var(--c-invisible-hi); }
.ccard.invisible .tag{ background: #100820; color: var(--c-invisible-hi); }
.sim-start-row { display: flex; align-items: center; gap: 8px; margin-bottom: 20px; flex-wrap: wrap; }
.sim-chip.observed { color: var(--c-observed-hi); border-color: var(--c-observed-border); }
.sim-chip.hidden { color: var(--c-hidden-hi); border-color: var(--c-hidden-border); }
.sim-chip.undetected { color: var(--c-undetected-hi); border-color: var(--c-undetected-border); }
.sim-chip.unnoticed { color: var(--c-unnoticed-hi); border-color: var(--c-unnoticed-border); }
.sim-box.observed { background: var(--c-observed); border-color: var(--c-observed-border); }
.sim-box.hidden { background: var(--c-hidden); border-color: var(--c-hidden-border); }
.sim-box.undetected { background: var(--c-undetected); border-color: var(--c-undetected-border); }
.sim-box.unnoticed { background: var(--c-unnoticed); border-color: var(--c-unnoticed-border); }
.sim-box.observed .sim-state-name { color: var(--c-observed-hi); }
.sim-box.hidden .sim-state-name { color: var(--c-hidden-hi); }
.sim-box.undetected .sim-state-name { color: var(--c-undetected-hi); }
.sim-box.unnoticed .sim-state-name { color: var(--c-unnoticed-hi); }
.sim-action {
padding: 9px 16px; background: var(--surface2); border: 1px solid var(--border);
color: var(--text); border-radius: 5px; cursor: pointer; font-family: inherit;
font-size: 0.83rem; text-align: left; max-width: 300px;
transition: border-color 0.15s, color 0.15s, background 0.15s;
}
.sim-outcomes-list { display: flex; flex-wrap: wrap; gap: 8px; }
.sim-outcome {
padding: 7px 16px; background: var(--surface); border: 1px solid var(--border);
color: var(--text); border-radius: 4px; cursor: pointer; font-family: inherit; font-size: 0.82rem;
transition: border-color 0.15s, color 0.15s;
}
.sim-result-box.observed { background: var(--c-observed); border-color: var(--c-observed-border); }
.sim-result-box.hidden { background: var(--c-hidden); border-color: var(--c-hidden-border); }
.sim-result-box.undetected { background: var(--c-undetected); border-color: var(--c-undetected-border); }
.sim-result-box.unnoticed { background: var(--c-unnoticed); border-color: var(--c-unnoticed-border); }
.sim-result-box.observed .sim-result-new-state { color: var(--c-observed-hi); }
.sim-result-box.hidden .sim-result-new-state { color: var(--c-hidden-hi); }
.sim-result-box.undetected .sim-result-new-state { color: var(--c-undetected-hi); }
.sim-result-box.unnoticed .sim-result-new-state { color: var(--c-unnoticed-hi); }
.sim-hist-state.observed { color: var(--c-observed-hi); }
.sim-hist-state.hidden { color: var(--c-hidden-hi); }
.sim-hist-state.undetected { color: var(--c-undetected-hi); }
.sim-hist-state.unnoticed { color: var(--c-unnoticed-hi); }
</style>
</head>
<body>
<header>
<h1><a href="index.html">PF2e Perception &amp; Stealth</a></h1>
<p>Flow of Control Reference &mdash; Player Core</p>
<button class="sidebar-toggle-btn" onclick="toggleSidebar()" title="Toggle Detection Conditions">
&#9776; Conditions
</button>
</header>
<div class="layout">
<!-- ── Sidebar ── -->
<aside id="sidebar">
<div class="aside-inner">
<div class="aside-header">
<h2>Detection Conditions</h2>
<button class="aside-close" onclick="closeSidebar()" title="Close">&times;</button>
</div>
<div class="ccard observed">
<h3>Observed</h3>
<p>You perceive the creature clearly with a precise sense. You can target it normally.</p>
<span class="tag">No flat check to target</span>
</div>
<div class="ccard concealed">
<h3>Concealed</h3>
<p>In mist, dim light, or similar. Still <em>observed</em>, but harder to affect. Does not change the main detection category.</p>
<span class="tag">DC 5 flat check to affect</span>
</div>
<div class="ccard hidden">
<h3>Hidden</h3>
<p>You know the creature&rsquo;s space but can&rsquo;t see it. You are off-guard to it. The creature cannot take non-unobtrusive actions without being observed.</p>
<span class="tag">DC 11 flat check to affect</span>
</div>
<div class="ccard undetected">
<h3>Undetected</h3>
<p>You don&rsquo;t know which space it&rsquo;s in. You are off-guard. You may know it is nearby (undetected) or have no clue at all (unnoticed).</p>
<span class="tag">Pick a square &mdash; GM rolls secretly</span>
</div>
<div class="ccard unnoticed">
<h3>Unnoticed</h3>
<p>You have no idea the creature even exists nearby. Certain abilities only work on totally unaware targets.</p>
<span class="tag">Cannot target at all</span>
</div>
<div class="ccard invisible">
<h3>Invisible</h3>
<p>Automatically <em>undetected</em> against creatures relying on sight alone. Precise senses other than vision still work. Seek can reveal location, making it hidden.</p>
<span class="tag">Undetected to sight-only perceivers</span>
</div>
<div class="cover-box">
<h3>Cover Bonuses (Stealth)</h3>
<strong>Standard Cover:</strong> +2 to Stealth<br>
<strong>Greater Cover:</strong> +4 to Stealth
</div>
</div>
</aside>
<div class="sidebar-overlay" id="sidebar-overlay" onclick="closeSidebar()"></div>
<!-- ── Main ── -->
<main>
<div class="tab-bar">
<button class="tab-btn active" onclick="showTab('simulator', this)">Situation Simulator</button>
<button class="tab-btn" onclick="showTab('stealth', this)">Stealth Actions</button>
<button class="tab-btn" onclick="showTab('detection', this)">Detection &amp; Targeting</button>
</div>
<!-- Stealth Tab -->
<div id="stealth" class="tab-panel">
<h2>Stealth Actions Flow</h2>
<p class="subtitle">From the <strong>stealther&rsquo;s</strong> perspective &mdash; how detection states change based on actions you take</p>
<div class="legend">
<div class="legend-item"><div class="legend-dot" style="background:var(--c-observed);border-color:var(--c-observed-hi)"></div> Observed</div>
<div class="legend-item"><div class="legend-dot" style="background:var(--c-hidden);border-color:var(--c-hidden-hi)"></div> Hidden</div>
<div class="legend-item"><div class="legend-dot" style="background:var(--c-undetected);border-color:var(--c-undetected-hi)"></div> Undetected</div>
<div class="legend-item"><div class="legend-dot" style="background:var(--c-unnoticed);border-color:var(--c-unnoticed-hi)"></div> Unnoticed</div>
<div class="legend-item"><div class="legend-dot" style="background:#1a2840;border-color:#4080b0"></div> Action / Roll</div>
</div>
<div class="diagram-wrap">
<div class="mermaid" id="stealth-diagram">
%%{init: {'theme':'dark','themeVariables':{'background':'#161210','primaryColor':'#193a14','primaryTextColor':'#e8d5b0','primaryBorderColor':'#3a7c32','lineColor':'#9a7e56','secondaryColor':'#3e2e06','tertiaryColor':'#1a2840','edgeLabelBackground':'#211a13','clusterBkg':'#211a13'}}}%%
flowchart LR
classDef obs fill:#193a14,stroke:#3a7c32,color:#b8e8b0,rx:30
classDef hid fill:#3e2e06,stroke:#a07a10,color:#f0e060
classDef und fill:#3e1a06,stroke:#a04010,color:#f0a868
classDef unn fill:#300c0c,stroke:#8a1010,color:#f07070
classDef act fill:#1a2840,stroke:#4080b0,color:#90c0e8
classDef roll fill:#201828,stroke:#6040a0,color:#c0a0e8
OBS(["OBSERVED"]):::obs
HID(["HIDDEN"]):::hid
UND(["UNDETECTED"]):::und
UNN(["UNNOTICED"]):::unn
HIDE["HIDE
- needs cover or concealment
- secret Stealth roll vs Perception DC
- Standard cover +2
- Greater cover +4"]:::act
SNEAK["SNEAK
- cover at START and END
- move ≤ half Speed
- secret Stealth roll vs Perception DC"]:::act
OBS --> HIDE
HIDE -->|"✓ Success"| HID
HIDE -->|"✗ Failure"| OBS
HID --> SNEAK
HID -->|"Non-stealthy action
or Strike"| OBS
HID -->|"STEP
with cover / concealment"| HID
HID -->|"Unobtrusive action
GM may require Stealth"| HID
SNEAK -->|"✓ Success"| UND
SNEAK -->|"✗ Failure"| HID
SNEAK -->|"✗✗ Crit Failure
(not invisible)"| OBS
UND -->|"SNEAK ✓ Success"| UND
UND -->|"Speak or
make loud noise"| OBS
UNN -.->|"may also be"| UND
UNN -->|"Speak, loud noise
or non-stealthy action"| OBS
</div>
</div>
<div class="note-box">
<h3>Key Rules</h3>
<ul>
<li><strong>HIDE</strong> only rolls against creatures that are currently <em>observing</em> you. If a creature already considers you hidden or undetected, you simply retain that condition — HIDE does not improve your state from undetected.</li>
<li><strong>SNEAK</strong> requires cover or concealment at <em>both the start and end</em> of your movement. You move up to half your Speed.</li>
<li>Taking any non-<em>unobtrusive</em> action while hidden immediately makes you <strong>Observed</strong>.</li>
<li>A <strong>Strike</strong> from a hidden or undetected position makes you <strong>Observed</strong> after the attack (target now knows your location).</li>
<li>An <strong>Invisible</strong> creature that critically fails a Sneak roll becomes <strong>Hidden</strong> rather than Observed, since it cannot be seen.</li>
<li><strong>AVOID NOTICE</strong> (Exploration): Roll Stealth to start an encounter Hidden or Undetected rather than Observed.</li>
</ul>
</div>
</div>
<!-- Detection Tab -->
<div id="detection" class="tab-panel">
<h2>Detection &amp; Targeting Flow</h2>
<p class="subtitle">From the <strong>detector&rsquo;s</strong> perspective &mdash; how to determine a creature&rsquo;s condition and what you can do about it</p>
<div class="legend">
<div class="legend-item"><div class="legend-dot" style="background:var(--c-observed);border-color:var(--c-observed-hi)"></div> Observed</div>
<div class="legend-item"><div class="legend-dot" style="background:var(--c-hidden);border-color:var(--c-hidden-hi)"></div> Hidden</div>
<div class="legend-item"><div class="legend-dot" style="background:var(--c-undetected);border-color:var(--c-undetected-hi)"></div> Undetected</div>
<div class="legend-item"><div class="legend-dot" style="background:var(--c-unnoticed);border-color:var(--c-unnoticed-hi)"></div> Unnoticed</div>
<div class="legend-item"><div class="legend-dot" style="background:#1a2840;border-color:#4080b0"></div> Action / Roll</div>
<div class="legend-item"><div class="legend-dot" style="background:#201828;border-color:#6040a0"></div> Decision</div>
</div>
<div class="diagram-wrap">
<div class="mermaid" id="detection-diagram">
%%{init: {'theme':'dark','themeVariables':{'background':'#161210','primaryColor':'#193a14','primaryTextColor':'#e8d5b0','primaryBorderColor':'#8b6914','lineColor':'#a08060','secondaryColor':'#3e2e06','tertiaryColor':'#1a2840','edgeLabelBackground':'#211a13','clusterBkg':'#211a13'}}}%%
flowchart TD
classDef obs fill:#193a14,stroke:#3a7c32,color:#b8e8b0
classDef hid fill:#3e2e06,stroke:#a07a10,color:#f0e060
classDef und fill:#3e1a06,stroke:#a04010,color:#f0a868
classDef unn fill:#300c0c,stroke:#8a1010,color:#f07070
classDef act fill:#1a2840,stroke:#4080b0,color:#90c0e8
classDef dec fill:#2a2010,stroke:#806040,color:#d0c0a0
START(["What can you perceive?"]):::dec
Q1["Can your precise sense detect it?"]:::dec
Q2["Do you know it is nearby?"]:::dec
Q3["Do you know which space?"]:::dec
OBS(["OBSERVED
Target normally
If concealed: DC 5 flat check"]):::obs
HID(["HIDDEN
You know the space
You are off-guard to it
DC 11 flat check to target"]):::hid
UND(["UNDETECTED
You do not know the space
You are off-guard to it
Guess a square - GM rolls secretly"]):::und
UNN(["UNNOTICED
No idea it exists
Cannot target at all"]):::unn
SEEK_HID["SEEK from Hidden
Perception vs Stealth DC
rolled secretly by GM"]:::act
SEEK_UND["SEEK from Undetected
Perception vs Stealth DC
rolled secretly by GM"]:::act
START --> Q1
Q1 -->|YES| OBS
Q1 -->|NO| Q2
Q2 -->|NO| UNN
Q2 -->|YES| Q3
Q3 -->|YES| HID
Q3 -->|NO| UND
HID -->|Use SEEK| SEEK_HID
UND -->|Use SEEK| SEEK_UND
SEEK_HID -->|Critical Success| OBS
SEEK_HID -->|Success precise sense| OBS
SEEK_HID -->|Success imprecise or invisible| HID
SEEK_HID -->|Failure| HID
SEEK_UND -->|Critical Success| OBS
SEEK_UND -->|Success| HID
SEEK_UND -->|Failure| UND
</div>
</div>
<div class="note-box">
<h3>Targeting Summary</h3>
<ul>
<li><strong>Observed:</strong> Attack normally. If also <em>concealed</em>, attempt a DC&nbsp;5 flat check before rolling — fail means no effect.</li>
<li><strong>Hidden:</strong> Attempt a DC&nbsp;11 flat check before rolling. On a fail, your actions (and spell slots, resources) are wasted. You remain off-guard to the creature regardless.</li>
<li><strong>Undetected:</strong> Declare a square to attack. The GM secretly rolls both the flat check and your attack roll. The GM will not say whether you missed due to the flat check, the attack roll, or choosing the wrong square.</li>
<li><strong>Unnoticed:</strong> You cannot target the creature directly. Area effects still work normally against undetected creatures.</li>
<li><strong>Invisible creature</strong> starts as <em>Hidden</em> if you were already observing it when it turned invisible (you last know where it was). It can then Sneak to become Undetected.</li>
<li><strong>Off-guard:</strong> When a creature is hidden or undetected from you, <em>you</em> are off-guard to <em>it</em> — you take a &minus;2 circumstance penalty to your AC against its attacks. The hidden/undetected creature is not automatically off-guard to you.</li>
</ul>
</div>
</div>
<!-- Simulator Tab -->
<div id="simulator" class="tab-panel active">
<h2>Situation Simulator</h2>
<p class="subtitle">Step through detection state changes interactively &mdash; choose an action to see what happens next</p>
<!-- Start-as state picker -->
<div class="sim-start-row">
<span class="sim-start-label">Start as:</span>
<button class="sim-chip observed" onclick="simSetState('OBSERVED')">Observed</button>
<button class="sim-chip hidden" onclick="simSetState('HIDDEN')">Hidden</button>
<button class="sim-chip undetected" onclick="simSetState('UNDETECTED')">Undetected</button>
<button class="sim-chip unnoticed" onclick="simSetState('UNNOTICED')">Unnoticed</button>
</div>
<!-- Current state display -->
<div id="sim-current-box" class="sim-box observed">
<div class="sim-box-label">Current State</div>
<div class="sim-state-name" id="sim-state-name">Observed</div>
<div class="sim-state-effects" id="sim-state-effects"></div>
</div>
<!-- Action buttons -->
<div id="sim-actions-section">
<div class="sim-section-head">What happens?</div>
<div class="sim-actions-list" id="sim-actions-list"></div>
</div>
<!-- Roll outcomes (shown when action needs a roll) -->
<div id="sim-outcomes-section" style="display:none">
<div class="sim-outcomes-box">
<div class="sim-outcomes-head" id="sim-outcomes-head">Select the outcome:</div>
<div class="sim-outcomes-list" id="sim-outcomes-list"></div>
</div>
</div>
<!-- Result of last transition -->
<div id="sim-result-container"></div>
<!-- History trail + Reset -->
<div class="sim-bottom-row">
<div class="sim-history-wrap">
<div class="sim-history-label">History</div>
<div class="sim-history-trail" id="sim-history-trail"></div>
</div>
<button class="sim-reset-btn" onclick="simReset()">&#8635; Reset</button>
</div>
</div>
</main>
</div>
<script>
mermaid.initialize({
startOnLoad: false,
theme: 'dark',
flowchart: { curve: 'basis', padding: 20, useMaxWidth: false },
securityLevel: 'loose',
});
document.addEventListener('DOMContentLoaded', async function() {
const diagrams = document.querySelectorAll('.mermaid');
for (let i = 0; i < diagrams.length; i++) {
const el = diagrams[i];
const id = el.id || ('mermaid-' + i);
try {
const { svg } = await mermaid.render(id + '-svg', el.textContent.trim());
el.innerHTML = svg;
} catch (e) {
console.error('Mermaid error in #' + id + ':', e);
el.innerHTML = '<pre style="color:#f07070;font-size:0.75rem;white-space:pre-wrap;padding:12px">' +
'Diagram error in #' + id + ':\n' + (e.message || e) + '</pre>';
}
}
simSetState('OBSERVED');
});
function showTab(name, btn) {
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.getElementById(name).classList.add('active');
btn.classList.add('active');
}
// ── Situation Simulator ─────────────────────────────────────
const SIM_STATES = {
OBSERVED: {
label: 'Observed',
cssClass: 'observed',
effects: 'Opponents perceive you clearly. You can be targeted normally. If also <strong>Concealed</strong>, they need a DC&nbsp;5 flat check.',
},
HIDDEN: {
label: 'Hidden',
cssClass: 'hidden',
effects: 'Opponents know your general location but not your exact square. You are <strong>off-guard</strong> to them. They need a DC&nbsp;11 flat check to affect you.',
},
UNDETECTED: {
label: 'Undetected',
cssClass: 'undetected',
effects: 'No opponent knows which square you occupy. They are <strong>off-guard</strong> to you. They must guess a square; the GM rolls secretly.',
},
UNNOTICED: {
label: 'Unnoticed',
cssClass: 'unnoticed',
effects: 'No opponent even knows you exist nearby. You cannot be targeted at all.',
},
};
const SIM_ACTIONS = {
OBSERVED: [
{
id: 'hide',
name: 'Use HIDE',
hint: 'Requires cover or concealment',
needsRoll: true,
rollLabel: 'HIDE &mdash; select the outcome (Stealth vs Perception DC):',
outcomes: [
{ label: '&#x2713; Success', newState: 'HIDDEN', msg: 'You slip out of sight. Opponents know your general area but not your exact square.' },
{ label: '&#x2717; Failure', newState: 'OBSERVED', msg: 'You fail to conceal yourself. You remain fully observed.' },
],
},
],
HIDDEN: [
{
id: 'sneak',
name: 'Use SNEAK',
hint: 'Move &le; half Speed, cover at start &amp; end',
needsRoll: true,
rollLabel: 'SNEAK &mdash; select the outcome (Stealth vs Perception DC):',
outcomes: [
{ label: '&#x2713; Success', newState: 'UNDETECTED', msg: 'You move silently. No opponent knows your new location.' },
{ label: '&#x2717; Failure', newState: 'HIDDEN', msg: 'You make some noise but stay in the same known area. You remain hidden.' },
{ label: '&#x2717;&#x2717; Crit Fail', newState: 'OBSERVED', msg: 'You give away your position. You are spotted! (If invisible, you become Hidden instead.)' },
],
},
{
id: 'seek',
name: 'Someone SEEKs me',
hint: 'Opponent rolls Perception vs your Stealth DC',
needsRoll: true,
rollLabel: 'SEEK &mdash; select the outcome (their Perception vs your Stealth DC):',
outcomes: [
{ label: '&#x2713;&#x2713; Crit Success', newState: 'OBSERVED', msg: 'The seeker pinpoints you precisely with a precise sense. You are now fully observed.' },
{ label: '&#x2713; Success (precise sense)', newState: 'OBSERVED', msg: 'The seeker locates you precisely. You are observed.' },
{ label: '&#x2713; Success (imprecise / you\'re invisible)', newState: 'HIDDEN', msg: 'The seeker narrows down your area but cannot see you precisely. You remain hidden.' },
{ label: '&#x2717; Failure', newState: 'HIDDEN', msg: 'The seeker finds nothing new. You remain hidden.' },
],
},
{
id: 'nonstealthy',
name: 'Non-stealthy action / Strike',
hint: 'Attack, cast a spell, or act openly',
needsRoll: false,
newState: 'OBSERVED',
msg: 'Acting openly reveals your position. You are now observed.',
},
{
id: 'step',
name: 'STEP with cover / concealment',
hint: 'One square while remaining covered',
needsRoll: false,
newState: 'HIDDEN',
msg: 'You step carefully while staying concealed. You remain hidden.',
},
{
id: 'unobtrusive',
name: 'Unobtrusive action',
hint: 'Draw an item, open a door &mdash; GM may call for Stealth',
needsRoll: false,
newState: 'HIDDEN',
msg: 'You act quietly. You remain hidden.',
},
],
UNDETECTED: [
{
id: 'sneak',
name: 'Use SNEAK',
hint: 'Move &le; half Speed, cover at start &amp; end',
needsRoll: true,
rollLabel: 'SNEAK &mdash; select the outcome (Stealth vs Perception DC):',
outcomes: [
{ label: '&#x2713; Success', newState: 'UNDETECTED', msg: 'You move silently. You remain undetected.' },
{ label: '&#x2717; Failure', newState: 'HIDDEN', msg: 'You make some noise. Opponents now know your general area but not your exact square.' },
{ label: '&#x2717;&#x2717; Crit Fail', newState: 'OBSERVED', msg: 'You completely give away your position. You are spotted! (If invisible, you become Hidden instead.)' },
],
},
{
id: 'seek',
name: 'Someone SEEKs me',
hint: 'Opponent rolls Perception vs your Stealth DC (GM rolls secretly)',
needsRoll: true,
rollLabel: 'SEEK &mdash; select the outcome (their Perception vs your Stealth DC):',
outcomes: [
{ label: '&#x2713;&#x2713; Crit Success', newState: 'OBSERVED', msg: 'The seeker pinpoints you precisely. You are now fully observed.' },
{ label: '&#x2713; Success', newState: 'HIDDEN', msg: 'The seeker narrows down your location. You are now hidden &mdash; they know the general area.' },
{ label: '&#x2717; Failure', newState: 'UNDETECTED', msg: 'The seeker fails to find you. You remain undetected.' },
],
},
{
id: 'speak',
name: 'Speak or make a loud noise',
hint: 'Anything that reveals your presence',
needsRoll: false,
newState: 'OBSERVED',
msg: 'Sound reveals your location. You are now observed.',
},
],
UNNOTICED: [
{
id: 'seek',
name: 'Someone SEEKs the area',
hint: 'Rare &mdash; seeker sweeps without knowing you\'re there',
needsRoll: true,
rollLabel: 'SEEK &mdash; select the outcome (their Perception vs your Stealth DC):',
outcomes: [
{ label: '&#x2713;&#x2713; Crit Success', newState: 'OBSERVED', msg: 'The seeker detects you completely. You are now observed.' },
{ label: '&#x2713; Success', newState: 'HIDDEN', msg: 'The seeker notices something. You are now hidden &mdash; they know the general area.' },
{ label: '&#x2717; Failure', newState: 'UNNOTICED', msg: 'The seeker notices nothing unusual. You remain unnoticed.' },
],
},
{
id: 'speak',
name: 'Speak or make a loud noise',
hint: 'Anything that reveals your existence',
needsRoll: false,
newState: 'OBSERVED',
msg: 'Sound reveals your presence. You are now observed.',
},
{
id: 'nonstealthy',
name: 'Non-stealthy action',
hint: 'Attack, cast a spell, or act openly',
needsRoll: false,
newState: 'OBSERVED',
msg: 'Acting openly reveals you. You are now observed.',
},
],
};
let simCurrentState = 'OBSERVED';
let simHistory = [];
let simPendingAction = null;
function simSetState(state) {
simCurrentState = state;
simHistory = [{ state }];
simPendingAction = null;
simRender();
}
function simReset() { simSetState('OBSERVED'); }
function toggleSidebar() {
const sidebar = document.getElementById('sidebar');
const overlay = document.getElementById('sidebar-overlay');
sidebar.classList.toggle('open');
overlay.classList.toggle('active');
}
function closeSidebar() {
document.getElementById('sidebar').classList.remove('open');
document.getElementById('sidebar-overlay').classList.remove('active');
}
function simSelectAction(id) {
const action = SIM_ACTIONS[simCurrentState].find(a => a.id === id);
if (!action) return;
simPendingAction = action;
document.getElementById('sim-result-container').innerHTML = '';
if (!action.needsRoll) {
simApplyTransition(action.newState, action.name, action.msg);
} else {
simRenderActionHighlight(id);
simRenderOutcomes(action);
}
}
function simSelectOutcome(idx) {
if (!simPendingAction) return;
const o = simPendingAction.outcomes[idx];
simApplyTransition(o.newState, simPendingAction.name + ' \u2014 ' + o.label.replace(/&#x[\da-f]+;/gi, s => { const t = document.createElement('textarea'); t.innerHTML = s; return t.value; }), o.msg);
}
function simApplyTransition(newState, actionLabel, msg) {
simCurrentState = newState;
simHistory.push({ state: newState, via: actionLabel });
simPendingAction = null;
simRenderWithResult(newState, msg);
}
function simRender() {
simRenderStateBox();
simRenderActions();
document.getElementById('sim-outcomes-section').style.display = 'none';
document.getElementById('sim-result-container').innerHTML = '';
simRenderHistory();
}
function simRenderWithResult(newState, msg) {
simRenderStateBox();
simRenderActions();
document.getElementById('sim-outcomes-section').style.display = 'none';
const info = SIM_STATES[newState];
document.getElementById('sim-result-container').innerHTML =
'<div class="sim-result-box ' + info.cssClass + '">' +
'<div class="sim-result-top">' +
'<span style="color:var(--text-muted);font-size:1.1rem;">\u2192</span>' +
'<span class="sim-result-new-state">' + info.label + '</span>' +
'</div>' +
'<div class="sim-result-msg">' + msg + '</div>' +
'</div>';
simRenderHistory();
}
function simRenderStateBox() {
const info = SIM_STATES[simCurrentState];
const box = document.getElementById('sim-current-box');
box.className = 'sim-box ' + info.cssClass;
document.getElementById('sim-state-name').textContent = info.label;
document.getElementById('sim-state-effects').innerHTML = info.effects;
}
function simRenderActions() {
const actions = SIM_ACTIONS[simCurrentState];
document.getElementById('sim-actions-list').innerHTML = actions.map(a =>
'<button class="sim-action" id="sim-act-' + a.id + '" onclick="simSelectAction(\'' + a.id + '\')">' +
'<div class="sim-action-name">' + a.name + '</div>' +
'<div class="sim-action-hint">' + a.hint + '</div>' +
'</button>'
).join('');
}
function simRenderActionHighlight(id) {
document.querySelectorAll('.sim-action').forEach(b => b.classList.remove('selected'));
const btn = document.getElementById('sim-act-' + id);
if (btn) btn.classList.add('selected');
}
function simRenderOutcomes(action) {
const section = document.getElementById('sim-outcomes-section');
section.style.display = 'block';
document.getElementById('sim-outcomes-head').innerHTML = action.rollLabel;
document.getElementById('sim-outcomes-list').innerHTML = action.outcomes.map((o, i) =>
'<button class="sim-outcome" onclick="simSelectOutcome(' + i + ')">' + o.label + '</button>'
).join('');
}
function simRenderHistory() {
const trail = document.getElementById('sim-history-trail');
let html = '';
simHistory.forEach((entry, i) => {
const info = SIM_STATES[entry.state];
if (i > 0 && entry.via) {
const shortVia = entry.via.split('\u2014')[0].trim();
html += '<span class="sim-hist-via">\u2192 ' + shortVia + ' \u2192</span> ';
}
html += '<span class="sim-hist-state ' + info.cssClass + '">' + info.label + '</span> ';
});
trail.innerHTML = html || '<span style="color:var(--text-muted);font-style:italic;font-size:0.78rem;">No actions yet</span>';
}
</script>
</body>
</html>