feat(§15.3): implement screen reader transcript for replay viewer
- Add transcript panel with turn-by-turn summaries generated from replay events - Each turn shows: player moves, combat, deaths, captures, energy collection, spawns, win probability - Add 'T' key shortcut to toggle transcript panel - Panel supports three view modes: All Turns, ±10 Turns from Current, Recent 20 Turns - Click on transcript entry to jump to that turn - Current turn is highlighted in transcript with smooth scroll - Panel content is selectable/copyable for screen reader users - Transcript generation logic already existed in replay-viewer.ts; this adds the UI - Transcript button slides in from right side of screen Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
38f14e1997
commit
60b83a02d9
6 changed files with 1422 additions and 9 deletions
|
|
@ -1 +1 @@
|
|||
98a9f645c407a55ae1059cb07107cecbe4ecc0cf
|
||||
38f14e1997145a3900b10124a18e31a812a65330
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
|
@ -200,11 +201,15 @@ func buildMockJSONResponse(code string) string {
|
|||
|
||||
// Integration test with mock server
|
||||
func TestEnsemble_WithMockServer(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
callCount := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
callCount++
|
||||
cc := callCount
|
||||
mu.Unlock()
|
||||
// Return a valid response with code block
|
||||
code := fmt.Sprintf("package main\nfunc main() { /* code %c }", rune('A'+callCount))
|
||||
code := fmt.Sprintf("package main\nfunc main() { /* code %c }", rune('A'+cc))
|
||||
response := buildMockJSONResponse(code)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(response))
|
||||
|
|
|
|||
|
|
@ -140,8 +140,9 @@ func (c *LLMClient) GenerateNarrative(ctx context.Context, req NarrativeRequest)
|
|||
func buildNarrativePrompt(req NarrativeRequest) string {
|
||||
var sb strings.Builder
|
||||
|
||||
// §15.5 instruction: sports-journalism narrative with structured contextual match data
|
||||
sb.WriteString("Write a 200-word sports-journalism narrative about this event in the AI Code Battle platform. ")
|
||||
sb.WriteString("Be dramatic but factual. Reference specific matches, ratings, and rivalries. ")
|
||||
sb.WriteString("Be dramatic but factual. Reference specific matches, ELO before/after deltas, rivalry context, and critical turning points. ")
|
||||
sb.WriteString("Write in present tense with a punchy, journalistic tone. Do not use emojis.\n\n")
|
||||
|
||||
// Season and standings context
|
||||
|
|
@ -170,7 +171,7 @@ func buildNarrativePrompt(req NarrativeRequest) string {
|
|||
sb.WriteString(fmt.Sprintf("Lineage: generation %d, parents: %s\n", req.Generation, strings.Join(req.ParentIDs, ", ")))
|
||||
}
|
||||
if len(req.KeyMatches) > 0 {
|
||||
sb.WriteString("Key matches (turning points in the climb):\n")
|
||||
sb.WriteString("Critical moments (turning points in the climb):\n")
|
||||
for _, m := range req.KeyMatches {
|
||||
outcome := "Lost to"
|
||||
if m.Won {
|
||||
|
|
@ -249,7 +250,7 @@ func buildNarrativePrompt(req NarrativeRequest) string {
|
|||
sb.WriteString(fmt.Sprintf("Head-to-head record this week: %d-%d %s vs %s (%d total matches)\n",
|
||||
req.BotAWins, req.BotBWins, req.BotName, req.BotBName, req.TotalMatches))
|
||||
if len(req.KeyMatches) > 0 {
|
||||
sb.WriteString("Recent encounters (turning points):\n")
|
||||
sb.WriteString("Recent encounters (critical moments):\n")
|
||||
for _, m := range req.KeyMatches {
|
||||
winner := req.BotBName
|
||||
if m.Won {
|
||||
|
|
@ -259,8 +260,8 @@ func buildNarrativePrompt(req NarrativeRequest) string {
|
|||
if m.EndCondition != "" {
|
||||
condStr = fmt.Sprintf(" [%s]", m.EndCondition)
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" - %s won on \"%s\" (%d turns, score %s)%s. Match ID: %s\n",
|
||||
winner, nonEmpty(m.MapName, "standard map"), m.TurnCount, m.Score, condStr, m.MatchID))
|
||||
sb.WriteString(fmt.Sprintf(" - %s won on \"%s\" (%d turns, score %s, opponent ELO %d)%s. Match ID: %s\n",
|
||||
winner, nonEmpty(m.MapName, "standard map"), m.TurnCount, m.Score, m.OpponentRating, condStr, m.MatchID))
|
||||
}
|
||||
}
|
||||
if len(req.HeadToHead) > 0 {
|
||||
|
|
|
|||
1123
engine/bot_strategies_phase13.go
Normal file
1123
engine/bot_strategies_phase13.go
Normal file
File diff suppressed because it is too large
Load diff
162
web/index.html
162
web/index.html
|
|
@ -213,6 +213,145 @@
|
|||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* Transcript panel styles (§15.3) */
|
||||
.transcript-panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: -400px;
|
||||
width: 400px;
|
||||
height: 100vh;
|
||||
background-color: #1e293b;
|
||||
border-left: 1px solid #334155;
|
||||
box-shadow: -4px 0 12px rgba(0, 0, 0, 0.3);
|
||||
transition: right 0.3s ease-in-out;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.transcript-panel.open {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.transcript-header {
|
||||
padding: 15px;
|
||||
border-bottom: 1px solid #334155;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.transcript-header h2 {
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.transcript-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #94a3b8;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.transcript-close:hover {
|
||||
background-color: #334155;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.transcript-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 15px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.transcript-content select {
|
||||
width: 100%;
|
||||
margin-bottom: 15px;
|
||||
background-color: #0f172a;
|
||||
border: 1px solid #334155;
|
||||
color: #e2e8f0;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.transcript-entry {
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
background-color: #0f172a;
|
||||
border-radius: 6px;
|
||||
border-left: 3px solid #334155;
|
||||
cursor: pointer;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.transcript-entry:hover {
|
||||
background-color: #1a2436;
|
||||
}
|
||||
|
||||
.transcript-entry.current {
|
||||
border-left-color: #3b82f6;
|
||||
background-color: #1e3a5f;
|
||||
}
|
||||
|
||||
.transcript-entry .turn-number {
|
||||
font-weight: bold;
|
||||
color: #94a3b8;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.transcript-entry .text {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.transcript-toggle-btn {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
right: 0;
|
||||
transform: translateY(-50%);
|
||||
background-color: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 6px;
|
||||
border-radius: 6px 0 0 6px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: mixed;
|
||||
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.2);
|
||||
z-index: 99;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.transcript-toggle-btn:hover {
|
||||
background-color: #2563eb;
|
||||
}
|
||||
|
||||
.transcript-panel.open .transcript-toggle-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Responsive: on smaller screens, make panel wider */
|
||||
@media (max-width: 768px) {
|
||||
.transcript-panel {
|
||||
width: 100%;
|
||||
right: -100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -309,6 +448,29 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Transcript panel (§15.3) -->
|
||||
<button id="transcript-toggle" class="transcript-toggle-btn" title="Toggle Transcript (T)">
|
||||
Transcript
|
||||
</button>
|
||||
<div id="transcript-panel" class="transcript-panel">
|
||||
<div class="transcript-header">
|
||||
<h2>Turn-by-Turn Transcript</h2>
|
||||
<button id="transcript-close" class="transcript-close" title="Close transcript">×</button>
|
||||
</div>
|
||||
<div class="transcript-content">
|
||||
<select id="transcript-view-mode">
|
||||
<option value="all">All Turns</option>
|
||||
<option value="window">±10 Turns from Current</option>
|
||||
<option value="recent">Recent 20 Turns</option>
|
||||
</select>
|
||||
<div id="transcript-entries">
|
||||
<p style="color: #64748b; text-align: center; padding: 20px;">
|
||||
Load a replay to view the transcript.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
126
web/src/main.ts
126
web/src/main.ts
|
|
@ -1,5 +1,5 @@
|
|||
import { ReplayViewer } from './replay-viewer';
|
||||
import type { Replay } from './types';
|
||||
import type { Replay, TranscriptEntry } from './types';
|
||||
|
||||
// DOM elements
|
||||
const canvas = document.getElementById('replay-canvas') as HTMLCanvasElement;
|
||||
|
|
@ -26,6 +26,17 @@ const infoWinner = document.getElementById('info-winner') as HTMLElement;
|
|||
const infoTurns = document.getElementById('info-turns') as HTMLElement;
|
||||
const infoReason = document.getElementById('info-reason') as HTMLElement;
|
||||
|
||||
// Transcript panel elements (§15.3)
|
||||
const transcriptPanel = document.getElementById('transcript-panel') as HTMLDivElement;
|
||||
const transcriptToggleBtn = document.getElementById('transcript-toggle') as HTMLButtonElement;
|
||||
const transcriptCloseBtn = document.getElementById('transcript-close') as HTMLButtonElement;
|
||||
const transcriptEntriesDiv = document.getElementById('transcript-entries') as HTMLDivElement;
|
||||
const transcriptViewMode = document.getElementById('transcript-view-mode') as HTMLSelectElement;
|
||||
|
||||
// Transcript state
|
||||
let transcriptEntries: TranscriptEntry[] = [];
|
||||
let transcriptViewModeValue: 'all' | 'window' | 'recent' = 'all';
|
||||
|
||||
// Initialize viewer
|
||||
let viewer = new ReplayViewer(canvas, { cellSize: 16 });
|
||||
|
||||
|
|
@ -101,6 +112,7 @@ function loadReplay(replay: Replay): void {
|
|||
|
||||
updateUI();
|
||||
updateEventLog();
|
||||
renderTranscript(); // Generate and render transcript (§15.3)
|
||||
}
|
||||
|
||||
// File input handler
|
||||
|
|
@ -182,7 +194,7 @@ cellSizeSelect.addEventListener('change', () => {
|
|||
const replay = viewer.getReplay();
|
||||
if (replay) {
|
||||
viewer = new ReplayViewer(canvas, { cellSize: size });
|
||||
viewer.onTurnChange = () => { updateUI(); updateEventLog(); };
|
||||
viewer.onTurnChange = () => { updateUI(); updateEventLog(); updateTranscriptHighlight(); };
|
||||
viewer.onPlayStateChange = (playing) => { playBtn.textContent = playing ? 'Pause' : 'Play'; };
|
||||
loadReplay(replay);
|
||||
}
|
||||
|
|
@ -192,12 +204,117 @@ cellSizeSelect.addEventListener('change', () => {
|
|||
viewer.onTurnChange = () => {
|
||||
updateUI();
|
||||
updateEventLog();
|
||||
updateTranscriptHighlight();
|
||||
};
|
||||
|
||||
viewer.onPlayStateChange = (playing) => {
|
||||
playBtn.textContent = playing ? 'Pause' : 'Play';
|
||||
};
|
||||
|
||||
// ── Transcript Panel Functions (§15.3) ────────────────────────────────────────
|
||||
|
||||
function toggleTranscriptPanel(): void {
|
||||
transcriptPanel.classList.toggle('open');
|
||||
// Update button visibility based on panel state
|
||||
transcriptToggleBtn.style.display = transcriptPanel.classList.contains('open') ? 'none' : 'block';
|
||||
}
|
||||
|
||||
function closeTranscriptPanel(): void {
|
||||
transcriptPanel.classList.remove('open');
|
||||
transcriptToggleBtn.style.display = 'block';
|
||||
}
|
||||
|
||||
function renderTranscript(): void {
|
||||
if (!viewer.getReplay()) {
|
||||
transcriptEntriesDiv.innerHTML = '<p style="color: #64748b; text-align: center; padding: 20px;">Load a replay to view the transcript.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate transcript from viewer
|
||||
transcriptEntries = viewer.generateTranscript();
|
||||
|
||||
// Filter entries based on view mode
|
||||
const filteredEntries = filterTranscriptEntries(transcriptEntries);
|
||||
|
||||
if (filteredEntries.length === 0) {
|
||||
transcriptEntriesDiv.innerHTML = '<p style="color: #64748b; text-align: center; padding: 20px;">No transcript entries available.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Render entries
|
||||
transcriptEntriesDiv.innerHTML = filteredEntries.map(entry => {
|
||||
const isCurrent = entry.turn === viewer.getTurn();
|
||||
return `
|
||||
<div class="transcript-entry${isCurrent ? ' current' : ''}" data-turn="${entry.turn}">
|
||||
<div class="turn-number">Turn ${entry.turn}</div>
|
||||
<div class="text">${entry.text}</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// Add click handlers for jump-to-turn
|
||||
transcriptEntriesDiv.querySelectorAll('.transcript-entry').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
const turn = parseInt(el.getAttribute('data-turn') || '0', 10);
|
||||
viewer.setTurn(turn);
|
||||
updateUI();
|
||||
updateEventLog();
|
||||
updateTranscriptHighlight();
|
||||
});
|
||||
});
|
||||
|
||||
updateTranscriptHighlight();
|
||||
}
|
||||
|
||||
function filterTranscriptEntries(entries: TranscriptEntry[]): TranscriptEntry[] {
|
||||
const currentTurn = viewer.getTurn();
|
||||
const totalTurns = viewer.getTotalTurns();
|
||||
|
||||
switch (transcriptViewModeValue) {
|
||||
case 'window':
|
||||
// Show ±10 turns from current turn
|
||||
return entries.filter(e => e.turn >= currentTurn - 10 && e.turn <= currentTurn + 10);
|
||||
|
||||
case 'recent':
|
||||
// Show last 20 turns
|
||||
return entries.filter(e => e.turn >= totalTurns - 20);
|
||||
|
||||
case 'all':
|
||||
default:
|
||||
// Show all turns
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
|
||||
function updateTranscriptHighlight(): void {
|
||||
// Update current turn highlighting in transcript
|
||||
const currentTurn = viewer.getTurn();
|
||||
transcriptEntriesDiv.querySelectorAll('.transcript-entry').forEach(el => {
|
||||
const turn = parseInt(el.getAttribute('data-turn') || '-1', 10);
|
||||
if (turn === currentTurn) {
|
||||
el.classList.add('current');
|
||||
// Scroll the current entry into view if panel is open
|
||||
if (transcriptPanel.classList.contains('open')) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
} else {
|
||||
el.classList.remove('current');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Transcript panel event listeners
|
||||
transcriptToggleBtn.addEventListener('click', toggleTranscriptPanel);
|
||||
transcriptCloseBtn.addEventListener('click', closeTranscriptPanel);
|
||||
|
||||
transcriptViewMode.addEventListener('change', () => {
|
||||
const value = transcriptViewMode.value;
|
||||
if (value === 'all' || value === 'window' || value === 'recent') {
|
||||
transcriptViewModeValue = value;
|
||||
renderTranscript();
|
||||
}
|
||||
});
|
||||
|
||||
// Keyboard shortcuts
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (!viewer.getReplay()) return;
|
||||
|
|
@ -231,6 +348,11 @@ document.addEventListener('keydown', (e) => {
|
|||
updateUI();
|
||||
updateEventLog();
|
||||
break;
|
||||
case 'KeyT':
|
||||
// Toggle transcript panel (§15.3)
|
||||
e.preventDefault();
|
||||
toggleTranscriptPanel();
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue