Files
Space-Game/archive/legacy-static/js/pages/agents.js
francy51 316a44661b Restructure into pnpm monorepo with game shell, docs, and SpacetimeDB backend
- Restructure flat static prototype into pnpm workspace monorepo
- apps/game: playable shell with R3F 3D scene, HUD, SpacetimeDB connection
- apps/docs: design docs and prototypes
- apps/site: landing page
- packages/ui: shared Button and Panel primitives
- services/spacetimedb: backend module (9 tables, 11 reducers)
- Archive legacy static files to archive/legacy-static/
- Game loop: connect, undock, target, approach, dock, mine, sell
- Add pnpm-workspace.yaml, tsconfig.base.json, spacetime.json
2026-05-31 17:56:56 -04:00

674 lines
43 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
window.GDD = window.GDD || {};
const { useState } = React;
/* ── Shared inline styles ── */
const mono = { fontFamily: 'var(--font-mono)' };
const dimText = { color: 'var(--fg-dim)', fontSize: '0.9rem' };
const dimSmall = { color: 'var(--fg-dim)', fontSize: '0.82rem' };
/* ── Small sub-components ── */
function AgentLifecycleDiagram() {
const phases = [
{ id: 'register', label: 'REGISTER', color: 'var(--purple)', desc: 'Scheduled table row inserted with initial interval, reducer target, and payload.' },
{ id: 'sleep', label: 'SLEEP', color: 'var(--muted)', desc: 'Row sits inert. No CPU, no allocation. SpacetimeDB\'s scheduler owns the timer.' },
{ id: 'wake', label: 'WAKE', color: 'var(--accent)', desc: 'Scheduler fires the bound reducer at the scheduled timestamp. Transaction begins.' },
{ id: 'execute', label: 'EXECUTE', color: 'var(--cyan)', desc: 'Reducer logic runs: read tables, mutate state, emit events, maybe re-schedule itself.' },
{ id: 'complete', label: 'COMPLETE', color: 'var(--green)', desc: 'Transaction commits or aborts. On commit, row updated (next_fire_at) or deleted. Subscribers notified.' },
];
return (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 'var(--sp-2)', overflowX: 'auto', padding: 'var(--sp-4) 0' }}>
{phases.map((p, i) => (
<React.Fragment key={p.id}>
<div style={{
flex: '1 1 0', minWidth: '140px',
background: 'var(--surface-raised)',
border: `1px solid var(--border)`,
borderRadius: 'var(--radius-lg)',
padding: 'var(--sp-4)',
borderTop: `3px solid ${p.color}`,
}}>
<div style={{ ...mono, fontSize: '0.7rem', color: p.color, letterSpacing: '0.06em', marginBottom: 'var(--sp-2)' }}>
{p.label}
</div>
<p style={{ ...dimSmall, margin: 0, lineHeight: 1.5 }}>
{p.desc}
</p>
</div>
{i < phases.length - 1 && (
<div style={{ display: 'flex', alignItems: 'center', color: 'var(--border-light)', fontSize: '1.2rem', paddingTop: '28px' }}>
</div>
)}
</React.Fragment>
))}
</div>
);
}
function ScheduleDiagram() {
return (
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--radius-lg)', padding: 'var(--sp-5)', marginBottom: 'var(--sp-5)' }}>
{/* Timeline visualization */}
<div style={{ position: 'relative', height: '120px', marginBottom: 'var(--sp-4)' }}>
{/* Time axis */}
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: '2px', background: 'var(--border-light)' }} />
<div style={{ position: 'absolute', bottom: '-18px', left: 0, ...mono, fontSize: '0.65rem', color: 'var(--muted)' }}>t=0</div>
<div style={{ position: 'absolute', bottom: '-18px', right: 0, ...mono, fontSize: '0.65rem', color: 'var(--muted)' }}>t+5min</div>
{/* Fixed-interval ticks */}
{[0.15, 0.35, 0.55, 0.75, 0.92].map((pct, i) => (
<React.Fragment key={`fixed-${i}`}>
<div style={{ position: 'absolute', bottom: 0, left: `${pct * 100}%`, width: '2px', height: '10px', background: 'var(--accent)' }} />
<div style={{
position: 'absolute', bottom: '14px', left: `${pct * 100}%`, transform: 'translateX(-50%)',
width: '8px', height: '8px', borderRadius: '50%', background: 'var(--accent)',
boxShadow: '0 0 8px rgba(240,160,48,0.4)',
}} />
</React.Fragment>
))}
{/* Conditional ticks */}
{[0.45, 0.78].map((pct, i) => (
<React.Fragment key={`cond-${i}`}>
<div style={{ position: 'absolute', bottom: 0, left: `${pct * 100}%`, width: '2px', height: '10px', background: 'var(--cyan)' }} />
<div style={{
position: 'absolute', top: '10px', left: `${pct * 100}%`, transform: 'translateX(-50%)',
width: '8px', height: '8px', borderRadius: '50%', background: 'var(--cyan)',
boxShadow: '0 0 8px rgba(34,211,238,0.4)',
}} />
</React.Fragment>
))}
{/* One-shot */}
<div style={{ position: 'absolute', bottom: 0, left: '60%', width: '2px', height: '10px', background: 'var(--purple)' }} />
<div style={{
position: 'absolute', top: '40px', left: '60%', transform: 'translateX(-50%)',
width: '10px', height: '10px', borderRadius: '50%', background: 'var(--purple)',
boxShadow: '0 0 8px rgba(167,139,250,0.4)',
}} />
{/* Labels */}
<div style={{ position: 'absolute', top: 0, left: 0, display: 'flex', gap: 'var(--sp-6)', ...mono, fontSize: '0.65rem' }}>
<span style={{ color: 'var(--accent)' }}> Fixed interval</span>
<span style={{ color: 'var(--cyan)' }}> Conditional</span>
<span style={{ color: 'var(--purple)' }}> One-shot</span>
</div>
</div>
</div>
);
}
/* ── Main page component ── */
function AgentsPage() {
const [activeSection, setActiveSection] = useState('lifecycle');
const [activeCatalogPkg, setActiveCatalogPkg] = useState('game');
const tabs = [
{ id: 'lifecycle', label: 'Lifecycle' },
{ id: 'scheduling', label: 'Scheduling' },
{ id: 'killswitch', label: 'Kill-Switch' },
{ id: 'catalog', label: 'Agent Catalog' },
];
return (
<div className="content-inner">
<h1 style={{ marginBottom: '8px' }}>Agent Lifecycle &amp; Scheduling</h1>
<p style={{ ...dimText, maxWidth: '720px' }}>
The server runs dozens of background agents that drive the living world from enemy health regeneration
and building decay to NPC trade migrations and day/night cycles. These agents are not OS-level processes
or threads; they are <strong style={{ color: 'var(--fg-bright)' }}>SpacetimeDB scheduled tables</strong> that
invoke reducer functions at configurable intervals, turning the database itself into a reliable task scheduler.
</p>
{/* Section tabs */}
<div style={{ display: 'flex', gap: 'var(--sp-2)', marginBottom: 'var(--sp-6)', marginTop: 'var(--sp-4)' }}>
{tabs.map(t => (
<button key={t.id}
className={`btn btn-sm${activeSection === t.id ? ' btn-primary' : ''}`}
onClick={() => setActiveSection(t.id)}>
{t.label}
</button>
))}
</div>
{/* ────────────────────── LIFECYCLE ────────────────────── */}
{activeSection === 'lifecycle' && (
<>
<div className="section-header">
<span className="section-num">AGENT-1</span>
<h2 style={{ margin: 0 }}>Uniform Lifecycle</h2>
</div>
<p style={{ ...dimText, marginBottom: 'var(--sp-4)' }}>
Every agent regardless of what it simulates follows the same five-phase lifecycle.
This uniformity means monitoring, debugging, and load-testing share one toolchain.
</p>
<AgentLifecycleDiagram />
<div className="section-header" style={{ marginTop: 'var(--sp-8)' }}>
<span className="section-num">AGENT-1.1</span>
<h2 style={{ margin: 0 }}>Scheduled Table Schema</h2>
</div>
<p style={{ ...dimText, marginBottom: 'var(--sp-4)' }}>
Each agent is a row in a <code style={{ color: 'var(--accent)' }}>scheduled_agents</code> table.
SpacetimeDB's built-in scheduler reads <code>next_fire_at</code> and invokes the
bound reducer at exactly that timestamp — no external cron, no process supervisor.
</p>
<div style={{ overflowX: 'auto' }}>
<table className="data-table">
<thead>
<tr><th>Column</th><th>Type</th><th>Purpose</th></tr>
</thead>
<tbody>
{[
{ col: 'agent_id', type: 'Identity', purpose: 'Unique agent instance key. Auto-generated on insert.' },
{ col: 'agent_type', type: 'String', purpose: 'Catalog key: "enemy_regen", "decay_tick", "npc_trade", "day_cycle", etc.' },
{ col: 'target_entity_id', type: 'u64', purpose: 'Optional entity this agent operates on (e.g. enemy_id, building_id).' },
{ col: 'reducer_name', type: 'String', purpose: 'Fully-qualified reducer to invoke: "game.enemy_regen_tick".' },
{ col: 'interval_ms', type: 'u64', purpose: 'Fixed interval in milliseconds. Zero for one-shot agents.' },
{ col: 'next_fire_at', type: 'Duration', purpose: 'Timestamp of next invocation. Scheduler reads this; agent never touches it directly after registration.' },
{ col: 'payload', type: 'JSON', purpose: 'Opaque reducer arguments. Each agent type defines its own schema.' },
{ col: 'state', type: 'enum', purpose: 'active | paused | killed. Only "active" rows are scheduled.' },
{ col: 'generation', type: 'u32', purpose: 'Monotonic counter incremented on each fire. Prevents stale re-schedules.' },
{ col: 'created_at', type: 'Timestamp', purpose: 'Registration time. Used for uptime metrics and TTL enforcement.' },
{ col: 'owner_module', type: 'String', purpose: 'Package that owns this agent: "game" or "global_module".' },
].map((row, i) => (
<tr key={i}>
<td><code style={{ fontSize: '0.75rem' }}>{row.col}</code></td>
<td><span className="pill pill-cyan" style={{ fontSize: '0.65rem' }}>{row.type}</span></td>
<td style={{ ...dimSmall }}>{row.purpose}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="section-header" style={{ marginTop: 'var(--sp-8)' }}>
<span className="section-num">AGENT-1.2</span>
<h2 style={{ margin: 0 }}>Registration Reducer</h2>
</div>
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
<div style={{ padding: 'var(--sp-3) var(--sp-4)', background: 'var(--surface-raised)', borderBottom: '1px solid var(--border)' }}>
<span style={{ ...mono, fontSize: '0.7rem', color: 'var(--accent)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
register_agent — Pseudocode
</span>
</div>
<div className="code-block" style={{ margin: 0, borderRadius: 0 }}>
<code>
<span className="cm">// Called by game logic to spawn a new agent</span><br/>
<span className="kw">reducer</span> <span className="fn">register_agent</span>(ctx, agent_type, target_id, interval_ms, payload) {'{'}<br/>
&nbsp;&nbsp;<span className="cm">// Validate agent_type exists in agent_catalog</span><br/>
&nbsp;&nbsp;<span className="kw">let</span> catalog_entry = ctx.db.agent_catalog().<span className="fn">find</span>(agent_type);<br/>
&nbsp;&nbsp;<span className="kw">require</span>!(catalog_entry.is_some(), <span className="str">"Unknown agent type"</span>);<br/>
<br/>
&nbsp;&nbsp;<span className="cm">// Enforce per-type concurrency limit</span><br/>
&nbsp;&nbsp;<span className="kw">let</span> active_count = ctx.db.scheduled_agents().<span className="fn">iter</span>()<br/>
&nbsp;&nbsp;&nbsp;&nbsp;.filter(|a| a.agent_type == agent_type && a.state == Active).count();<br/>
&nbsp;&nbsp;<span className="kw">require</span>!(active_count {'<'} catalog_entry.max_concurrent);<br/>
<br/>
&nbsp;&nbsp;ctx.db.scheduled_agents().<span className="fn">insert</span>(ScheduledAgent {'{'}<br/>
&nbsp;&nbsp;&nbsp;&nbsp;agent_id: Identity::generate(),<br/>
&nbsp;&nbsp;&nbsp;&nbsp;agent_type,<br/>
&nbsp;&nbsp;&nbsp;&nbsp;target_entity_id: target_id,<br/>
&nbsp;&nbsp;&nbsp;&nbsp;reducer_name: catalog_entry.reducer_name,<br/>
&nbsp;&nbsp;&nbsp;&nbsp;interval_ms,<br/>
&nbsp;&nbsp;&nbsp;&nbsp;next_fire_at: ctx.timestamp + interval_ms,<br/>
&nbsp;&nbsp;&nbsp;&nbsp;payload,<br/>
&nbsp;&nbsp;&nbsp;&nbsp;state: AgentState::Active,<br/>
&nbsp;&nbsp;&nbsp;&nbsp;generation: 0,<br/>
&nbsp;&nbsp;&nbsp;&nbsp;created_at: ctx.timestamp,<br/>
&nbsp;&nbsp;&nbsp;&nbsp;owner_module: catalog_entry.module,<br/>
&nbsp;&nbsp;{'}'});<br/>
{'}'}
</code>
</div>
</div>
<div className="section-header" style={{ marginTop: 'var(--sp-8)' }}>
<span className="section-num">AGENT-1.3</span>
<h2 style={{ margin: 0 }}>Execution Contract</h2>
</div>
<div className="callout callout-warn" style={{ marginBottom: 'var(--sp-5)' }}>
<strong>Atomicity guarantee:</strong> each agent fire runs inside a single SpacetimeDB transaction.
If the reducer panics or returns an error, the entire transaction rolls back — no partial state mutation.
The agent row is not updated, so the scheduler retries at the same <code>next_fire_at</code> on the next tick.
</div>
<div className="grid-3">
{[
{ title: 'Read phase', color: 'var(--cyan)', items: [
'Load agent row + payload from scheduled_agents',
'Read target entity state (enemy HP, building condition, NPC inventory)',
'Query related tables (faction relations, player density, market state)',
]},
{ title: 'Mutate phase', color: 'var(--accent)', items: [
'Apply simulation logic (regen HP, decay condition, move NPC, rotate cycle)',
'Write updated entity state back to tables',
'Maybe emit world events, story log entries, or chat notifications',
]},
{ title: 'Re-schedule phase', color: 'var(--green)', items: [
'Increment generation counter (prevents double-fire)',
'Set next_fire_at = now + interval_ms (fixed) or compute next (conditional)',
'Or delete the row entirely (one-shot or TTL expired)',
]},
].map((phase, i) => (
<div key={i} className="card" style={{ borderTop: `3px solid ${phase.color}` }}>
<h4 style={{ color: phase.color, marginBottom: 'var(--sp-3)' }}>{phase.title}</h4>
<ul style={{ ...dimSmall, margin: 0, paddingLeft: 'var(--sp-5)', lineHeight: 1.7 }}>
{phase.items.map((item, j) => <li key={j}>{item}</li>)}
</ul>
</div>
))}
</div>
</>
)}
{/* ────────────────────── SCHEDULING ────────────────────── */}
{activeSection === 'scheduling' && (
<>
<div className="section-header">
<span className="section-num">AGENT-2</span>
<h2 style={{ margin: 0 }}>Three Scheduling Strategies</h2>
</div>
<p style={{ ...dimText, marginBottom: 'var(--sp-5)' }}>
All agents share the same table schema, but <em>when</em> they fire follows one of three
strategies. The choice is baked into the agent's catalog entry and cannot be changed at
runtime without re-registration.
</p>
<ScheduleDiagram />
{/* Strategy cards */}
{[
{
id: 'fixed',
title: 'Fixed Interval',
color: 'var(--accent)',
tag: 'Most common',
desc: 'Fire every N milliseconds, unconditionally. The workhorse strategy — used for health regen, resource ticks, decay timers, and day/night cycles.',
mechanics: [
'next_fire_at = last_fire_at + interval_ms — simple arithmetic, no conditional logic.',
'If the server is overloaded and fires late, the next fire is still interval_ms from the actual fire time (not from the scheduled time). Prevents cascade acceleration.',
'Configurable jitter: ±10% of interval_ms to spread simultaneous agent fires across a window. Prevents thundering-herd CPU spikes when 200 enemies share the same regen tick.',
],
examples: 'enemy_regen (2s), building_decay (60s), resource_respawn (300s), day_night_cycle (600s)',
},
{
id: 'conditional',
title: 'Conditional',
color: 'var(--cyan)',
tag: 'Event-gated',
desc: 'Fire only when a precondition table meets criteria. The scheduler still checks on a fixed cadence, but the reducer body is a no-op until the condition is true.',
mechanics: [
'The reducer reads a condition table first (e.g. "has at least one player in system?"). If false, it re-schedules without doing work.',
'Avoids wasting cycles on empty systems: NPC trade migrations only run in systems with active markets and at least one player docked.',
'Condition tables are indexed. The read cost of a no-op conditional fire is ~microseconds.',
],
examples: 'npc_trade_route (fires only if market activity above threshold), faction_tension_eval (fires only if two factions share a border system with standing < -5)',
},
{
id: 'oneshot',
title: 'One-Shot',
color: 'var(--purple)',
tag: 'Delayed execution',
desc: 'Fire exactly once after a delay, then self-delete. Used for delayed consequences, timed explosions, cooldown expirations, and cascading event chains.',
mechanics: [
'interval_ms = 0 in the row. The scheduler fires at next_fire_at and the reducer deletes the row.',
'No re-schedule step — the agent simply ceases to exist after execution.',
'Can be re-registered by the reducer itself or by another agent, creating event chains: "pirate_raid_warning fires → 30s later pirate_raid_arrival fires → raid logic runs".',
],
examples: 'delayed_damage (3s after mine explosion), event_cascade (fires next event in a story chain), cooldown_expire (marks ability ready again)',
},
].map((strategy) => (
<div key={strategy.id} className="card" style={{ borderTop: `3px solid ${strategy.color}`, marginBottom: 'var(--sp-5)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--sp-3)', marginBottom: 'var(--sp-4)' }}>
<h3 style={{ margin: 0, color: strategy.color }}>{strategy.title}</h3>
<span className="pill pill-amber" style={{ fontSize: '0.65rem' }}>{strategy.tag}</span>
</div>
<p style={{ ...dimText, marginBottom: 'var(--sp-4)' }}>{strategy.desc}</p>
<h4 style={{ color: 'var(--fg-bright)', fontSize: '0.85rem', marginBottom: 'var(--sp-2)' }}>Mechanics</h4>
<ul style={{ ...dimSmall, margin: 0, paddingLeft: 'var(--sp-5)', lineHeight: 1.7, marginBottom: 'var(--sp-4)' }}>
{strategy.mechanics.map((m, i) => <li key={i}>{m}</li>)}
</ul>
<div style={{ ...mono, fontSize: '0.75rem', color: 'var(--muted)', borderTop: '1px solid var(--border)', paddingTop: 'var(--sp-3)' }}>
<span style={{ color: 'var(--fg-dim)' }}>Examples:</span> {strategy.examples}
</div>
</div>
))}
<div className="section-header" style={{ marginTop: 'var(--sp-6)' }}>
<span className="section-num">AGENT-2.1</span>
<h2 style={{ margin: 0 }}>Jitter &amp; Thundering Herd Prevention</h2>
</div>
<div className="callout callout-info" style={{ marginBottom: 'var(--sp-5)' }}>
When 500 enemy entities all share a 2-second regen interval, they were likely registered at the same time meaning
all 500 fire simultaneously. Jitter spreads each agent's <code>next_fire_at</code> by a random ±X% of its interval,
turning a single CPU spike into a smooth load curve.
</div>
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
<div style={{ padding: 'var(--sp-3) var(--sp-4)', background: 'var(--surface-raised)', borderBottom: '1px solid var(--border)' }}>
<span style={{ ...mono, fontSize: '0.7rem', color: 'var(--accent)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
Jitter Application — Pseudocode
</span>
</div>
<div className="code-block" style={{ margin: 0, borderRadius: 0 }}>
<code>
<span className="kw">fn</span> <span className="fn">apply_jitter</span>(base_interval_ms: <span className="type">u64</span>, jitter_pct: <span className="type">f32</span>) -> <span className="type">Duration</span> {'{'}<br/>
&nbsp;&nbsp;<span className="kw">let</span> jitter_range = (base_interval_ms <span className="kw">as</span> <span className="type">f64</span>) * (jitter_pct <span className="kw">as</span> <span className="type">f64</span>);<br/>
&nbsp;&nbsp;<span className="kw">let</span> offset = thread_rng().<span className="fn">gen_range</span>(-jitter_range..jitter_range);<br/>
&nbsp;&nbsp;Duration::from_millis((base_interval_ms <span className="kw">as</span> <span className="type">f64</span> + offset) <span className="kw">as</span> <span className="type">u64</span>)<br/>
{'}'}<br/>
<br/>
<span className="cm">// Applied during registration and each re-schedule:</span><br/>
agent.next_fire_at = ctx.timestamp + <span className="fn">apply_jitter</span>(agent.interval_ms, <span className="str">0.10</span>);
</code>
</div>
</div>
</>
)}
{/* ────────────────────── KILL-SWITCH ────────────────────── */}
{activeSection === 'killswitch' && (
<>
<div className="section-header">
<span className="section-num">AGENT-3</span>
<h2 style={{ margin: 0 }}>Global Kill-Switch</h2>
</div>
<p style={{ ...dimText, marginBottom: 'var(--sp-5)' }}>
A single admin-level toggle can pause or terminate every agent of a given type — or all agents
across the entire server. This is the primary operational lever for hotfixes, load shedding,
and emergency maintenance.
</p>
<div className="card" style={{ borderTop: '3px solid var(--red)', marginBottom: 'var(--sp-5)' }}>
<div style={{ display: 'flex', gap: 'var(--sp-5)', alignItems: 'center' }}>
<div style={{ flex: 1 }}>
<h4 style={{ color: 'var(--red)', marginBottom: 'var(--sp-2)' }}>Emergency Shutdown</h4>
<p style={{ ...dimSmall, margin: 0 }}>
A single reducer <code style={{ color: 'var(--red)' }}>kill_all_agents</code> iterates every row
in <code>scheduled_agents</code> and sets <code>state = Killed</code>. The scheduler skips killed
rows entirely. No reducers fire. No CPU. Agents can be selectively revived by type or module.
</p>
</div>
<div style={{ ...mono, fontSize: '0.75rem', color: 'var(--red)', background: 'var(--red-bg)', padding: 'var(--sp-3) var(--sp-4)', borderRadius: 'var(--radius-md)', whiteSpace: 'nowrap', border: '1px solid rgba(239,68,68,0.25)' }}>
⬛ KILL ALL
</div>
</div>
</div>
<div className="section-header" style={{ marginTop: 'var(--sp-6)' }}>
<span className="section-num">AGENT-3.1</span>
<h2 style={{ margin: 0 }}>Kill-Switch Mechanics</h2>
</div>
<div style={{ overflowX: 'auto' }}>
<table className="data-table">
<thead>
<tr><th>Operation</th><th>Scope</th><th>Behavior</th></tr>
</thead>
<tbody>
{[
{ op: 'kill_all_agents()', scope: 'Server-wide', behavior: 'Sets state=Killed on every row. Scheduler skips all. Agents retain their payload and next_fire_at for potential revival.' },
{ op: 'kill_agents_by_type(type)', scope: 'Type-wide', behavior: 'Kills only agents matching agent_type. Used when one simulation is misbehaving (e.g. enemy_regen running amok) without touching NPC trades.' },
{ op: 'kill_agents_by_module(module)', scope: 'Package-wide', behavior: 'Kills all agents owned by a module ("game" or "global_module"). Allows safe module hot-reload.' },
{ op: 'pause_agent(agent_id)', scope: 'Single agent', behavior: 'Sets state=Paused. Scheduler skips but row persists. Used to suspend a specific NPC\'s trade route.' },
{ op: 'revive_agent(agent_id)', scope: 'Single agent', behavior: 'Sets state=Active, recalculates next_fire_at from now. Agent resumes as if freshly registered.' },
{ op: 'revive_all_by_type(type)', scope: 'Type-wide', behavior: 'Revives all killed/paused agents of a type. Re-applies jitter to prevent synchronized firing.' },
].map((row, i) => (
<tr key={i}>
<td><code style={{ fontSize: '0.75rem', color: 'var(--accent)' }}>{row.op}</code></td>
<td><span className="pill pill-purple">{row.scope}</span></td>
<td style={{ ...dimSmall }}>{row.behavior}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="section-header" style={{ marginTop: 'var(--sp-8)' }}>
<span className="section-num">AGENT-3.2</span>
<h2 style={{ margin: 0 }}>Safety Properties</h2>
</div>
<div className="grid-2">
{[
{
title: 'No orphan state',
color: 'var(--green)',
desc: 'When an agent is killed mid-execution (transaction aborted), the entity it was modifying rolls back. No enemy is half-healed, no building is half-decayed. The atomicity guarantee ensures killed agents leave zero partial state.',
},
{
title: 'Revival preserves payload',
color: 'var(--cyan)',
desc: 'Killed agents retain their payload, target_entity_id, and interval_ms. Revival simply flips state back to Active and recalculates next_fire_at. The agent continues as if it had paused — no re-registration needed.',
},
{
title: 'Cascade protection',
color: 'var(--accent)',
desc: 'If agent A fires and registers agent B (event chain), killing agent A does NOT kill agent B. Each agent row is independent. Kill operations are explicitly scoped — cascade kills require a custom reducer.',
},
{
title: 'Generation guard',
color: 'var(--purple)',
desc: 'Each fire increments a generation counter. If a kill arrives between the scheduler reading the row and the reducer executing, the generation mismatch aborts the transaction. Prevents zombie fires.',
},
].map((item, i) => (
<div key={i} className="card" style={{ borderLeft: `3px solid ${item.color}` }}>
<h4 style={{ color: item.color, marginBottom: 'var(--sp-2)' }}>{item.title}</h4>
<p style={{ ...dimSmall, margin: 0, lineHeight: 1.6 }}>{item.desc}</p>
</div>
))}
</div>
</>
)}
{/* ────────────────────── CATALOG ────────────────────── */}
{activeSection === 'catalog' && (
<>
<div className="section-header">
<span className="section-num">AGENT-4</span>
<h2 style={{ margin: 0 }}>Agent Catalog</h2>
</div>
<p style={{ ...dimText, marginBottom: 'var(--sp-4)' }}>
Complete inventory of every background agent in the server, organized by owning package.
Each entry defines the reducer to invoke, scheduling strategy, interval, concurrency cap,
and what it simulates.
</p>
{/* Package tabs */}
<div style={{ display: 'flex', gap: 'var(--sp-2)', marginBottom: 'var(--sp-5)' }}>
{[
{ id: 'game', label: 'game', icon: '◉' },
{ id: 'global_module', label: 'global_module', icon: '◈' },
].map(pkg => (
<button key={pkg.id}
className={`btn btn-sm${activeCatalogPkg === pkg.id ? ' btn-primary' : ''}`}
onClick={() => setActiveCatalogPkg(pkg.id)}>
{pkg.icon} {pkg.label}
</button>
))}
</div>
{activeCatalogPkg === 'game' && (
<>
<h4 style={{ color: 'var(--accent)', marginBottom: 'var(--sp-3)' }}>Combat &amp; Entity Agents</h4>
<div style={{ overflowX: 'auto', marginBottom: 'var(--sp-6)' }}>
<table className="data-table">
<thead>
<tr><th>Agent Type</th><th>Strategy</th><th>Interval</th><th>Max Concurrent</th><th>Description</th></tr>
</thead>
<tbody>
{[
{ type: 'enemy_regen', strat: 'fixed', interval: '2s', max: '∞', desc: 'Regenerates enemy shield/armor HP per tick. Payload contains regen_rate, max_hp. Skips if enemy dead.' },
{ type: 'enemy_respawn', strat: 'one-shot', interval: '30120s', max: '∞', desc: 'Delays enemy respawn after death. Fires once, deletes self, inserts new enemy entity row.' },
{ type: 'aggro_scan', strat: 'fixed', interval: '1s', max: '∞', desc: 'Scans nearby players within aggro range. Transitions enemy from idle → combat state.' },
{ type: 'pirate_spawn', strat: 'conditional', interval: '300s', max: '∞', desc: 'Spawns NPC pirates at belts, gates, and anomalies. Condition: player proximity OR world tick in low/null-sec. Respects per-system density cap (10 max).' },
{ type: 'pirate_combat_tick', strat: 'fixed', interval: '1s', max: '1 per engaged NPC', desc: 'Executes NPC behavior template per tick: orbit/approach/kite/flee. Applies damage to target, regenerates shields/armor. Only runs for NPCs in combat state.' },
{ type: 'pirate_loot_drop', strat: 'one-shot', interval: '0s', max: '∞', desc: 'Generates loot and bounty from destroyed NPC. Rolls loot table, awards ISK bounty to top damage contributor, creates wreck with items.' },
{ type: 'loot_decay', strat: 'fixed', interval: '120s', max: '500', desc: 'Counts down loot container lifetime. On expiry, removes container row and frees the slot.' },
{ type: 'pvp_session_timer', strat: 'one-shot', interval: 'varies', max: '∞', desc: 'Tracks PvP engagement timers (combat flags, weapons timers). Fires on expiry to clear flags.' },
].map((row, i) => (
<tr key={i}>
<td><code style={{ fontSize: '0.75rem' }}>{row.type}</code></td>
<td><span className={`pill ${row.strat === 'fixed' ? 'pill-amber' : row.strat === 'one-shot' ? 'pill-purple' : 'pill-cyan'}`}>{row.strat}</span></td>
<td style={{ ...mono, fontSize: '0.8rem' }}>{row.interval}</td>
<td style={{ ...mono, fontSize: '0.8rem' }}>{row.max}</td>
<td style={{ ...dimSmall }}>{row.desc}</td>
</tr>
))}
</tbody>
</table>
</div>
<h4 style={{ color: 'var(--accent)', marginBottom: 'var(--sp-3)' }}>World &amp; Environment Agents</h4>
<div style={{ overflowX: 'auto', marginBottom: 'var(--sp-6)' }}>
<table className="data-table">
<thead>
<tr><th>Agent Type</th><th>Strategy</th><th>Interval</th><th>Max Concurrent</th><th>Description</th></tr>
</thead>
<tbody>
{[
{ type: 'day_night_cycle', strat: 'fixed', interval: '600s', max: '1', desc: 'Advances the global day/night phase. Updates lighting table, triggers fauna behavior shifts, affects stealth detection range.' },
{ type: 'weather_tick', strat: 'fixed', interval: '300s', max: '1 per system', desc: 'Evolves system weather state (clear → storm → nebula haze). Affects sensor range, weapon accuracy, and visual overlay.' },
{ type: 'asteroid_respawn', strat: 'conditional', interval: '300s', max: '1 per belt', desc: 'Checks if belt is below max asteroids. Condition: player count in system > 0 AND asteroid count < belt_capacity.' },
{ type: 'anomaly_lifecycle', strat: 'one-shot', interval: 'varies', max: '1 per anomaly', desc: 'Spawned by world_tick. Self-deletes on expiry, removing the anomaly row and dropping any unresolved loot.' },
{ type: 'fauna_migration', strat: 'fixed', interval: '1800s', max: '1 per species', desc: 'Advances fauna along their migration route. Updates current_system_id, writes story log if entering a populated system.' },
].map((row, i) => (
<tr key={i}>
<td><code style={{ fontSize: '0.75rem' }}>{row.type}</code></td>
<td><span className={`pill ${row.strat === 'fixed' ? 'pill-amber' : row.strat === 'one-shot' ? 'pill-purple' : 'pill-cyan'}`}>{row.strat}</span></td>
<td style={{ ...mono, fontSize: '0.8rem' }}>{row.interval}</td>
<td style={{ ...mono, fontSize: '0.8rem' }}>{row.max}</td>
<td style={{ ...dimSmall }}>{row.desc}</td>
</tr>
))}
</tbody>
</table>
</div>
<h4 style={{ color: 'var(--accent)', marginBottom: 'var(--sp-3)' }}>Structure &amp; Decay Agents</h4>
<div style={{ overflowX: 'auto' }}>
<table className="data-table">
<thead>
<tr><th>Agent Type</th><th>Strategy</th><th>Interval</th><th>Max Concurrent</th><th>Description</th></tr>
</thead>
<tbody>
{[
{ type: 'building_decay', strat: 'fixed', interval: '60s', max: '∞', desc: 'Reduces building condition per tick (0.12% depending on material). At 0%, marks for demolition.' },
{ type: 'fuel_consumption', strat: 'fixed', interval: '300s', max: '1 per structure', desc: 'Deducts fuel from powered structures. If fuel hits 0, disables structure services.' },
{ type: 'production_cycle', strat: 'fixed', interval: 'varies', max: '1 per structure', desc: 'Advances manufacturing jobs. On completion, moves output to structure inventory.' },
{ type: 'reinforcement_timer', strat: 'one-shot', interval: '2472h', max: '1 per structure', desc: 'Delays structure destruction in PvP. Allows owners time to defend. Fires → structure becomes vulnerable.' },
].map((row, i) => (
<tr key={i}>
<td><code style={{ fontSize: '0.75rem' }}>{row.type}</code></td>
<td><span className={`pill ${row.strat === 'fixed' ? 'pill-amber' : row.strat === 'one-shot' ? 'pill-purple' : 'pill-cyan'}`}>{row.strat}</span></td>
<td style={{ ...mono, fontSize: '0.8rem' }}>{row.interval}</td>
<td style={{ ...mono, fontSize: '0.8rem' }}>{row.max}</td>
<td style={{ ...dimSmall }}>{row.desc}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
{activeCatalogPkg === 'global_module' && (
<>
<h4 style={{ color: 'var(--cyan)', marginBottom: 'var(--sp-3)' }}>Economy &amp; Trade Agents</h4>
<div style={{ overflowX: 'auto', marginBottom: 'var(--sp-6)' }}>
<table className="data-table">
<thead>
<tr><th>Agent Type</th><th>Strategy</th><th>Interval</th><th>Max Concurrent</th><th>Description</th></tr>
</thead>
<tbody>
{[
{ type: 'npc_trade_route', strat: 'conditional', interval: '600s', max: '50', desc: 'Evaluates NPC trade route viability. Condition: market activity > threshold AND at least one player docked. Moves goods between stations.' },
{ type: 'market_price_adjust', strat: 'fixed', interval: '300s', max: '1 per item_type', desc: 'Ticks NPC demand pressure algorithm: updates flow_ema, recomputes demand_pressure, applies idle decay. See Economy → NPC Pricing tab for full algorithm spec.' },
{ type: 'bounty_pool_refresh', strat: 'fixed', interval: '3600s', max: '1', desc: 'Refreshes the global bounty pool based on faction military budgets. Allocates bounty value to active threats.' },
{ type: 'insurance_payout', strat: 'one-shot', interval: '30120s', max: '∞', desc: 'Delays ship insurance payout. Fires once, credits ISK to player, deletes self. Prevents instant-PvP-profit loops.' },
].map((row, i) => (
<tr key={i}>
<td><code style={{ fontSize: '0.75rem' }}>{row.type}</code></td>
<td><span className={`pill ${row.strat === 'fixed' ? 'pill-amber' : row.strat === 'one-shot' ? 'pill-purple' : 'pill-cyan'}`}>{row.strat}</span></td>
<td style={{ ...mono, fontSize: '0.8rem' }}>{row.interval}</td>
<td style={{ ...mono, fontSize: '0.8rem' }}>{row.max}</td>
<td style={{ ...dimSmall }}>{row.desc}</td>
</tr>
))}
</tbody>
</table>
</div>
<h4 style={{ color: 'var(--cyan)', marginBottom: 'var(--sp-3)' }}>NPC &amp; Faction Agents</h4>
<div style={{ overflowX: 'auto', marginBottom: 'var(--sp-6)' }}>
<table className="data-table">
<thead>
<tr><th>Agent Type</th><th>Strategy</th><th>Interval</th><th>Max Concurrent</th><th>Description</th></tr>
</thead>
<tbody>
{[
{ type: 'faction_tension_eval', strat: 'conditional', interval: '300s', max: '1 per pair', desc: 'Evaluates if two factions should escalate. Condition: shared border AND standing < -5. Spawns skirmish events if threshold met.' },
{ type: 'npc_patrol_route', strat: 'fixed', interval: '60s', max: '100', desc: 'Advances NPC patrol along waypoints. Updates position, checks for player encounters, may trigger aggro.' },
{ type: 'npc_mission_refresh', strat: 'fixed', interval: '1800s', max: '1 per station', desc: 'Regenerates NPC mission offerings. Removes expired missions, adds new ones from template pool weighted by local faction state.' },
{ type: 'diplomacy_shift', strat: 'fixed', interval: '7200s', max: '1 per pair', desc: 'Slowly drifts faction standing toward baseline. Prevents permanent extreme standings from transient events.' },
{ type: 'concord_response', strat: 'one-shot', interval: '315s', max: '∞', desc: 'Spawns CONCORD fleet at criminal location. Delay based on system security level. Applies overwhelming damage. One-shot: fires, destroys criminal ship, deletes self.' },
{ type: 'security_status_tick', strat: 'fixed', interval: '3600s', max: '1 per player', desc: 'Passive security status recovery. +0.01 per tick for players with no hostile acts in the last hour. Clean record slowly heals.' },
].map((row, i) => (
<tr key={i}>
<td><code style={{ fontSize: '0.75rem' }}>{row.type}</code></td>
<td><span className={`pill ${row.strat === 'fixed' ? 'pill-amber' : row.strat === 'one-shot' ? 'pill-purple' : 'pill-cyan'}`}>{row.strat}</span></td>
<td style={{ ...mono, fontSize: '0.8rem' }}>{row.interval}</td>
<td style={{ ...mono, fontSize: '0.8rem' }}>{row.max}</td>
<td style={{ ...dimSmall }}>{row.desc}</td>
</tr>
))}
</tbody>
</table>
</div>
<h4 style={{ color: 'var(--cyan)', marginBottom: 'var(--sp-3)' }}>Galaxy Story Agents</h4>
<div style={{ overflowX: 'auto' }}>
<table className="data-table">
<thead>
<tr><th>Agent Type</th><th>Strategy</th><th>Interval</th><th>Max Concurrent</th><th>Description</th></tr>
</thead>
<tbody>
{[
{ type: 'world_tick', strat: 'fixed', interval: '300s', max: '1', desc: 'Master galaxy simulation tick. Evaluates faction matrix, anomaly slots, fauna routes, player density. Spawns conditional events.' },
{ type: 'story_chapter_advance', strat: 'one-shot', interval: 'varies', max: '1 per event', desc: 'Advances multi-chapter story events. Each chapter is a one-shot agent that spawns the next on completion.' },
{ type: 'galaxy_census', strat: 'fixed', interval: '3600s', max: '1', desc: 'Snapshots player counts per system, faction territory control, active event density. Writes to metrics table for admin dashboards.' },
].map((row, i) => (
<tr key={i}>
<td><code style={{ fontSize: '0.75rem' }}>{row.type}</code></td>
<td><span className={`pill ${row.strat === 'fixed' ? 'pill-amber' : row.strat === 'one-shot' ? 'pill-purple' : 'pill-cyan'}`}>{row.strat}</span></td>
<td style={{ ...mono, fontSize: '0.8rem' }}>{row.interval}</td>
<td style={{ ...mono, fontSize: '0.8rem' }}>{row.max}</td>
<td style={{ ...dimSmall }}>{row.desc}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</>
)}
</div>
);
}
window.GDD.AgentsPage = AgentsPage;