395 lines
19 KiB
JavaScript
395 lines
19 KiB
JavaScript
window.GDD = window.GDD || {};
|
||
|
||
const { useState, useEffect, useCallback, useRef } = React;
|
||
|
||
function ChatDemo() {
|
||
// ── State ──
|
||
const [activeChannel, setActiveChannel] = useState('local');
|
||
const [messages, setMessages] = useState([]);
|
||
const [inputText, setInputText] = useState('');
|
||
const [playerName] = useState('CMDR Kimura');
|
||
const [playerSystem] = useState('Jita');
|
||
const [simTime, setSimTime] = useState(0);
|
||
const [speed, setSpeed] = useState(1);
|
||
const [running, setRunning] = useState(false);
|
||
const messagesEndRef = useRef(null);
|
||
const tickRef = useRef(null);
|
||
|
||
// ── Simulated players in different systems ──
|
||
const players = [
|
||
{ name: 'CMDR Vasquez', system: 'Jita', distance: 0 },
|
||
{ name: 'CMDR Chen', system: 'Jita', distance: 0 },
|
||
{ name: 'CMDR Okafor', system: 'Amarr', distance: 6 },
|
||
{ name: 'CMDR Lindström', system: 'Amarr', distance: 6 },
|
||
{ name: 'CMDR Tanaka', system: 'Rens', distance: 12 },
|
||
{ name: 'CMDR Dubois', system: 'Hek', distance: 18 },
|
||
{ name: 'CMDR Voronov', system: 'PF-346', distance: 32 },
|
||
];
|
||
|
||
// ── Channel definitions ──
|
||
const channels = [
|
||
{ id: 'local', name: 'Local', range: 'Current System', delay: 'Instant', icon: '📡', color: 'var(--fg-bright)' },
|
||
{ id: 'trade', name: 'Trade', range: 'Station / Region', delay: '0–30s', icon: '💰', color: 'var(--green)' },
|
||
{ id: 'private', name: 'Private (Okafor)', range: 'Distance-based', delay: '~12s (6 jumps)', icon: '✉', color: 'var(--cyan)' },
|
||
{ id: 'fleet', name: 'Fleet [Post-MVP]', range: 'Fleet members', delay: 'Instant', icon: '🚀', color: 'var(--purple)', disabled: true },
|
||
];
|
||
|
||
// ── Pre-seeded message corpus ──
|
||
const seedMessages = {
|
||
local: [
|
||
{ from: 'CMDR Vasquez', text: 'Anyone seen a Veldspar belt that isn\'t depleted?', time: -45 },
|
||
{ from: 'CMDR Chen', text: 'Try belt 7-2, was full 10 min ago', time: -38 },
|
||
{ from: 'CMDR Vasquez', text: 'Thanks, warping now', time: -35 },
|
||
{ from: 'CMDR Chen', text: 'Watch out, saw a Corpii frigate on scan near 7-2', time: -30 },
|
||
{ from: 'CMDR Vasquez', text: 'Corpii? In high-sec? That\'s unusual', time: -25 },
|
||
{ from: 'System', text: '⚠ CONCORD response dispatched in Jita — criminal act in progress near Jita IV', time: -20, isSystem: true },
|
||
{ from: 'CMDR Chen', text: 'Well that explains the locals being jumpy', time: -15 },
|
||
],
|
||
trade: [
|
||
{ from: 'CMDR Chen', text: 'WTS 5000 Tritanium @ 3.15/unit — Jita IV docked', time: -60 },
|
||
{ from: 'CMDR Vasquez', text: 'WTB Nocxium x200, paying market +5%. Jita.', time: -50 },
|
||
{ from: 'Market Bot', text: '📊 Scordite volume up 340% in Jita in last hour. Spread widening.', time: -40, isSystem: true },
|
||
{ from: 'CMDR Chen', text: 'That Scordite spike is because someone bought out the entire sell wall at Jita IV', time: -30 },
|
||
],
|
||
private: [
|
||
{ from: 'CMDR Okafor', text: 'Hey, you still in Jita?', time: -120 },
|
||
{ from: 'CMDR Okafor', text: 'Megacyte just crashed 15% in Amarr. Someone panic-sold a freighter load.', time: -90 },
|
||
{ from: 'CMDR Okafor', text: 'If you can haul fast, there\'s a 20% spread between Amarr buy and Jita sell', time: -75 },
|
||
],
|
||
};
|
||
|
||
// ── Delay calculation ──
|
||
const getDelay = (fromDistance) => {
|
||
if (fromDistance === 0) return 0;
|
||
return Math.round(2 * Math.sqrt(fromDistance));
|
||
};
|
||
|
||
const formatDelay = (seconds) => {
|
||
if (seconds === 0) return 'instant';
|
||
if (seconds < 60) return `~${seconds}s`;
|
||
return `~${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
||
};
|
||
|
||
// ── Simulation ──
|
||
useEffect(() => {
|
||
if (!running) {
|
||
if (tickRef.current) clearInterval(tickRef.current);
|
||
return;
|
||
}
|
||
tickRef.current = setInterval(() => {
|
||
setSimTime(t => t + speed);
|
||
}, 500);
|
||
return () => clearInterval(tickRef.current);
|
||
}, [running, speed]);
|
||
|
||
// Seed initial messages
|
||
useEffect(() => {
|
||
const initial = [];
|
||
Object.entries(seedMessages).forEach(([channel, msgs]) => {
|
||
msgs.forEach(msg => {
|
||
initial.push({
|
||
id: `${channel}-${msg.time}-${msg.from}`,
|
||
channel,
|
||
from: msg.from,
|
||
text: msg.text,
|
||
isSystem: msg.isSystem || false,
|
||
timestamp: msg.time,
|
||
deliveredAt: msg.time + (channel === 'private' ? getDelay(6) : 0),
|
||
delay: channel === 'private' ? getDelay(6) : 0,
|
||
status: 'delivered',
|
||
});
|
||
});
|
||
});
|
||
initial.sort((a, b) => a.deliveredAt - b.deliveredAt);
|
||
setMessages(initial);
|
||
}, []);
|
||
|
||
// Check for delayed message delivery
|
||
useEffect(() => {
|
||
if (!running) return;
|
||
setMessages(prev => prev.map(m => {
|
||
if (m.status === 'pending' && simTime >= m.deliveredAt) {
|
||
return { ...m, status: 'delivered' };
|
||
}
|
||
return m;
|
||
}));
|
||
}, [simTime, running]);
|
||
|
||
useEffect(() => {
|
||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||
}, [messages]);
|
||
|
||
const sendMessage = () => {
|
||
if (!inputText.trim()) return;
|
||
const now = simTime;
|
||
let delay = 0;
|
||
if (activeChannel === 'private') delay = getDelay(6);
|
||
if (activeChannel === 'trade') delay = Math.floor(Math.random() * 30);
|
||
|
||
const msg = {
|
||
id: `user-${Date.now()}`,
|
||
channel: activeChannel,
|
||
from: playerName,
|
||
text: inputText.trim(),
|
||
isSystem: false,
|
||
timestamp: now,
|
||
deliveredAt: now + delay,
|
||
delay,
|
||
status: delay === 0 ? 'delivered' : 'pending',
|
||
};
|
||
setMessages(prev => [...prev, msg]);
|
||
setInputText('');
|
||
|
||
// Simulate NPC response in local
|
||
if (activeChannel === 'local' && Math.random() > 0.5) {
|
||
const responder = players.filter(p => p.system === playerSystem && p.name !== playerName);
|
||
if (responder.length > 0) {
|
||
const responder_ = responder[Math.floor(Math.random() * responder.length)];
|
||
const responses = [
|
||
'Copy that.',
|
||
'Interesting. Keep us posted.',
|
||
'Acknowledged.',
|
||
'Seen it. Be careful out there.',
|
||
'Good luck.',
|
||
];
|
||
setTimeout(() => {
|
||
setMessages(prev => [...prev, {
|
||
id: `npc-${Date.now()}`,
|
||
channel: 'local',
|
||
from: responder_.name,
|
||
text: responses[Math.floor(Math.random() * responses.length)],
|
||
isSystem: false,
|
||
timestamp: simTime + 3,
|
||
deliveredAt: simTime + 3,
|
||
delay: 0,
|
||
status: 'delivered',
|
||
}]);
|
||
}, 1500);
|
||
}
|
||
}
|
||
};
|
||
|
||
const visibleMessages = messages.filter(m => {
|
||
if (m.channel !== activeChannel) return false;
|
||
if (m.status === 'pending') return true;
|
||
return true;
|
||
});
|
||
|
||
const channelInfo = channels.find(c => c.id === activeChannel);
|
||
|
||
return (
|
||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', gap: '0', background: 'var(--surface-base)' }}>
|
||
{/* Header */}
|
||
<div style={{ padding: 'var(--sp-3) var(--sp-4)', borderBottom: '1px solid var(--border)', background: 'var(--surface-raised)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--sp-3)' }}>
|
||
<a href="#overview" style={{ display: 'inline-flex', alignItems: 'center', gap: '4px', fontSize: '0.7rem', fontFamily: 'var(--font-mono)', color: 'var(--muted)', textDecoration: 'none', padding: '2px 8px', border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)', transition: 'color 0.15s, border-color 0.15s' }} onMouseEnter={e => { e.currentTarget.style.color='var(--fg-bright)'; e.currentTarget.style.borderColor='var(--border-light)'; }} onMouseLeave={e => { e.currentTarget.style.color='var(--muted)'; e.currentTarget.style.borderColor='var(--border)'; }}>← Docs</a>
|
||
<div>
|
||
<h3 style={{ margin: 0, fontSize: '1rem' }}>💬 Communication System Demo</h3>
|
||
<span style={{ fontSize: '0.75rem', color: 'var(--muted)' }}>Location: {playerSystem} — Simulating light-speed delay</span>
|
||
</div>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 'var(--sp-2)', alignItems: 'center' }}>
|
||
<span style={{ fontSize: '0.75rem', color: 'var(--muted)' }}>Speed:</span>
|
||
{[0.5, 1, 2, 5].map(s => (
|
||
<button key={s} className={`btn btn-sm${speed === s ? ' btn-primary' : ''}`}
|
||
onClick={() => setSpeed(s)} style={{ padding: '2px 8px', fontSize: '0.7rem' }}>
|
||
{s}×
|
||
</button>
|
||
))}
|
||
<button className={`btn btn-sm${running ? '' : ' btn-primary'}`}
|
||
onClick={() => { setRunning(!running); if (!running) setSimTime(t => t); }}
|
||
style={{ padding: '2px 12px', fontSize: '0.75rem', marginLeft: 'var(--sp-2)' }}>
|
||
{running ? '⏸ Pause' : '▶ Run'}
|
||
</button>
|
||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '0.75rem', color: 'var(--accent)', marginLeft: 'var(--sp-2)' }}>
|
||
T+{simTime.toFixed(0)}s
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
|
||
{/* Channel sidebar */}
|
||
<div style={{ width: '200px', borderRight: '1px solid var(--border)', background: 'var(--surface-sunken)', padding: 'var(--sp-3)', flexShrink: 0 }}>
|
||
<div style={{ fontSize: '0.65rem', color: 'var(--muted)', fontFamily: 'var(--font-mono)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 'var(--sp-2)' }}>
|
||
Channels
|
||
</div>
|
||
{channels.map(ch => (
|
||
<button key={ch.id}
|
||
disabled={ch.disabled}
|
||
onClick={() => setActiveChannel(ch.id)}
|
||
style={{
|
||
display: 'flex', alignItems: 'center', gap: 'var(--sp-2)',
|
||
width: '100%', padding: 'var(--sp-2) var(--sp-3)',
|
||
borderRadius: 'var(--radius-sm)', border: 'none',
|
||
background: activeChannel === ch.id ? 'var(--surface-raised)' : 'transparent',
|
||
color: ch.disabled ? 'var(--muted)' : ch.color,
|
||
cursor: ch.disabled ? 'not-allowed' : 'pointer',
|
||
opacity: ch.disabled ? 0.4 : 1,
|
||
fontSize: '0.82rem', textAlign: 'left', marginBottom: '2px',
|
||
}}>
|
||
<span>{ch.icon}</span>
|
||
<span>{ch.name}</span>
|
||
</button>
|
||
))}
|
||
|
||
<div style={{ marginTop: 'var(--sp-5)', fontSize: '0.65rem', color: 'var(--muted)', fontFamily: 'var(--font-mono)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 'var(--sp-2)' }}>
|
||
Nearby Pilots
|
||
</div>
|
||
{players.filter(p => p.system === playerSystem).map(p => (
|
||
<div key={p.name} style={{ fontSize: '0.75rem', color: 'var(--fg-dim)', padding: '2px var(--sp-3)' }}>
|
||
<span style={{ color: 'var(--green)' }}>●</span> {p.name}
|
||
</div>
|
||
))}
|
||
|
||
<div style={{ marginTop: 'var(--sp-4)', fontSize: '0.65rem', color: 'var(--muted)', fontFamily: 'var(--font-mono)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 'var(--sp-2)' }}>
|
||
Distant Pilots
|
||
</div>
|
||
{players.filter(p => p.system !== playerSystem).map(p => (
|
||
<div key={p.name} style={{ fontSize: '0.75rem', color: 'var(--fg-dim)', padding: '2px var(--sp-3)' }}>
|
||
<span style={{ color: 'var(--amber)' }}>●</span> {p.name}
|
||
<span style={{ color: 'var(--muted)', fontSize: '0.65rem', marginLeft: 'var(--sp-1)' }}>({p.distance}j)</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Main chat area */}
|
||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||
{/* Channel info bar */}
|
||
<div style={{ padding: 'var(--sp-2) var(--sp-4)', borderBottom: '1px solid var(--border)', background: 'var(--surface-raised)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--sp-3)' }}>
|
||
<span style={{ fontSize: '1rem' }}>{channelInfo?.icon}</span>
|
||
<span style={{ fontWeight: 600, color: channelInfo?.color }}>{channelInfo?.name}</span>
|
||
<span className="pill" style={{ fontSize: '0.6rem', background: 'var(--surface-sunken)', color: 'var(--fg-dim)' }}>
|
||
{channelInfo?.range}
|
||
</span>
|
||
</div>
|
||
<div style={{ fontSize: '0.75rem', color: 'var(--muted)', fontFamily: 'var(--font-mono)' }}>
|
||
Delay: {channelInfo?.delay}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Messages */}
|
||
<div style={{ flex: 1, overflowY: 'auto', padding: 'var(--sp-3) var(--sp-4)' }}>
|
||
{visibleMessages.length === 0 && (
|
||
<div style={{ textAlign: 'center', color: 'var(--muted)', fontSize: '0.85rem', padding: 'var(--sp-8)' }}>
|
||
No messages yet. Press ▶ Run to start the simulation.
|
||
</div>
|
||
)}
|
||
{visibleMessages.map(msg => (
|
||
<div key={msg.id} style={{
|
||
marginBottom: 'var(--sp-2)',
|
||
padding: 'var(--sp-2) var(--sp-3)',
|
||
borderRadius: 'var(--radius-sm)',
|
||
background: msg.isSystem ? 'rgba(245, 158, 11, 0.08)' : msg.status === 'pending' ? 'rgba(100, 100, 100, 0.08)' : 'transparent',
|
||
borderLeft: msg.isSystem ? '2px solid var(--amber)' : msg.status === 'pending' ? '2px solid var(--muted)' : '2px solid transparent',
|
||
opacity: msg.status === 'pending' ? 0.5 : 1,
|
||
}}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '2px' }}>
|
||
<span style={{
|
||
fontWeight: 600, fontSize: '0.82rem',
|
||
color: msg.isSystem ? 'var(--amber)' :
|
||
msg.from === playerName ? 'var(--accent)' : 'var(--cyan)',
|
||
}}>
|
||
{msg.isSystem ? '⚠ System' : msg.from}
|
||
</span>
|
||
<span style={{ fontSize: '0.65rem', color: 'var(--muted)', fontFamily: 'var(--font-mono)' }}>
|
||
{msg.status === 'pending'
|
||
? `⏳ delivering… (${formatDelay(msg.delay)} light-speed delay)`
|
||
: msg.delay > 0
|
||
? `T+${msg.deliveredAt.toFixed(0)}s (delayed ${formatDelay(msg.delay)})`
|
||
: `T+${msg.timestamp.toFixed(0)}s`}
|
||
</span>
|
||
</div>
|
||
<div style={{ fontSize: '0.85rem', color: msg.isSystem ? 'var(--amber)' : 'var(--fg-dim)' }}>
|
||
{msg.text}
|
||
</div>
|
||
</div>
|
||
))}
|
||
<div ref={messagesEndRef} />
|
||
</div>
|
||
|
||
{/* Input */}
|
||
<div style={{ padding: 'var(--sp-3) var(--sp-4)', borderTop: '1px solid var(--border)', background: 'var(--surface-raised)', display: 'flex', gap: 'var(--sp-3)' }}>
|
||
<input
|
||
type="text"
|
||
value={inputText}
|
||
onChange={e => setInputText(e.target.value)}
|
||
onKeyDown={e => { if (e.key === 'Enter') sendMessage(); }}
|
||
placeholder={activeChannel === 'private' ? `Message CMDR Okafor (${formatDelay(getDelay(6))} delay)...` : `Send to ${channelInfo?.name}...`}
|
||
style={{
|
||
flex: 1, padding: 'var(--sp-2) var(--sp-3)',
|
||
borderRadius: 'var(--radius-sm)', border: '1px solid var(--border)',
|
||
background: 'var(--surface-base)', color: 'var(--fg)',
|
||
fontSize: '0.85rem', outline: 'none',
|
||
}}
|
||
/>
|
||
<button className="btn btn-primary" onClick={sendMessage} style={{ padding: 'var(--sp-2) var(--sp-4)', fontSize: '0.85rem' }}>
|
||
Send {activeChannel === 'private' ? `(${formatDelay(getDelay(6))})` : ''}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Right sidebar: delay visualization */}
|
||
<div style={{ width: '220px', borderLeft: '1px solid var(--border)', background: 'var(--surface-sunken)', padding: 'var(--sp-3)', overflowY: 'auto', flexShrink: 0 }}>
|
||
<div style={{ fontSize: '0.65rem', color: 'var(--muted)', fontFamily: 'var(--font-mono)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 'var(--sp-3)' }}>
|
||
Light-Speed Delay Map
|
||
</div>
|
||
|
||
<div style={{ fontSize: '0.7rem', color: 'var(--fg-dim)', marginBottom: 'var(--sp-4)', lineHeight: 1.5 }}>
|
||
Messages to/from pilots in other systems travel at light speed. Formula: <code style={{ fontFamily: 'var(--font-mono)', fontSize: '0.65rem' }}>2 × √(jumps)</code> seconds.
|
||
</div>
|
||
|
||
{players.map(p => {
|
||
const delay = getDelay(p.distance);
|
||
const isLocal = p.distance === 0;
|
||
return (
|
||
<div key={p.name} style={{
|
||
padding: 'var(--sp-2) var(--sp-3)',
|
||
marginBottom: '4px',
|
||
borderRadius: 'var(--radius-sm)',
|
||
background: isLocal ? 'rgba(34, 197, 94, 0.08)' : 'var(--surface-raised)',
|
||
borderLeft: `3px solid ${isLocal ? 'var(--green)' : delay > 15 ? 'var(--red)' : delay > 5 ? 'var(--amber)' : 'var(--cyan)'}`,
|
||
}}>
|
||
<div style={{ fontSize: '0.75rem', color: 'var(--fg)', fontWeight: 500 }}>{p.name}</div>
|
||
<div style={{ fontSize: '0.65rem', color: 'var(--muted)', display: 'flex', justifyContent: 'space-between' }}>
|
||
<span>{p.system}</span>
|
||
<span style={{ color: isLocal ? 'var(--green)' : delay > 15 ? 'var(--red)' : 'var(--amber)' }}>
|
||
{isLocal ? 'instant' : `~${delay}s`}
|
||
</span>
|
||
</div>
|
||
{p.distance > 0 && (
|
||
<div style={{
|
||
marginTop: '4px', height: '3px', borderRadius: '2px',
|
||
background: 'var(--surface-raised)', overflow: 'hidden',
|
||
}}>
|
||
<div style={{
|
||
width: `${Math.min(100, (delay / 40) * 100)}%`,
|
||
height: '100%',
|
||
background: delay > 15 ? 'var(--red)' : delay > 5 ? 'var(--amber)' : 'var(--cyan)',
|
||
borderRadius: '2px',
|
||
transition: 'width 0.3s ease',
|
||
}} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
<div style={{ marginTop: 'var(--sp-5)', fontSize: '0.65rem', color: 'var(--muted)', fontFamily: 'var(--font-mono)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 'var(--sp-2)' }}>
|
||
What This Validates
|
||
</div>
|
||
<ul style={{ color: 'var(--fg-dim)', fontSize: '0.75rem', margin: 0, paddingLeft: 'var(--sp-4)', lineHeight: 1.8 }}>
|
||
<li>Light-speed delay <em>feels</em> meaningful — not instant</li>
|
||
<li>Private messages arrive after a visible wait</li>
|
||
<li>Local chat is instant, creating information asymmetry</li>
|
||
<li>Trade channel has moderate delay (regional relay)</li>
|
||
<li>System messages (CONCORD, market) are immediate</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
window.GDD.ChatDemo = ChatDemo;
|