add interactive Situation Simulator tab

Third tab lets users step through detection state changes by choosing
Hide, Sneak, or Seek actions and selecting roll outcomes to see how
the Observed/Hidden/Undetected/Unnoticed state evolves, with a
history trail and start-as state picker.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
Matthew Huntington 1 month ago
parent 2eef14f2c2
commit ea244bc4f2

@ -274,6 +274,209 @@
}
.note-box li { margin-bottom: 4px; }
.note-box strong { color: var(--accent); }
/* ── Simulator Tab ── */
.sim-start-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.sim-start-label {
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
}
.sim-chip {
padding: 5px 14px;
border-radius: 20px;
border: 1px solid;
background: transparent;
cursor: pointer;
font-family: inherit;
font-size: 0.78rem;
font-weight: bold;
letter-spacing: 0.05em;
transition: opacity 0.15s;
}
.sim-chip:hover { opacity: 0.7; }
.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 {
border-radius: 7px;
border: 2px solid;
padding: 18px 22px;
margin-bottom: 22px;
transition: background 0.25s, border-color 0.25s;
}
.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-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--text-muted);
margin-bottom: 4px;
}
.sim-state-name {
font-size: 1.7rem;
font-weight: bold;
letter-spacing: 0.06em;
text-transform: uppercase;
margin-bottom: 6px;
}
.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-state-effects {
font-size: 0.8rem;
color: var(--text);
line-height: 1.6;
}
.sim-section-head {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-muted);
margin-bottom: 10px;
}
.sim-actions-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 22px;
}
.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-action:hover { border-color: var(--accent); color: var(--accent); }
.sim-action.selected { border-color: var(--accent); background: #1e1608; color: var(--accent); }
.sim-action-name { font-weight: bold; margin-bottom: 3px; }
.sim-action-hint { font-size: 0.7rem; color: var(--text-muted); }
.sim-action.selected .sim-action-hint { color: #906c38; }
.sim-outcomes-box {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 6px;
padding: 14px 18px;
margin-bottom: 22px;
}
.sim-outcomes-head {
font-size: 0.78rem;
color: var(--text-muted);
margin-bottom: 10px;
}
.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-outcome:hover { border-color: var(--accent); color: var(--accent); }
.sim-result-box {
border-radius: 6px;
border: 1px solid;
padding: 14px 18px;
margin-bottom: 22px;
animation: simFadeIn 0.25s ease;
}
@keyframes simFadeIn {
from { opacity: 0; transform: translateY(5px); }
to { opacity: 1; transform: translateY(0); }
}
.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-top { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; }
.sim-result-new-state {
font-size: 1.05rem;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.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-result-msg { font-size: 0.8rem; color: var(--text); line-height: 1.6; }
.sim-bottom-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
margin-top: 4px;
}
.sim-history-wrap { flex: 1; min-width: 0; }
.sim-history-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-muted);
margin-bottom: 6px;
}
.sim-history-trail {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
font-size: 0.78rem;
min-height: 1.4em;
}
.sim-hist-state { font-weight: bold; }
.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); }
.sim-hist-via { color: var(--text-muted); font-size: 0.7rem; font-style: italic; }
.sim-reset-btn {
padding: 7px 18px;
background: var(--surface2);
border: 1px solid var(--border);
color: var(--text-muted);
border-radius: 4px;
cursor: pointer;
font-family: inherit;
font-size: 0.8rem;
white-space: nowrap;
transition: color 0.15s, border-color 0.15s;
}
.sim-reset-btn:hover { color: var(--text); border-color: var(--text-muted); }
</style>
</head>
<body>
@ -337,6 +540,7 @@
<div class="tab-bar">
<button class="tab-btn active" onclick="showTab('stealth', this)">Stealth Actions</button>
<button class="tab-btn" onclick="showTab('detection', this)">Detection &amp; Targeting</button>
<button class="tab-btn" onclick="showTab('simulator', this)">Situation Simulator</button>
</div>
<!-- Stealth Tab -->
@ -511,6 +715,54 @@ flowchart TD
</ul>
</div>
</div>
<!-- Simulator Tab -->
<div id="simulator" class="tab-panel">
<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>
@ -536,6 +788,7 @@ flowchart TD
'Diagram error in #' + id + ':\n' + (e.message || e) + '</pre>';
}
}
simSetState('OBSERVED');
});
function showTab(name, btn) {
@ -544,6 +797,276 @@ flowchart TD
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. You are <strong>off-guard</strong> to them. 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 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>

Loading…
Cancel
Save