/**
* CombatDebugRenderer - State-focused combat renderer for debugging.
*
* Per SPEC-VIEWER ยง5.3:
* - R1: init() MUST be callable multiple times (re-init for new match)
* - R2: renderFrame() MUST handle any valid GameplayFrame
* - R3: destroy() MUST remove all DOM elements added by renderer
* - R4: Renderers MUST NOT modify MatchData or GameplayFrame objects
*/
class CombatDebugRenderer {
constructor() {
this._container = null;
this._matchData = null;
this._elements = {};
}
/**
* Initialize renderer in container
* @param {HTMLElement} container
* @param {MatchData} matchData
*/
init(container, matchData) {
// R1: Clean up previous init
this.destroy();
this._container = container;
this._matchData = matchData;
this._createDebugView();
this._renderLifecyclePanels();
}
/**
* Render a gameplay frame
* @param {GameplayFrame} frame
*/
renderFrame(frame) {
if (!this._container) return;
// Update frame info
this._elements.frameNumber.textContent = frame.index + 1;
this._elements.turnNumber.textContent = frame.turnNumber;
this._elements.player.textContent = frame.player;
this._elements.action.textContent = frame.action;
// Update state before/after
this._elements.stateBefore.textContent = JSON.stringify(frame.stateBefore, null, 2);
this._elements.stateAfter.textContent = JSON.stringify(frame.stateAfter, null, 2);
// Update reasoning if present
if (frame.reasoning) {
this._elements.reasoning.textContent = frame.reasoning;
this._elements.reasoningContainer.style.display = 'block';
} else {
this._elements.reasoningContainer.style.display = 'none';
}
// Update prompt data if present
if (frame.prompt) {
this._elements.promptText.textContent = frame.prompt.promptText || 'N/A';
this._elements.responseText.textContent = frame.prompt.responseText || 'N/A';
this._elements.duration.textContent = `${frame.prompt.duration || 0}ms`;
this._elements.promptContainer.style.display = 'block';
} else {
this._elements.promptContainer.style.display = 'none';
}
}
/**
* Render victory screen
* @param {string | null} winner
* @param {GameState} finalState
*/
renderVictory(winner, finalState, outcomeInfo = {}) {
if (!this._container) return;
const { outcome, forfeitReason, forfeitingPlayer } = outcomeInfo;
this._elements.victoryContainer.style.display = 'block';
this._elements.victoryTitle.textContent = outcome === 'forfeit' ? 'Match Forfeited' : 'Match Complete';
this._elements.winner.textContent = winner || 'Draw';
this._elements.finalState.textContent = JSON.stringify(finalState, null, 2);
if (outcome === 'forfeit') {
this._elements.outcomeRow.style.display = 'flex';
this._elements.outcome.textContent = 'Forfeit';
this._elements.outcomeDetailRow.style.display = 'flex';
this._elements.outcomeDetail.textContent = forfeitingPlayer
? `${forfeitingPlayer} ${this._formatForfeitReason(forfeitReason)}`
: this._formatForfeitReason(forfeitReason);
return;
}
this._elements.outcomeRow.style.display = 'none';
this._elements.outcome.textContent = '';
this._elements.outcomeDetailRow.style.display = 'none';
this._elements.outcomeDetail.textContent = '';
}
/**
* Cleanup resources
*/
destroy() {
if (this._container && this._elements.root) {
this._container.removeChild(this._elements.root);
}
this._container = null;
this._matchData = null;
this._elements = {};
}
// =========================================================================
// Private: View Creation
// =========================================================================
_createDebugView() {
const root = document.createElement('div');
root.className = 'debug-viewer';
root.innerHTML = `
-
-
-
-
Match Complete
-
Final State
{}
Conclusions / Reflections
`;
// Store element references
this._elements.root = root;
this._elements.handshakeContainer = root.querySelector('#debug-handshake-container');
this._elements.handshakeList = root.querySelector('#debug-handshake-list');
this._elements.conclusionContainer = root.querySelector('#debug-conclusion-container');
this._elements.conclusionList = root.querySelector('#debug-conclusion-list');
this._elements.frameNumber = root.querySelector('#debug-frame-number');
this._elements.turnNumber = root.querySelector('#debug-turn-number');
this._elements.player = root.querySelector('#debug-player');
this._elements.action = root.querySelector('#debug-action');
this._elements.highlightRow = root.querySelector('#debug-highlight-row');
this._elements.highlightIcon = root.querySelector('#debug-highlight-icon');
this._elements.highlightText = root.querySelector('#debug-highlight-text');
this._elements.stateBefore = root.querySelector('#debug-state-before');
this._elements.stateAfter = root.querySelector('#debug-state-after');
this._elements.reasoningContainer = root.querySelector('#debug-reasoning-container');
this._elements.reasoning = root.querySelector('#debug-reasoning');
this._elements.promptContainer = root.querySelector('#debug-prompt-container');
this._elements.promptText = root.querySelector('#debug-prompt-text');
this._elements.responseText = root.querySelector('#debug-response-text');
this._elements.duration = root.querySelector('#debug-duration');
this._elements.victoryContainer = root.querySelector('#debug-victory-container');
this._elements.victoryTitle = root.querySelector('#debug-victory-title');
this._elements.outcomeRow = root.querySelector('#debug-outcome-row');
this._elements.outcome = root.querySelector('#debug-outcome');
this._elements.outcomeDetailRow = root.querySelector('#debug-outcome-detail-row');
this._elements.outcomeDetail = root.querySelector('#debug-outcome-detail');
this._elements.winner = root.querySelector('#debug-winner');
this._elements.finalState = root.querySelector('#debug-final-state');
this._container.appendChild(root);
}
_renderLifecyclePanels() {
const lifecycle = this._matchData?.lifecycle || {};
const handshakes = Array.isArray(lifecycle.handshakes) ? lifecycle.handshakes : [];
const conclusions = Array.isArray(lifecycle.conclusions) ? lifecycle.conclusions : [];
if (handshakes.length > 0) {
this._elements.handshakeContainer.style.display = 'block';
this._elements.handshakeList.innerHTML = handshakes
.map((entry) => {
const status = this._formatHandshakeStatus(entry);
const prompt = entry.promptText || 'N/A';
const response = entry.responseText || entry.normalizedResponse || 'N/A';
return `
${this._escapeHtml(entry.player)}
${this._escapeHtml(status)}
Prompt
${this._escapeHtml(prompt)}
Response
${this._escapeHtml(response)}
`;
})
.join('');
} else {
this._elements.handshakeContainer.style.display = 'none';
}
if (conclusions.length > 0) {
this._elements.conclusionContainer.style.display = 'block';
this._elements.conclusionList.innerHTML = conclusions
.map((entry) => {
const prompt = entry.promptText || 'N/A';
const reflection = entry.reflectionText || entry.responseText || 'N/A';
return `
${this._escapeHtml(entry.player)}
Conclusion
Prompt
${this._escapeHtml(prompt)}
Reflection
${this._escapeHtml(reflection)}
`;
})
.join('');
} else {
this._elements.conclusionContainer.style.display = 'none';
}
}
_formatHandshakeStatus(entry) {
if (entry.status === 'aborted') {
return `Aborted${entry.reason ? ` (${entry.reason})` : ''}`;
}
if (entry.status === 'complete') {
return entry.accepted === false ? 'Rejected' : 'Accepted';
}
return 'Started';
}
_escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
_formatForfeitReason(reason) {
switch (reason) {
case 'parse_failure':
return 'failed to provide a valid action';
case 'timeout':
return 'ran out of time';
case 'invalid_action':
return 'chose an invalid action';
case 'disconnect':
return 'disconnected';
default:
return 'forfeited the match';
}
}
setActiveHighlight(highlight) {
const row = this._elements.highlightRow;
const icon = this._elements.highlightIcon;
const text = this._elements.highlightText;
if (!row || !icon || !text) return;
if (!highlight || !highlight.label) {
row.style.display = 'none';
row.removeAttribute('data-kind');
icon.textContent = '';
text.textContent = '';
return;
}
row.style.display = 'flex';
if (highlight.kind) row.dataset.kind = highlight.kind;
else row.removeAttribute('data-kind');
icon.textContent = highlight.icon || '';
text.textContent = highlight.label;
}
}
// Register renderer for the bundled combat games (if registry present).
if (typeof RendererRegistry !== 'undefined') {
RendererRegistry.register('ArchivistChoiceGame', 'debug', CombatDebugRenderer);
RendererRegistry.register('FixedDamageGame', 'debug', CombatDebugRenderer);
RendererRegistry.register('VariableDamageGame', 'debug', CombatDebugRenderer);
}
// Export for module systems, also available as global
if (typeof module !== 'undefined' && module.exports) {
module.exports = CombatDebugRenderer;
}