Compare commits

..

1 Commits

Author SHA1 Message Date
Alexander Whitestone
fdc02dc121 fix: [PORTALS] Build a portal atlas / world directory for all current and future worlds (closes #712)
Some checks failed
CI / test (pull_request) Failing after 10s
CI / validate (pull_request) Failing after 16s
Review Approval Gate / verify-review (pull_request) Failing after 3s
2026-04-12 11:52:12 -04:00
5 changed files with 234 additions and 110 deletions

109
app.js
View File

@@ -2032,6 +2032,7 @@ function setupControls() {
document.getElementById('atlas-toggle-btn').addEventListener('click', openPortalAtlas);
document.getElementById('atlas-close-btn').addEventListener('click', closePortalAtlas);
initAtlasControls();
}
function sendChatMessage(overrideText = null) {
@@ -2815,58 +2816,142 @@ function closeVisionOverlay() {
document.getElementById('vision-overlay').style.display = 'none';
}
// ═══ PORTAL ATLAS ═══
// ═══ PORTAL ATLAS / WORLD DIRECTORY ═══
let atlasActiveFilter = 'all';
let atlasSearchQuery = '';
function openPortalAtlas() {
atlasOverlayActive = true;
document.getElementById('atlas-overlay').style.display = 'flex';
populateAtlas();
// Focus search input
setTimeout(() => document.getElementById('atlas-search')?.focus(), 100);
}
function closePortalAtlas() {
atlasOverlayActive = false;
document.getElementById('atlas-overlay').style.display = 'none';
atlasSearchQuery = '';
atlasActiveFilter = 'all';
}
function initAtlasControls() {
const searchInput = document.getElementById('atlas-search');
if (searchInput) {
searchInput.addEventListener('input', (e) => {
atlasSearchQuery = e.target.value.toLowerCase().trim();
populateAtlas();
});
}
const filterBtns = document.querySelectorAll('.atlas-filter-btn');
filterBtns.forEach(btn => {
btn.addEventListener('click', () => {
filterBtns.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
atlasActiveFilter = btn.dataset.filter;
populateAtlas();
});
});
}
function matchesAtlasFilter(config) {
if (atlasActiveFilter === 'all') return true;
if (atlasActiveFilter === 'harness') return (config.portal_type || 'harness') === 'harness' || !config.portal_type;
if (atlasActiveFilter === 'game-world') return config.portal_type === 'game-world';
return config.status === atlasActiveFilter;
}
function matchesAtlasSearch(config) {
if (!atlasSearchQuery) return true;
const haystack = [config.name, config.description, config.id,
config.world_category, config.portal_type, config.destination?.type]
.filter(Boolean).join(' ').toLowerCase();
return haystack.includes(atlasSearchQuery);
}
function populateAtlas() {
const grid = document.getElementById('atlas-grid');
grid.innerHTML = '';
let onlineCount = 0;
let standbyCount = 0;
let downloadedCount = 0;
let visibleCount = 0;
portals.forEach(portal => {
const config = portal.config;
if (config.status === 'online') onlineCount++;
if (config.status === 'standby') standbyCount++;
if (config.status === 'downloaded') downloadedCount++;
if (!matchesAtlasFilter(config) || !matchesAtlasSearch(config)) return;
visibleCount++;
const card = document.createElement('div');
card.className = 'atlas-card';
card.style.setProperty('--portal-color', config.color);
const statusClass = `status-${config.status || 'online'}`;
const statusLabel = (config.status || 'ONLINE').toUpperCase();
const portalType = config.portal_type || 'harness';
const categoryLabel = config.world_category
? config.world_category.replace(/-/g, ' ').toUpperCase()
: portalType.replace(/-/g, ' ').toUpperCase();
// Readiness bar for game-worlds
let readinessHTML = '';
if (config.readiness_steps) {
const steps = Object.values(config.readiness_steps);
readinessHTML = `<div class="atlas-card-readiness" title="Readiness: ${steps.filter(s=>s.done).length}/${steps.length}">`;
steps.forEach(step => {
readinessHTML += `<div class="readiness-step ${step.done ? 'done' : ''}" title="${step.label}${step.done ? ' ✓' : ''}"></div>`;
});
readinessHTML += '</div>';
}
// Action label
const actionLabel = config.destination?.action_label
|| (config.status === 'online' ? 'ENTER' : config.status === 'downloaded' ? 'LAUNCH' : 'VIEW');
card.innerHTML = `
<div class="atlas-card-header">
<div class="atlas-card-name">${config.name}</div>
<div class="atlas-card-status ${statusClass}">${config.status || 'ONLINE'}</div>
<div>
<span class="atlas-card-name">${config.name}</span>
<span class="atlas-card-category">${categoryLabel}</span>
</div>
<div class="atlas-card-status ${statusClass}">${statusLabel}</div>
</div>
<div class="atlas-card-desc">${config.description}</div>
${readinessHTML}
<div class="atlas-card-footer">
<div class="atlas-card-coord">X:${config.position.x} Z:${config.position.z}</div>
<div class="atlas-card-type">${config.destination?.type?.toUpperCase() || 'UNKNOWN'}</div>
<div class="atlas-card-action">${actionLabel}</div>
</div>
`;
card.addEventListener('click', () => {
focusPortal(portal);
closePortalAtlas();
});
grid.appendChild(card);
});
// Show empty state
if (visibleCount === 0) {
const empty = document.createElement('div');
empty.className = 'atlas-empty';
empty.textContent = atlasSearchQuery
? `No worlds match "${atlasSearchQuery}"`
: 'No worlds in this category';
grid.appendChild(empty);
}
document.getElementById('atlas-online-count').textContent = onlineCount;
document.getElementById('atlas-standby-count').textContent = standbyCount;
document.getElementById('atlas-downloaded-count').textContent = downloadedCount;
document.getElementById('atlas-total-count').textContent = portals.length;
// Update Bannerlord HUD status
const bannerlord = portals.find(p => p.config.id === 'bannerlord');

View File

@@ -113,9 +113,9 @@
<!-- Top Right: Agent Log & Atlas Toggle -->
<div class="hud-top-right">
<button id="atlas-toggle-btn" class="hud-icon-btn" title="Portal Atlas">
<button id="atlas-toggle-btn" class="hud-icon-btn" title="World Directory">
<span class="hud-icon">🌐</span>
<span class="hud-btn-label">ATLAS</span>
<span class="hud-btn-label">WORLDS</span>
</button>
<div id="bannerlord-status" class="hud-status-item" title="Bannerlord Readiness">
<span class="status-dot"></span>
@@ -214,20 +214,35 @@
<div class="atlas-header">
<div class="atlas-title">
<span class="atlas-icon">🌐</span>
<h2>PORTAL ATLAS</h2>
<h2>WORLD DIRECTORY</h2>
</div>
<button id="atlas-close-btn" class="atlas-close-btn">CLOSE</button>
</div>
<div class="atlas-controls">
<input type="text" id="atlas-search" class="atlas-search" placeholder="Search worlds..." autocomplete="off" />
<div class="atlas-filters" id="atlas-filters">
<button class="atlas-filter-btn active" data-filter="all">ALL</button>
<button class="atlas-filter-btn" data-filter="online">ONLINE</button>
<button class="atlas-filter-btn" data-filter="standby">STANDBY</button>
<button class="atlas-filter-btn" data-filter="downloaded">DOWNLOADED</button>
<button class="atlas-filter-btn" data-filter="harness">HARNESS</button>
<button class="atlas-filter-btn" data-filter="game-world">GAME</button>
</div>
</div>
<div class="atlas-grid" id="atlas-grid">
<!-- Portals will be injected here -->
<!-- Worlds will be injected here -->
</div>
<div class="atlas-footer">
<div class="atlas-status-summary">
<span class="status-indicator online"></span> <span id="atlas-online-count">0</span> ONLINE
&nbsp;&nbsp;
<span class="status-indicator standby"></span> <span id="atlas-standby-count">0</span> STANDBY
&nbsp;&nbsp;
<span class="status-indicator downloaded"></span> <span id="atlas-downloaded-count">0</span> DOWNLOADED
&nbsp;&nbsp;
<span class="atlas-total">| <span id="atlas-total-count">0</span> WORLDS TOTAL</span>
</div>
<div class="atlas-hint">Click a portal to focus or teleport</div>
<div class="atlas-hint">Click a world to focus or enter</div>
</div>
</div>
</div>

View File

@@ -1340,74 +1340,6 @@ class MnemosyneArchive:
results.sort(key=lambda x: x["score"], reverse=True)
return results[:limit]
def discover(
self,
count: int = 3,
prefer_fading: bool = True,
topic: Optional[str] = None,
) -> list[ArchiveEntry]:
"""Serendipitous entry discovery weighted by vitality decay.
Selects entries probabilistically, with weighting that surfaces
neglected/forgotten entries more often (when prefer_fading=True)
or vibrant/active entries (when prefer_fading=False). Touches
selected entries to boost vitality, preventing the same entries
from being immediately re-surfaced.
Args:
count: Number of entries to discover (default 3).
prefer_fading: If True (default), weight toward fading entries.
If False, weight toward vibrant entries.
topic: If set, restrict to entries with this topic (case-insensitive).
Returns:
List of ArchiveEntry, up to count entries.
"""
import random
candidates = list(self._entries.values())
if not candidates:
return []
if topic:
topic_lower = topic.lower()
candidates = [e for e in candidates if topic_lower in [t.lower() for t in e.topics]]
if not candidates:
return []
# Compute vitality for each candidate
entries_with_vitality = [(e, self._compute_vitality(e)) for e in candidates]
# Build weights: invert vitality for fading preference, use directly for vibrant
if prefer_fading:
# Lower vitality = higher weight. Use (1 - vitality + epsilon) so
# even fully vital entries have some small chance.
weights = [1.0 - v + 0.01 for _, v in entries_with_vitality]
else:
# Higher vitality = higher weight. Use (vitality + epsilon).
weights = [v + 0.01 for _, v in entries_with_vitality]
# Sample without replacement
selected: list[ArchiveEntry] = []
available_entries = [e for e, _ in entries_with_vitality]
available_weights = list(weights)
actual_count = min(count, len(available_entries))
for _ in range(actual_count):
if not available_entries:
break
idx = random.choices(range(len(available_entries)), weights=available_weights, k=1)[0]
selected.append(available_entries.pop(idx))
available_weights.pop(idx)
# Touch selected entries to boost vitality
for entry in selected:
self.touch(entry.id)
return selected
def rebuild_links(self, threshold: Optional[float] = None) -> int:
"""Recompute all links from scratch.

View File

@@ -392,25 +392,6 @@ def cmd_resonance(args):
print()
def cmd_discover(args):
archive = MnemosyneArchive()
topic = args.topic if args.topic else None
results = archive.discover(
count=args.count,
prefer_fading=not args.vibrant,
topic=topic,
)
if not results:
print("No entries to discover.")
return
for entry in results:
v = archive.get_vitality(entry.id)
print(f"[{entry.id[:8]}] {entry.title}")
print(f" Topics: {', '.join(entry.topics) if entry.topics else '(none)'}")
print(f" Vitality: {v['vitality']:.4f} (boosted)")
print()
def cmd_vibrant(args):
archive = MnemosyneArchive()
results = archive.vibrant(limit=args.limit)
@@ -518,11 +499,6 @@ def main():
rs.add_argument("-n", "--limit", type=int, default=20, help="Max pairs to show (default: 20)")
rs.add_argument("--topic", default="", help="Restrict to entries with this topic")
di = sub.add_parser("discover", help="Serendipitous entry exploration")
di.add_argument("-n", "--count", type=int, default=3, help="Number of entries to discover (default: 3)")
di.add_argument("-t", "--topic", default="", help="Filter to entries with this topic")
di.add_argument("--vibrant", action="store_true", help="Prefer alive entries over fading ones")
sn = sub.add_parser("snapshot", help="Point-in-time backup and restore")
sn_sub = sn.add_subparsers(dest="snapshot_cmd")
sn_create = sn_sub.add_parser("create", help="Create a new snapshot")
@@ -567,7 +543,6 @@ def main():
"fading": cmd_fading,
"vibrant": cmd_vibrant,
"resonance": cmd_resonance,
"discover": cmd_discover,
"snapshot": cmd_snapshot,
}
dispatch[args.command](args)

117
style.css
View File

@@ -410,6 +410,123 @@ canvas#nexus-canvas {
font-style: italic;
}
/* Atlas Controls */
.atlas-controls {
padding: 15px 30px;
border-bottom: 1px solid var(--color-border);
display: flex;
flex-direction: column;
gap: 12px;
}
.atlas-search {
width: 100%;
padding: 10px 15px;
background: rgba(20, 30, 60, 0.6);
border: 1px solid var(--color-border);
color: var(--color-text);
font-family: var(--font-body);
font-size: 13px;
outline: none;
transition: border-color 0.2s;
}
.atlas-search:focus {
border-color: var(--color-primary);
}
.atlas-search::placeholder {
color: rgba(160, 184, 208, 0.4);
}
.atlas-filters {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.atlas-filter-btn {
background: transparent;
border: 1px solid var(--color-border);
color: var(--color-text-muted);
padding: 4px 12px;
font-family: var(--font-display);
font-size: 10px;
cursor: pointer;
transition: all 0.2s;
letter-spacing: 1px;
}
.atlas-filter-btn:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
.atlas-filter-btn.active {
background: rgba(74, 240, 192, 0.15);
border-color: var(--color-primary);
color: var(--color-primary);
}
/* Enhanced Atlas Cards */
.status-downloaded { background: rgba(255, 165, 0, 0.2); color: #ffa500; border: 1px solid #ffa500; }
.status-indicator.downloaded { background: #ffa500; box-shadow: 0 0 5px #ffa500; }
.atlas-card-category {
font-family: var(--font-display);
font-size: 9px;
padding: 2px 6px;
border-radius: 2px;
text-transform: uppercase;
background: rgba(255, 255, 255, 0.05);
color: var(--color-text-muted);
border: 1px solid rgba(255, 255, 255, 0.08);
margin-left: 6px;
}
.atlas-card-readiness {
display: flex;
gap: 4px;
margin-top: 10px;
margin-bottom: 5px;
}
.readiness-step {
flex: 1;
height: 3px;
background: rgba(255, 255, 255, 0.1);
border-radius: 1px;
position: relative;
}
.readiness-step.done {
background: var(--portal-color, var(--color-primary));
}
.readiness-step[title] {
cursor: help;
}
.atlas-card-action {
font-family: var(--font-display);
font-size: 10px;
color: var(--portal-color, var(--color-primary));
letter-spacing: 1px;
}
.atlas-total {
color: var(--color-text-muted);
}
.atlas-empty {
grid-column: 1 / -1;
text-align: center;
padding: 40px;
color: var(--color-text-muted);
font-style: italic;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }