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.
533 lines (461 loc) • 22.9 kB
JavaScript
/**
* Gameplay module for Veritaminal
* Handles the core gameplay mechanics including document generation,
* verification rules, and scoring.
*/
import chalk from 'chalk';
import { MemoryManager } from './memory.js';
import { SettingsManager } from './settings.js';
import {
generateDocumentForSetting,
aiJudgeDocument
// Import other API functions if needed directly by gameplay
} from './api.js';
/**
* Represents a verification rule for documents.
*/
class Rule {
/**
* Initialize a rule.
* @param {string} name - Name of the rule.
* @param {string} description - Description of the rule.
* @param {function(object): boolean} checkFunction - Function that checks if a document follows this rule.
*/
constructor(name, description, checkFunction) {
this.name = name;
this.description = description;
this.checkFunction = checkFunction; // Primarily for display/reference now
}
/**
* Check if a document follows this rule (primarily for reference, AI makes main decision).
* @param {object} document - The document to check.
* @returns {boolean} True if the document follows this rule, False otherwise.
*/
check(document) {
try {
return this.checkFunction(document);
} catch (e) {
console.error(chalk.red(`Error checking rule "${this.name}": ${e.message}`));
return false; // Fail safe
}
}
}
class GameplayManager {
/**
* Manages the gameplay mechanics.
*/
constructor() {
this.score = 0;
/** @type {object|null} */
this.currentDocument = null;
/** @type {Rule[]} */
this.rules = []; // Basic rules, mainly for display reference
this.memoryManager = new MemoryManager();
this.settingsManager = new SettingsManager();
this._initializeRules();
/** @type {import('./api.js').AIJudgment | null} */
this.aiJudgment = null; // Stores the AI's judgment of the current document
this.gameCompleted = false; // Track if player has completed a full game
this.travelersProcessedToday = 0;
// Get travelers per day from settings manager
this.travelersPerDay = this.settingsManager.getGameConfig().travelersPerDay;
}
/** Initialize basic verification rules (mainly for display/reference). */
_initializeRules() {
this.rules = [
new Rule(
"Permit Format",
"Permits must start with 'P'.",
(doc) => typeof doc.permit === 'string' && doc.permit.startsWith("P")
),
new Rule(
"Permit Number Length",
"Permit numbers must have 4 digits after the 'P' (total 5 chars).",
(doc) => typeof doc.permit === 'string' && doc.permit.length === 5
),
new Rule(
"Permit Digits",
"Characters after 'P' in permit must be digits.",
(doc) => typeof doc.permit === 'string' && /^\d{4}$/.test(doc.permit.substring(1))
),
new Rule(
"Name Format",
"Traveler names must include at least a first and last name.",
(doc) => typeof doc.name === 'string' && doc.name.trim().split(/\s+/).length >= 2
)
];
}
/**
* Adds a new rule (primarily for display/memory).
* @param {Rule} rule - The rule to add.
*/
addRule(rule) {
this.rules.push(rule);
this.memoryManager.addRuleChange(rule.description);
console.log(chalk.blue(`Gameplay: Added rule - ${rule.description}`));
}
/**
* Initializes a new game or loads state for the selected setting.
* @param {string} [settingId=null] - ID of the border setting. If null, uses the first available.
* @returns {object} The selected setting.
*/
async initializeGame(settingId = null) {
console.log(chalk.blue("Gameplay: Initializing new game..."));
// Reset game state for a new career/session
this.score = 0;
this.currentDocument = null;
this.aiJudgment = null;
this.gameCompleted = false;
this.travelersProcessedToday = 0;
// Reset memory but keep used names from previous sessions (optional)
const usedNames = this.memoryManager.memory.usedNames;
this.memoryManager.resetMemory();
this.memoryManager.memory.usedNames = usedNames; // Persist names across careers if desired
// Select a setting
const setting = settingId
? this.settingsManager.selectSetting(settingId)
: this.settingsManager.getAvailableSettings()[0]; // Default to first if no ID
this.settingsManager.selectSetting(setting.id); // Ensure it's set in settings manager
// Initialize memory with the selected setting
this.memoryManager.setBorderSetting(setting);
// Update travelers per day from current settings
this.travelersPerDay = this.settingsManager.getGameConfig().travelersPerDay;
this.memoryManager.addNarrativeEvent(
`You begin your shift at the ${setting.name}. Day ${this.memoryManager.memory.gameState.day}.`,
"start"
);
// Start a new session with initial save
await this.memoryManager.startNewSession();
return setting;
}
/**
* Generates a new document based on the current setting.
* @async
* @returns {Promise<object|null>} The generated document or null on error.
*/
async generateDocument() {
console.log(chalk.blue("Gameplay: Generating new document..."));
const setting = this.settingsManager.getCurrentSetting();
if (!setting) {
console.error(chalk.red("Gameplay Error: No setting selected. Cannot generate document."));
return null;
}
// Get context for AI
const usedNamesContext = this.memoryManager.getUsedNamesContext();
const settingContext = this.settingsManager.getSettingContext();
const memoryContext = this.memoryManager.getMemoryContext();
// Generate document content using API
const documentData = await generateDocumentForSetting(setting, usedNamesContext);
if (!documentData) {
console.error(chalk.red("Gameplay Error: Failed to generate document content from API."));
// Maybe return a predefined error document?
return { name: "Error", permit: "P0000", backstory: "Document generation failed.", is_valid: false, additional_fields: {} };
}
// Use AI to judge the generated document *before* showing to player
this.aiJudgment = await aiJudgeDocument(documentData, settingContext, memoryContext);
if (!this.aiJudgment) {
console.error(chalk.red("Gameplay Error: Failed to get AI judgment. Assuming invalid document."));
// Assign a fallback judgment if AI fails
this.aiJudgment = {
decision: 'deny',
confidence: 0.5,
reasoning: 'AI judgment system unavailable.',
suspicious_elements: ['AI system unavailable']
};
}
// Store the AI's ground truth decision in the document object (for internal use/scoring)
// Player does not see this directly.
documentData.is_valid = this.aiJudgment.decision === "approve";
this.currentDocument = documentData;
console.log(chalk.green(`Gameplay: Document generated for ${this.currentDocument.name}. AI decision: ${this.aiJudgment.decision}`));
return this.currentDocument;
}
/**
* Checks if a document is valid according to the AI's judgment.
* @param {object} document - The document to check.
* @returns {boolean} True if the AI judged the document as valid ('approve').
*/
checkDocumentValidity(document) {
// The ground truth is now determined by the AI's judgment made during generation.
if (!this.aiJudgment) {
console.warn(chalk.yellow("Gameplay Warning: Checking validity without AI judgment available. Assuming invalid."));
return false; // Fail safe if AI judgment is missing
}
// Compare against the stored AI judgment for *this specific document*
const documentName = document?.name;
const judgmentName = this.currentDocument?.name; // Ensure judgment matches current doc
if (documentName === judgmentName) {
return this.aiJudgment.decision === "approve";
} else {
console.error(chalk.red(`Gameplay Error: Attempting to check validity of document "${documentName}" but current AI judgment is for "${judgmentName}".`));
return false; // Mismatch, assume invalid
}
}
/**
* Processes the player's decision on the current document.
* @param {('approve'|'deny')} playerDecision - The player's decision.
* @returns {{isCorrect: boolean, pointsEarned: number}|null} Result or null if no document.
*/
makeDecision(playerDecision) {
if (!this.currentDocument || !this.aiJudgment) {
console.error(chalk.red("Gameplay Error: No current document or AI judgment to make a decision on."));
return null;
}
const aiDecision = this.aiJudgment.decision;
const isCorrect = playerDecision === aiDecision;
const confidence = this.aiJudgment.confidence ?? 0.5;
// Calculate points (e.g., 1 point * confidence for correct, maybe negative for incorrect?)
// Simple scoring: 1 point * confidence if correct, 0 if incorrect.
const pointsEarned = isCorrect ? (1 * confidence) : 0;
this.score += pointsEarned;
this.score = Math.round(this.score * 100) / 100; // Keep score tidy
console.log(chalk.blue(`Gameplay: Player decided ${playerDecision}. AI decided ${aiDecision}. Correct: ${isCorrect}. Points: ${pointsEarned.toFixed(2)}. New Score: ${this.score}`));
// Update game state (corruption/trust) based on decision correctness
this.updateGameState(playerDecision, isCorrect);
// Store the decision and traveler in memory
this.memoryManager.addTraveler(
this.currentDocument,
playerDecision,
isCorrect,
this.aiJudgment
);
this.travelersProcessedToday += 1;
// Clear current document and judgment for the next one
// this.currentDocument = null; // Keep it until end of day? Let's clear after decision.
// this.aiJudgment = null;
return { isCorrect, pointsEarned };
}
/**
* Updates the game state (corruption/trust) based on the player's decision.
* @param {string} decision - The player's decision.
* @param {boolean} isCorrect - Whether the decision was correct.
*/
updateGameState(decision, isCorrect) {
const stateUpdates = {};
let currentCorruption = this.memoryManager.memory.gameState.corruption;
let currentTrust = this.memoryManager.memory.gameState.trust;
// Get the running stats for consecutive decisions
let correctStreak = this.memoryManager.memory.gameState.correctStreak || 0;
let incorrectStreak = this.memoryManager.memory.gameState.incorrectStreak || 0;
if (isCorrect) {
// Reset incorrect streak on correct decision
incorrectStreak = 0;
// Increment correct streak
correctStreak += 1;
// Increase trust after 2 correct decisions
if (correctStreak >= 2) {
stateUpdates.trust = currentTrust + 1;
correctStreak = 0; // Reset streak after applying bonus
console.log(chalk.green(`Gameplay State: Trust increased to ${stateUpdates.trust} after 2 correct decisions`));
}
} else {
// Reset correct streak on incorrect decision
correctStreak = 0;
// Increment incorrect streak
incorrectStreak += 1;
// Always decrease trust on any incorrect decision
stateUpdates.trust = currentTrust - 1;
console.log(chalk.yellow(`Gameplay State: Trust decreased to ${stateUpdates.trust} after incorrect decision`));
// Increase corruption after 2 wrong decisions
if (incorrectStreak >= 2) {
stateUpdates.corruption = currentCorruption + 1;
incorrectStreak = 0; // Reset streak after applying penalty
console.log(chalk.red(`Gameplay State: Corruption increased to ${stateUpdates.corruption} after 2 incorrect decisions`));
}
// Additional context based on decision type
if (decision === "approve") {
console.log(chalk.yellow(`Gameplay State: Incorrectly approving a suspicious traveler has greater impact on corruption.`));
// Could add additional effects here if desired
} else if (decision === "deny") {
console.log(chalk.yellow(`Gameplay State: Incorrectly denying a valid traveler has greater impact on trust.`));
// Could add additional effects here if desired
}
}
// Store the streak values in game state
stateUpdates.correctStreak = correctStreak;
stateUpdates.incorrectStreak = incorrectStreak;
// Apply the updates via memory manager
if (Object.keys(stateUpdates).length > 0) {
this.memoryManager.updateGameState(stateUpdates);
}
// Check for game over conditions based on corruption/trust
this.checkGameOverConditions();
}
/**
* Checks if game over conditions are met based on corruption/trust levels.
* @returns {boolean} True if a game over condition is met.
*/
checkGameOverConditions() {
const gameState = this.memoryManager.memory.gameState;
const corruption = gameState.corruption;
const trust = gameState.trust;
// Game over thresholds
const corruptionThreshold = 5; // Game over if corruption reaches 5
const trustThreshold = -5; // Game over if trust falls to -5
// Check for corruption-based game over
if (corruption >= corruptionThreshold) {
console.log(chalk.red(`Gameplay: GAME OVER - Corruption level (${corruption}) exceeded threshold (${corruptionThreshold}).`));
this.gameCompleted = true;
this.memoryManager.addNarrativeEvent(
"Your continued tolerance of suspicious travelers has led to an investigation. Your career at border control is over.",
"game_over_corruption"
);
return true;
}
// Check for trust-based game over
if (trust <= trustThreshold) {
console.log(chalk.red(`Gameplay: GAME OVER - Trust level (${trust}) fell below threshold (${trustThreshold}).`));
this.gameCompleted = true;
this.memoryManager.addNarrativeEvent(
"Your excessive suspicion and denials have damaged relations. You've been reassigned away from border control.",
"game_over_trust"
);
return true;
}
return false;
}
/**
* Checks if the current day should end.
* @returns {boolean} True if the day should end.
*/
shouldEndDay() {
return this.travelersProcessedToday >= this.travelersPerDay;
}
/**
* Advances to the next day in the game. Checks for game completion.
* @returns {string} The day announcement message.
*/
advanceDay() {
console.log(chalk.blue(`Gameplay: Advancing day. Processed ${this.travelersProcessedToday} travelers.`));
this.memoryManager.advanceDay();
const day = this.memoryManager.memory.gameState.day;
this.travelersProcessedToday = 0; // Reset counter for the new day
// Check if player has completed the target number of days
const totalDays = this.settingsManager.getGameConfig().totalDays;
if (day > totalDays) {
this.gameCompleted = true;
return `Assignment Complete: You have finished your ${totalDays}-day assignment.`;
}
// Example Day-specific events (can be expanded)
let message = "";
if (day === 3) {
message = `Day ${day}: New regulations are in effect. Increased scrutiny expected.`;
// Example: Add a temporary rule or just let narrative/AI handle it
this.memoryManager.addRuleChange("Increased scrutiny protocols active.");
} else if (day === 7) {
message = `Day ${day}: Border tensions are high. Security measures tightened.`;
this.memoryManager.addNarrativeEvent("Border tensions spike.", "event");
} else {
const settingName = this.settingsManager.getCurrentSetting()?.name || "the border";
message = `Day ${day}: Another shift begins at the ${settingName}.`;
}
this.memoryManager.addNarrativeEvent(message, "day_change");
return message;
}
/**
* Gets all current rules (basic + setting-specific).
* @returns {Rule[]} List of all rules.
*/
getAllRules() {
// Combine basic rules with setting-specific descriptions
const settingRules = this.settingsManager.getAllRules();
const combinedRules = [...this.rules]; // Start with basic checkable rules
// Add setting rules as descriptive rules
settingRules.forEach((desc, index) => {
// Avoid adding duplicates if basic rules cover setting rules
if (!combinedRules.some(r => r.description === desc)) {
combinedRules.push(new Rule(`Setting Rule ${index + 1}`, desc, () => true)); // Non-checking function
}
});
return combinedRules;
}
/** Gets the current score. */
getScore() {
return this.score;
}
/**
* Gets the AI's reasoning for the current judgment.
* @returns {string} The AI's reasoning or a default message.
*/
getAiReasoning() {
if (this.aiJudgment?.reasoning) {
return this.aiJudgment.reasoning;
}
return "No AI reasoning available for the last document.";
}
/**
* Gets the AI's suspicious elements list for the current judgment.
* @returns {string[]} The AI's list or an empty array.
*/
getAiSuspiciousElements() {
return this.aiJudgment?.suspicious_elements || [];
}
/**
* Saves the current game state.
* @async
* @returns {Promise<boolean>} True if save was successful.
*/
async saveGame() {
console.log(chalk.blue("Gameplay: Attempting to save game..."));
// Save complete settings state before saving to file
const settingsData = {
currentSetting: this.settingsManager.getCurrentSetting(),
customRules: this.settingsManager.customRules,
gameConfig: this.settingsManager.getGameConfig()
};
this.memoryManager.updateSettings(settingsData);
return await this.memoryManager.saveGame(); // Filename handled by memory manager
}
/**
* Loads a saved game.
* @async
* @param {string} filepath - Absolute path to the save file.
* @returns {Promise<boolean>} True if load was successful.
*/
async loadGame(filepath) {
console.log(chalk.blue(`Gameplay: Attempting to load game from ${filepath}...`));
const success = await this.memoryManager.loadGame(filepath);
if (success) {
// Restore gameplay state from loaded memory
this.score = this.memoryManager.memory.decisions.reduce((totalScore, decision) => {
// Recalculate score based on loaded history if needed, or just load a saved score
// For simplicity, let's assume score is saved/loaded directly if added to memory save format
// Or we reset score on load and rely on future points. Let's reset.
return 0; // Reset score on load for now.
}, 0);
// Restore complete settings state from saved data
const savedSettings = this.memoryManager.getSavedSettings();
// Restore game configuration
if (savedSettings.gameConfig) {
this.settingsManager.gameConfig = { ...savedSettings.gameConfig };
this.travelersPerDay = savedSettings.gameConfig.travelersPerDay;
}
// Restore custom rules
if (savedSettings.customRules) {
this.settingsManager.customRules = [...savedSettings.customRules];
}
// Set the correct border setting
if (savedSettings.currentSettingId) {
this.settingsManager.selectSetting(savedSettings.currentSettingId);
} else {
// Fallback to legacy border setting data or first setting
const loadedSetting = this.memoryManager.memory.borderSetting;
if (loadedSetting && loadedSetting.id) {
this.settingsManager.selectSetting(loadedSetting.id);
} else {
this.settingsManager.selectSetting(this.settingsManager.getAvailableSettings()[0].id);
}
}
// Set game_completed flag if loaded day is past the limit
const totalDays = this.settingsManager.getGameConfig().totalDays;
this.gameCompleted = this.memoryManager.memory.gameState.day > totalDays;
this.travelersProcessedToday = 0; // Reset for the loaded day
console.log(chalk.green("Gameplay: Game loaded. State restored."));
return true;
} else {
console.error(chalk.red("Gameplay: Failed to load game."));
return false;
}
}
}
export { GameplayManager, Rule };