veritaminal
Version:
An AI-powered border control simulation game inspired by Papers, Please. Make critical decisions as a border agent while navigating corruption, trust, and moral dilemmas across multiple story-driven scenarios.
365 lines (321 loc) • 15 kB
JavaScript
/**
* UI module for Veritaminal
* Handles the terminal-based user interface using chalk for styling
* and readline-sync for interactive prompts (more compatible with Bun).
*/
import chalk from 'chalk';
import readlineSync from 'readline-sync';
import os from 'os';
// Helper function for centering text
function centerText(text, width) {
const textLength = text.replace(/\x1b\[[0-9;]*m/g, '').length; // Remove ANSI codes for length calculation
const padding = Math.max(0, Math.floor((width - textLength) / 2));
return ' '.repeat(padding) + text;
}
// Helper function for left-justifying text
function ljust(text, width) {
const textLength = text.replace(/\x1b\[[0-9;]*m/g, '').length;
const padding = Math.max(0, width - textLength);
return text + ' '.repeat(padding);
}
class TerminalUI {
/**
* Manages the terminal-based user interface.
*/
constructor() {
this.width = this._getTerminalWidth();
// Define color styles using chalk
this.colors = {
title: chalk.bold.yellow.bgBlue, // Bold Yellow on Blue
header: chalk.bold.blue,
normal: chalk.white,
error: chalk.bold.red,
success: chalk.bold.green,
warning: chalk.yellow,
hint: chalk.cyan.italic,
command: chalk.magenta,
veritas: chalk.bold.green,
border_info: chalk.yellow,
border: chalk.blue,
key: chalk.cyan,
value: chalk.white,
dim: chalk.dim, // Dim style for less important info
// Add more as needed
};
}
/** Get terminal width, defaulting if unavailable. */
_getTerminalWidth() {
try {
return process.stdout.columns || 80;
} catch (e) {
return 80;
}
}
/** Adjusts UI width based on current terminal size. */
adjustTerminalSize() {
this.width = this._getTerminalWidth();
}
/** Clears the terminal screen. */
clearScreen() {
console.clear();
}
/**
* Returns text formatted with the specified chalk style.
* @param {string} text - Text to format.
* @param {keyof this.colors} styleName - Name of the style to apply.
* @returns {string} Formatted text.
*/
coloredText(text, styleName) {
const style = this.colors[styleName] || this.colors.normal;
return style(text);
}
/**
* Prints text with the specified color style.
* @param {string} text - Text to print.
* @param {keyof this.colors} [styleName='normal'] - Name of the style to apply.
*/
print(text, styleName = 'normal') {
console.log(this.coloredText(text, styleName));
}
/**
* Draws a border line with an optional title.
* @param {string} [title=null] - Title to display, centered.
* @param {string} [char='='] - Character to use for the border.
* @param {keyof this.colors} [style='border'] - Style for the border line.
* @param {keyof this.colors} [titleStyle='title'] - Style for the title.
*/
drawBorder(title = null, char = '=', style = 'border', titleStyle = 'title') {
const borderLine = this.coloredText(char.repeat(this.width), style);
console.log("\n" + borderLine);
if (title) {
console.log(centerText(this.coloredText(title, titleStyle), this.width));
console.log(borderLine + "\n");
} else {
console.log(); // Just an empty line if no title
}
}
/** Displays the welcome message. */
displayWelcome() {
this.clearScreen();
this.drawBorder("VERITAMINAL: Document Verification Game");
const welcomeText = [
"Welcome, Agent.",
"",
"Your task: Verify traveler documents.",
"Approve or Deny entry based on regulations and observation.",
"",
"Veritas, your AI assistant, may offer insights.",
"Decisions have consequences.",
"",
"Type 'help' for commands.",
];
welcomeText.forEach(line => console.log(centerText(this.coloredText(line, 'normal'), this.width)));
this.drawBorder();
}
/**
* Displays a document to the player.
* @param {object} document - The document object { name, permit, backstory, additional_fields? }.
*/
displayDocument(document) {
if (!document) {
this.print("Error displaying document.", 'error');
return;
}
this.clearScreen();
this.drawBorder("TRAVELER DOCUMENT");
console.log(`${this.coloredText(ljust('Name:', 10), 'key')} ${this.coloredText(document.name || 'N/A', 'value')}`);
console.log(`${this.coloredText(ljust('Permit:', 10), 'key')} ${this.coloredText(document.permit || 'N/A', 'value')}`);
console.log(`\n${this.coloredText('Backstory:', 'key')}`);
console.log(this.coloredText(document.backstory || 'No backstory provided.', 'value'));
const additionalFields = document.additional_fields || {};
const fieldKeys = Object.keys(additionalFields);
if (fieldKeys.length > 0) {
console.log(`\n${this.coloredText('Additional Information:', 'header')}`);
fieldKeys.forEach(field => {
const keyText = field.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()) + ':'; // Format key nicely
console.log(`${this.coloredText(ljust(keyText, 20), 'key')} ${this.coloredText(additionalFields[field], 'value')}`);
});
}
this.drawBorder(null, '-'); // Use different char for internal separator
}
/**
* Displays a hint from Veritas.
* @param {string} hint - The hint text.
*/
displayVeritasHint(hint) {
this.drawBorder("VERITAS ASSISTANT", '=', 'veritas', 'veritas');
console.log(centerText(this.coloredText(`"${hint}"`, 'hint'), this.width));
this.drawBorder(null, '=', 'veritas');
}
/**
* Displays the current verification rules.
* @param {import('./gameplay.js').Rule[]} rules - List of Rule objects.
*/
displayRules(rules) {
this.clearScreen();
this.drawBorder("VERIFICATION RULES");
if (!rules || rules.length === 0) {
this.print("No specific rules currently active.", 'warning');
} else {
rules.forEach((rule, index) => {
console.log(`${this.coloredText(String(index + 1) + '. ' + rule.name + ':', 'key')} ${this.coloredText(rule.description, 'normal')}`);
});
}
this.drawBorder(null, '-');
}
/** Displays help information. */
displayHelp() {
this.clearScreen();
this.drawBorder("HELP - AVAILABLE COMMANDS");
const commands = [
{ cmd: "approve", desc: "Approve the current traveler's entry." },
{ cmd: "deny", desc: "Deny the current traveler's entry." },
{ cmd: "hint", desc: "Request a hint from Veritas AI assistant." },
{ cmd: "rules", desc: "Display current verification rules for this border." },
{ cmd: "save", desc: "Save your current game progress." },
{ cmd: "quit", desc: "Quit the current game and return to the main menu." },
{ cmd: "help", desc: "Show this help information." },
];
commands.forEach(({ cmd, desc }) => {
console.log(`${this.coloredText(ljust(cmd, 10), 'command')} - ${this.coloredText(desc, 'normal')}`);
});
this.drawBorder(null, '-');
}
/**
* Displays feedback based on the player's decision.
* @param {boolean} isCorrect - Whether the decision was correct.
* @param {string} narrativeUpdate - The narrative update text.
* @param {object|null} aiJudgment - The AI's judgment for comparison.
*/
displayFeedback(isCorrect, narrativeUpdate, aiJudgment) {
console.log("\n" + this.coloredText("-".repeat(this.width), 'border')); // Separator
if (isCorrect) {
this.print("✓ Correct Decision!", 'success');
} else {
this.print("✗ Incorrect Decision!", 'error');
if (aiJudgment) {
this.print(` (AI would have decided: ${this.coloredText(aiJudgment.decision.toUpperCase(), 'warning')})`, 'dim');
}
}
this.print(`\n${narrativeUpdate}`, 'normal'); // Display narrative consequence
if (!isCorrect && aiJudgment) {
this.print("\n--- AI Assessment ---", 'header');
this.printReasoning(aiJudgment.reasoning, aiJudgment.confidence, aiJudgment.suspicious_elements);
}
console.log(this.coloredText("-".repeat(this.width), 'border') + "\n");
}
/**
* Displays AI reasoning details.
* @param {string} reasoning - The AI's reasoning text.
* @param {number} confidence - The AI's confidence score (0-1).
* @param {string[]} suspiciousElements - List of suspicious elements.
*/
printReasoning(reasoning, confidence, suspiciousElements) {
const confidencePct = (confidence * 100).toFixed(0);
let confidenceStyle = 'success';
if (confidencePct < 75) confidenceStyle = 'warning';
if (confidencePct < 50) confidenceStyle = 'error';
this.print(`${this.coloredText('Confidence:', 'key')} ${this.coloredText(confidencePct + '%', confidenceStyle)}`);
this.print(`${this.coloredText('Reasoning:', 'key')} ${this.coloredText(reasoning || 'N/A', 'normal')}`);
if (suspiciousElements && suspiciousElements.length > 0) {
this.print(`${this.coloredText('Suspicious:', 'key')}`, 'normal');
suspiciousElements.forEach(el => this.print(` - ${this.coloredText(el, 'warning')}`));
}
}
/**
* Displays the game over screen.
* @param {string} endingType - Type of ending ('good', 'bad_corrupt', 'bad_strict', 'neutral_corrupt', etc.).
* @param {string} endingMessage - The final message.
* @param {number} finalScore - The player's final score.
*/
displayGameOver(endingType, endingMessage, finalScore) {
this.clearScreen();
this.drawBorder("ASSIGNMENT COMPLETE / CAREER OVER");
const endingStyle = endingType.startsWith('good') ? 'success' : endingType.includes('corrupt') ? 'error' : endingType.includes('strict') ? 'warning' : 'normal';
console.log(centerText(this.coloredText(endingMessage, endingStyle), this.width));
console.log("\n" + centerText(this.coloredText(`Final Score: ${finalScore.toFixed(2)}`, 'value'), this.width) + "\n");
this.drawBorder();
}
/**
* Displays status information (Day, Score, Narrative State).
* @param {number} day - Current day.
* @param {number} score - Current score.
* @param {string} stateSummary - Summary string from NarrativeManager.
*/
displayStatus(day, score, stateSummary) {
this.drawBorder(null, '-'); // Separator before status
const dayStr = `${this.coloredText('Day:', 'key')} ${this.coloredText(String(day), 'value')}`;
const scoreStr = `${this.coloredText('Score:', 'key')} ${this.coloredText(score.toFixed(2), 'value')}`;
const stateStr = this.coloredText(stateSummary, 'border_info');
console.log(`${dayStr} | ${scoreStr}`);
console.log(stateStr);
this.drawBorder(null, '='); // Main border after status
}
/**
* Uses readline-sync to get the user's next command.
* @async
* @param {string} [message='Enter command > '] - The prompt message.
* @returns {Promise<string>} The user's command (lowercase, trimmed).
*/
async getUserInput(message = 'Enter command (approve, deny, hint, rules, save, quit, help) > ') {
try {
const input = readlineSync.question(this.coloredText(message, 'hint'));
return input.trim().toLowerCase();
} catch (error) {
console.error(chalk.red("\nInput error occurred."), error);
return "quit"; // Default to quit on error
}
}
/**
* Uses readline-sync to present a list of choices.
* @async
* @param {string} message - The question to ask.
* @param {Array<string|object>} choices - Array of choices. Can be strings or objects {name, value}.
* @param {string} [name='selection'] - Internal name for the answer (not used in readline-sync).
* @returns {Promise<any>} The value of the selected choice.
*/
async getListChoice(message, choices, name = 'selection') {
try {
console.log(this.coloredText(message, 'header'));
// Format choices to display properly
const formattedChoices = choices.map(choice => {
if (typeof choice === 'string') {
return choice;
} else if (choice && choice.name) {
return choice.name;
}
return String(choice);
});
// Display choices
formattedChoices.forEach((choiceName, index) => {
console.log(`${index + 1}. ${choiceName}`);
});
// Get selection (1-based for user, convert to 0-based for array)
const index = readlineSync.questionInt(this.coloredText('Select option number: ', 'hint'), {
limitMessage: this.coloredText('Please enter a number between 1 and ' + formattedChoices.length, 'error'),
limit: [1, formattedChoices.length]
}) - 1;
// Return the value (if object with value property) or the choice itself
return (choices[index] && typeof choices[index] === 'object' && 'value' in choices[index])
? choices[index].value
: choices[index];
} catch (error) {
console.error(chalk.red("\nChoice selection error occurred."), error);
return null; // Indicate error or cancellation
}
}
/**
* Simple prompt to wait for the user to press Enter.
* @async
* @param {string} [message='Press Enter to continue...']
*/
async pressEnterToContinue(message = 'Press Enter to continue...') {
try {
readlineSync.question(this.coloredText(message, 'hint'));
} catch (error) {
console.error(chalk.red("\nInput error occurred."), error);
// Continue anyway on error
}
}
}
export { TerminalUI };