asanwari's picture
fix: invalidate stale game assets after deploy
453d293
Raw
History Blame Contribute Delete
27.9 kB
import { getJson, postJson, setSessionToken } from "./api.js?v=20260615-2";
import { createNotebook } from "./notebook.js?v=20260615-2";
import { createScreenRouter } from "./router.js?v=20260615-2";
import { createWorldController } from "./world.js?v=20260615-2";
import { drawCharacterPreview } from "./character.js?v=20260615-2";
const $ = (selector) => document.querySelector(selector);
const $$ = (selector) => [...document.querySelectorAll(selector)];
const ui = {
registerTab: $("#registerTab"), loginTab: $("#loginTab"), authForm: $("#authForm"),
username: $("#usernameInput"), password: $("#passwordInput"), level: $("#levelInput"),
levelLabel: $("#levelLabel"), avatarLabel: $("#avatarLabel"), avatar: $("#avatarInput"),
appearanceFields: $("#appearanceFields"),
cameraTools: $("#cameraTools"), cameraPreview: $("#cameraPreview"), cameraCanvas: $("#cameraCanvas"),
startCamera: $("#startCameraButton"), captureCamera: $("#captureCameraButton"),
authSubmit: $("#authSubmitButton"), authStatus: $("#authStatus"), loading: $("#loadingCurtain"), loadingText: $("#loadingText"),
onboardingText: $("#onboardingText"), continueOnboarding: $("#continueOnboardingButton"),
agentName: $("#agentName"), activeMission: $("#activeMission"), logout: $("#logoutButton"),
worldPrompt: $("#worldPrompt"), interact: $("#interactButton"),
lessonForm: $("#lessonForm"), topic: $("#topicInput"), lessonReader: $("#lessonReader"),
lessonProgress: $("#lessonProgress"), lessonTitle: $("#lessonStepTitle"), lessonBody: $("#lessonStepBody"),
lessonExample: $("#lessonStepExample"), previousStep: $("#prevStepButton"), nextStep: $("#nextStepButton"),
teacherForm: $("#teacherQuestionForm"), teacherQuestion: $("#teacherQuestionInput"),
teacherChat: $("#teacherChat"), teacherChatMessages: $("#teacherChatMessages"),
closeTeacherChat: $("#closeTeacherChat"), teacherFollowupForm: $("#teacherFollowupForm"),
teacherFollowup: $("#teacherFollowupInput"),
newTopic: $("#newTopicButton"), startQuiz: $("#startQuizButton"), schoolStatus: $("#schoolStatus"),
phone: $("#phoneButton"), desk: $("#deskButton"), bed: $("#bedButton"), homeDialog: $("#homeDialog"),
closeHomeDialog: $("#closeHomeDialog"), phoneDialog: $("#phoneDialog"), phoneDialogText: $("#phoneDialogText"), practicePanel: $("#practicePanel"),
practiceSearch: $("#practiceSearchInput"), practiceList: $("#practiceList"), homeStatus: $("#homeStatus"),
bossName: $("#bossName"), bossBriefing: $("#bossBriefing"), startBoss: $("#startBossButton"),
quizForm: $("#quizAnswerForm"), quizQuestion: $("#quizQuestionText"), quizAnswer: $("#quizAnswerInput"), quizFeedback: $("#quizFeedback"),
clearQuiz: $("#clearQuizCanvasButton"), submitQuiz: $("#submitQuizAnswerButton"),
nextQuiz: $("#nextQuizQuestionButton"), quizProgress: $("#quizProgressBar"), quizProgressText: $("#quizProgressText"),
quizProgressTrack: $("#quizProgressTrack"),
battleBossName: $("#battleBossName"), battleStats: $("#battleStats"), bossQuestion: $("#bossQuestionText"),
bossForm: $("#bossAnswerForm"), bossAnswer: $("#bossAnswerInput"), bossFeedback: $("#bossFeedback"), clearBoss: $("#clearBossCanvasButton"), submitBoss: $("#submitBossAnswerButton"),
nextBoss: $("#nextBossQuestionButton"), bossProgress: $("#bossProgressBar"),
bossProgressTrack: $("#bossProgressTrack"),
hqVillain: $("#hqVillainImage"), battleVillain: $("#battleVillainImage"),
};
const state = {
authMode: "register", user: null, gameState: null, lesson: null, lessonStep: 0,
nearbyBuilding: null, quizQuestion: null, bossQuestion: null, bossState: null,
avatarDataUrl: null, onboardingIndex: 0,
worldPosition: null,
teacherChatKey: null, teacherChatHistory: [],
quizIndex: 0, quizPending: null, bossPending: null,
};
const quizNotebook = createNotebook($("#quizCanvas"));
const bossNotebook = createNotebook($("#bossCanvas"));
let world = null;
function ensureWorld() {
if (world) return world;
world = createWorldController({
mountId: "worldMount",
getPlayerName: () => state.user?.username || "AGENT",
getPlayerAppearance: () => state.user?.character_appearance || selectedAppearance(),
onNearbyChange: (building) => {
state.nearbyBuilding = building;
ui.interact.disabled = !building;
ui.worldPrompt.textContent = building ? `Press Enter to visit ${buildingLabel(building)}.` : "Use WASD or arrow keys to explore.";
},
onEnterBuilding: enterBuilding,
initialPlayerPosition: state.worldPosition,
});
return world;
}
const router = createScreenRouter({
onBeforeChange(current) {
if (current !== "world" || !world) return;
state.worldPosition = world.getPlayerPosition();
world.destroy();
world = null;
$("#worldMount").replaceChildren();
},
onAfterChange(next) {
if (next === "world") {
// Create Phaser only after the map element is visible and has dimensions.
requestAnimationFrame(() => ensureWorld().activate());
}
},
});
wireEvents();
updateCharacterPreview();
bootstrap();
async function bootstrap() {
setLoading(true, "Opening classified records...");
try {
const me = await getJson("/api/me");
state.user = me.user;
state.gameState = me.game_state;
state.lesson = me.latest_lesson;
hydratePersistentUi();
if (state.gameState.onboarding_complete) router.show("world", { replace: true });
else startOnboarding(true);
} catch {
router.show("auth", { replace: true });
} finally {
setLoading(false);
}
}
function wireEvents() {
ui.registerTab.addEventListener("click", () => setAuthMode("register"));
ui.loginTab.addEventListener("click", () => setAuthMode("login"));
ui.authForm.addEventListener("submit", submitAuth);
ui.avatar.addEventListener("change", readAvatar);
$$(".color-swatches input").forEach((input) => input.addEventListener("change", updateCharacterPreview));
ui.startCamera.addEventListener("click", startCamera);
ui.captureCamera.addEventListener("click", captureCamera);
ui.continueOnboarding.addEventListener("click", advanceOnboarding);
ui.logout.addEventListener("click", logout);
ui.interact.addEventListener("click", () => world?.enterNearby());
$$('[data-move]').forEach(wireMovementButton);
$$('[data-back-to-world]').forEach((button) => button.addEventListener("click", () => goWorld("Back on the map.")));
$$('[data-answer-exit]').forEach((button) => button.addEventListener("click", () => goWorld("You left the challenge.")));
ui.lessonForm.addEventListener("submit", startLesson);
ui.previousStep.addEventListener("click", () => changeLessonStep(-1));
ui.nextStep.addEventListener("click", advanceLesson);
ui.teacherForm.addEventListener("submit", askTeacher);
ui.teacherFollowupForm.addEventListener("submit", askTeacherFollowup);
ui.closeTeacherChat.addEventListener("click", () => { ui.teacherChat.hidden = true; });
ui.newTopic.addEventListener("click", chooseNewTopic);
ui.startQuiz.addEventListener("click", beginQuiz);
ui.phone.addEventListener("click", showPhone);
ui.desk.addEventListener("click", showPractice);
ui.bed.addEventListener("click", rest);
ui.closeHomeDialog.addEventListener("click", () => { ui.homeDialog.hidden = true; });
ui.practiceSearch.addEventListener("input", renderPractice);
ui.startBoss.addEventListener("click", beginBoss);
ui.clearQuiz.addEventListener("click", quizNotebook.clear);
ui.clearBoss.addEventListener("click", bossNotebook.clear);
ui.quizForm.addEventListener("submit", submitQuizAnswer);
ui.bossForm.addEventListener("submit", submitBossAnswer);
ui.nextQuiz.addEventListener("click", advanceQuizAfterFeedback);
ui.nextBoss.addEventListener("click", advanceBossAfterFeedback);
}
function wireMovementButton(button) {
const stop = (event) => {
world?.stopMoving(button.dataset.move);
if (event?.pointerId !== undefined && button.hasPointerCapture?.(event.pointerId)) {
button.releasePointerCapture(event.pointerId);
}
};
button.addEventListener("pointerdown", (event) => {
event.preventDefault();
button.setPointerCapture?.(event.pointerId);
world?.startMoving(button.dataset.move);
});
button.addEventListener("pointerup", stop);
button.addEventListener("pointercancel", stop);
button.addEventListener("lostpointercapture", () => world?.stopMoving(button.dataset.move));
button.addEventListener("contextmenu", (event) => event.preventDefault());
}
function setAuthMode(mode) {
state.authMode = mode;
ui.registerTab.classList.toggle("active", mode === "register");
ui.loginTab.classList.toggle("active", mode === "login");
ui.levelLabel.hidden = mode !== "register";
ui.avatarLabel.hidden = mode !== "register";
ui.appearanceFields.hidden = mode !== "register";
ui.cameraTools.hidden = mode !== "register";
ui.authSubmit.textContent = mode === "register" ? "Create Agent File" : "Enter Headquarters";
ui.authStatus.textContent = "";
}
async function submitAuth(event) {
event.preventDefault();
const payload = { username: ui.username.value.trim(), password: ui.password.value };
if (state.authMode === "register") {
payload.learner_level = ui.level.value;
payload.avatar_image_data_url = state.avatarDataUrl;
payload.character_appearance = selectedAppearance();
}
setLoading(true, state.authMode === "register" ? "Creating your cover identity..." : "Checking credentials...");
try {
const response = await postJson(`/api/auth/${state.authMode}`, payload);
setSessionToken(response.session_token);
state.user = response.user;
state.gameState = response.game_state;
state.lesson = null;
hydratePersistentUi();
if (state.gameState.onboarding_complete) router.show("world");
else startOnboarding();
} catch (error) {
ui.authStatus.textContent = error.message;
} finally {
setLoading(false);
}
}
async function logout() {
try {
await postJson("/api/auth/logout");
} finally {
setSessionToken(null);
}
state.user = null; state.gameState = null; state.lesson = null;
setAuthMode("login");
router.show("auth");
}
const onboardingLines = [
"Welcome, agent. To everyone else, you are an ordinary student.",
"At School, choose a topic and learn the skills hidden inside it.",
"At Home, your secret phone delivers missions and your desk stores practice notes.",
"When you are ready, visit Grandma's House. Nobody suspects it is headquarters.",
];
function startOnboarding(replace = false) {
state.onboardingIndex = 0;
ui.onboardingText.textContent = onboardingLines[0];
ui.continueOnboarding.textContent = "Continue";
router.show("onboarding", { replace });
}
async function advanceOnboarding() {
state.onboardingIndex += 1;
if (state.onboardingIndex < onboardingLines.length) {
ui.onboardingText.textContent = onboardingLines[state.onboardingIndex];
ui.continueOnboarding.textContent = state.onboardingIndex === onboardingLines.length - 1 ? "Begin Mission" : "Continue";
return;
}
await postJson("/api/state/location", { location: "world", story_milestone: "onboarding_complete" });
state.gameState.onboarding_complete = true;
goWorld("First stop: School.");
}
async function enterBuilding(kind) {
if (!state.user || router.current() !== "world") return;
await postJson("/api/state/location", { location: kind });
if (kind === "school") {
prepareSchool();
router.show("school");
} else if (kind === "home") {
prepareHome();
router.show("home");
} else {
prepareHq();
router.show("hq");
}
}
function goWorld(message) {
hydratePersistentUi();
router.show("world");
ui.worldPrompt.textContent = message || "Use WASD or arrow keys to explore.";
}
function prepareSchool() {
ui.schoolStatus.textContent = state.lesson ? `Current subject: ${state.lesson.topic}` : "The teacher is ready.";
if (state.lesson) renderLesson();
else {
ui.lessonForm.hidden = false;
ui.lessonReader.hidden = true;
}
}
function chooseNewTopic() {
ui.lessonReader.hidden = true;
ui.lessonForm.hidden = false;
ui.topic.value = "";
ui.topic.focus();
ui.schoolStatus.textContent = "Choose the next subject to investigate.";
}
async function startLesson(event) {
event.preventDefault();
setLoading(true, "The teacher is preparing the blackboard...");
try {
state.lesson = await postJson("/api/lesson/start", { topic: ui.topic.value.trim() });
state.lessonStep = 0;
resetTeacherChat();
renderLesson();
hydratePersistentUi();
ui.schoolStatus.textContent = "Lesson ready.";
} catch (error) {
ui.schoolStatus.textContent = error.message;
} finally {
setLoading(false);
}
}
function renderLesson() {
if (!state.lesson) return;
const steps = state.lesson.lesson_steps;
const step = steps[state.lessonStep];
ui.lessonForm.hidden = true;
ui.lessonReader.hidden = false;
ui.lessonProgress.textContent = `Lesson ${state.lessonStep + 1}/${steps.length}`;
ui.lessonTitle.textContent = step.title;
ui.lessonBody.textContent = step.body;
ui.lessonExample.textContent = step.example || "";
ui.previousStep.disabled = state.lessonStep === 0;
const finalStep = state.lessonStep === steps.length - 1;
ui.nextStep.disabled = false;
ui.nextStep.textContent = finalStep ? "Start Quiz" : "Next";
ui.startQuiz.hidden = !finalStep;
ui.startQuiz.disabled = !finalStep;
ui.schoolStatus.textContent = finalStep
? "Lesson complete. Select Start Quiz to open your notebook."
: `Lesson step ${state.lessonStep + 1} of ${steps.length}.`;
}
function changeLessonStep(change) {
if (!state.lesson) return;
state.lessonStep = Math.max(0, Math.min(state.lesson.lesson_steps.length - 1, state.lessonStep + change));
ensureTeacherChatForStep();
renderLesson();
scrollToStart(ui.lessonReader);
}
function advanceLesson() {
if (!state.lesson) return;
const finalStep = state.lessonStep === state.lesson.lesson_steps.length - 1;
if (finalStep) beginQuiz();
else changeLessonStep(1);
}
async function askTeacher(event) {
event.preventDefault();
const question = ui.teacherQuestion.value.trim();
if (!question) return;
ui.teacherQuestion.value = "";
await sendTeacherQuestion(question);
}
async function askTeacherFollowup(event) {
event.preventDefault();
const question = ui.teacherFollowup.value.trim();
if (!question) return;
ui.teacherFollowup.value = "";
await sendTeacherQuestion(question);
}
async function sendTeacherQuestion(question) {
if (!state.lesson) return;
ensureTeacherChatForStep();
const priorHistory = state.teacherChatHistory.slice(-12);
state.teacherChatHistory.push({ role: "student", content: question });
renderTeacherChat();
ui.teacherChat.hidden = false;
setLoading(true, "The teacher is thinking...");
try {
const response = await postJson(`/api/lesson/${state.lesson.id}/ask`, {
step_index: state.lessonStep,
question,
history: priorHistory,
});
state.teacherChatHistory.push({ role: "teacher", content: response.answer });
} catch (error) {
state.teacherChatHistory.push({ role: "teacher", content: `I could not answer that yet: ${error.message}` });
} finally {
setLoading(false);
renderTeacherChat({ alignLatestTeacher: true });
ui.teacherFollowup.focus();
}
}
function ensureTeacherChatForStep() {
const key = state.lesson ? `${state.lesson.id}:${state.lessonStep}` : null;
if (key === state.teacherChatKey) return;
state.teacherChatKey = key;
state.teacherChatHistory = [];
ui.teacherChat.hidden = true;
renderTeacherChat();
}
function resetTeacherChat() {
state.teacherChatKey = null;
state.teacherChatHistory = [];
ensureTeacherChatForStep();
}
function renderTeacherChat({ alignLatestTeacher = false } = {}) {
ui.teacherChatMessages.replaceChildren();
for (const message of state.teacherChatHistory) {
const bubble = document.createElement("p");
bubble.className = `chat-message ${message.role}`;
bubble.textContent = message.content;
ui.teacherChatMessages.append(bubble);
}
if (alignLatestTeacher) {
const latestTeacher = ui.teacherChatMessages.querySelector(".chat-message.teacher:last-of-type");
if (latestTeacher) requestAnimationFrame(() => scrollElementToTop(ui.teacherChatMessages, latestTeacher));
} else {
ui.teacherChatMessages.scrollTop = ui.teacherChatMessages.scrollHeight;
}
}
function prefersReducedMotion() {
return window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
}
function scrollToStart(element) {
element.scrollTo({ top: 0, behavior: prefersReducedMotion() ? "auto" : "smooth" });
}
function scrollElementToTop(container, element) {
container.scrollTo({
top: Math.max(0, element.offsetTop - container.offsetTop),
behavior: prefersReducedMotion() ? "auto" : "smooth",
});
}
function beginQuiz() {
if (!state.lesson?.quiz_questions.length) return;
state.quizQuestion = state.lesson.quiz_questions[0];
state.quizIndex = 0;
state.quizPending = null;
renderQuizQuestion();
router.show("quiz");
setTimeout(() => ui.quizAnswer.focus(), 0);
}
function renderQuizQuestion() {
const total = state.lesson.quiz_questions.length;
ui.quizQuestion.textContent = state.quizQuestion.question;
ui.quizProgressText.textContent = `Question ${state.quizIndex + 1} of ${total}`;
ui.quizProgress.style.width = `${((state.quizIndex + 1) / total) * 100}%`;
ui.quizProgressTrack.setAttribute("aria-valuemax", total);
ui.quizProgressTrack.setAttribute("aria-valuenow", state.quizIndex + 1);
ui.quizAnswer.value = "";
setFeedback("quiz", "neutral", "");
ui.nextQuiz.hidden = true;
setAnswerControls("quiz", false);
quizNotebook.clear();
}
async function submitQuizAnswer(event) {
event?.preventDefault();
if (state.quizPending) return;
const payload = answerPayload("quiz", state.quizQuestion, ui.quizAnswer, quizNotebook);
if (!payload) { setFeedback("quiz", "error", "Type or draw an answer first."); return; }
setLoading(true, "Checking your notebook...");
try {
const response = await postJson("/api/quiz/submit", payload);
setFeedback("quiz", response.result.correct ? "correct" : "incorrect", response.result.feedback);
state.quizPending = response;
setAnswerControls("quiz", true);
if (response.completed) {
appendFeedback("quiz", " Quiz complete. Go home for your mission call.");
ui.nextQuiz.textContent = "Finish Quiz";
} else {
ui.nextQuiz.textContent = "Next Question";
}
ui.nextQuiz.hidden = false;
} catch (error) { setFeedback("quiz", "error", error.message); }
finally { setLoading(false); }
}
function advanceQuizAfterFeedback() {
if (!state.quizPending) return;
if (state.quizPending.completed) {
state.quizPending = null;
goWorld("Head home for your mission briefing.");
return;
}
state.quizQuestion = state.quizPending.next_question;
const nextIndex = state.lesson.quiz_questions.findIndex((question) => question.id === state.quizQuestion.id);
state.quizIndex = nextIndex >= 0 ? nextIndex : state.quizIndex + 1;
state.quizPending = null;
renderQuizQuestion();
ui.quizAnswer.focus();
}
function prepareHome() {
ui.homeDialog.hidden = true;
ui.homeStatus.textContent = state.lesson ? "Your secret phone is flashing." : "Visit School to begin a mission.";
}
function showPhone() {
ui.homeDialog.hidden = false;
ui.phoneDialog.hidden = false;
ui.practicePanel.hidden = true;
ui.phoneDialogText.textContent = state.lesson
? `Agent, ${state.lesson.boss_mission.boss_name} is attacking everything connected to ${state.lesson.topic}. Practice if needed, then report to Grandma's House.`
: "No mission yet. Your next assignment begins at School.";
}
function showPractice() {
ui.homeDialog.hidden = false;
ui.phoneDialog.hidden = true;
ui.practicePanel.hidden = false;
renderPractice();
}
function renderPractice() {
ui.practiceList.replaceChildren();
if (!state.lesson) { ui.practiceList.textContent = "No notes yet."; return; }
const query = ui.practiceSearch.value.trim().toLowerCase();
const notes = [
...state.lesson.lesson_steps.map((step) => ({ title: step.title, body: `${step.body} ${step.example || ""}` })),
...state.lesson.quiz_questions.map((question) => ({ title: question.question, body: question.explanation })),
].filter((note) => `${note.title} ${note.body}`.toLowerCase().includes(query));
for (const note of notes) {
const article = document.createElement("article");
article.className = "practice-item";
const title = document.createElement("strong"); title.textContent = note.title;
const body = document.createElement("p"); body.textContent = note.body;
article.append(title, body); ui.practiceList.append(article);
}
}
async function rest() {
await postJson("/api/state/location", { location: "home", story_milestone: "rest" });
ui.homeStatus.textContent = "You rested. Agent health restored.";
}
function prepareHq() {
ui.bossName.textContent = state.lesson?.boss_mission.boss_name || "No boss detected";
ui.bossBriefing.textContent = state.lesson?.boss_mission.briefing || "Complete a lesson before opening the vault.";
ui.startBoss.disabled = !state.lesson;
setVillainImage(ui.hqVillain, state.lesson?.boss_mission.villain_image_url);
}
async function beginBoss() {
if (!state.lesson) return;
setLoading(true, "Unlocking the mission vault...");
try {
state.bossState = await postJson("/api/boss/start", { lesson_id: state.lesson.id });
state.bossQuestion = state.bossState.question;
state.bossPending = null;
renderBossQuestion();
router.show("boss");
setTimeout(() => ui.bossAnswer.focus(), 0);
} catch (error) { ui.bossBriefing.textContent = error.message; }
finally { setLoading(false); }
}
function renderBossQuestion() {
ui.battleBossName.textContent = state.bossState.boss_name;
ui.battleStats.textContent = `Question ${state.bossState.question_index}/${state.bossState.total_questions} | Mistakes left: ${state.bossState.mistakes_remaining}`;
ui.bossProgress.style.width = `${(state.bossState.question_index / state.bossState.total_questions) * 100}%`;
ui.bossProgressTrack.setAttribute("aria-valuemax", state.bossState.total_questions);
ui.bossProgressTrack.setAttribute("aria-valuenow", state.bossState.question_index);
setVillainImage(ui.battleVillain, state.lesson?.boss_mission.villain_image_url);
ui.bossQuestion.textContent = state.bossQuestion.question;
ui.bossAnswer.value = "";
setFeedback("boss", "neutral", "");
ui.nextBoss.hidden = true;
setAnswerControls("boss", false);
bossNotebook.clear();
}
async function submitBossAnswer(event) {
event?.preventDefault();
if (state.bossPending) return;
const payload = answerPayload("boss", state.bossQuestion, ui.bossAnswer, bossNotebook);
if (!payload) { setFeedback("boss", "error", "Transmit an answer first."); return; }
setLoading(true, "Resolving attack...");
try {
const response = await postJson("/api/boss/submit", payload);
setFeedback("boss", response.result.correct ? "correct" : "incorrect", response.result.feedback);
state.bossState = response;
state.bossPending = response;
setAnswerControls("boss", true);
if (response.defeated) {
appendFeedback("boss", " Mission complete. The world is safe.");
ui.nextBoss.textContent = "Complete Mission";
} else if (response.lost) {
appendFeedback("boss", " Retreat home and rest before trying again.");
ui.nextBoss.textContent = "Retreat";
} else {
ui.nextBoss.textContent = response.question?.id === state.bossQuestion?.id ? "Try Again" : "Next Question";
}
ui.nextBoss.hidden = false;
} catch (error) { setFeedback("boss", "error", error.message); }
finally { setLoading(false); }
}
function advanceBossAfterFeedback() {
if (!state.bossPending) return;
if (state.bossPending.defeated) {
state.bossPending = null;
goWorld("Mission complete. School is ready for a new topic.");
return;
}
if (state.bossPending.lost) {
state.bossPending = null;
goWorld("Return home to recover.");
return;
}
state.bossQuestion = state.bossPending.question;
state.bossPending = null;
renderBossQuestion();
ui.bossAnswer.focus();
}
function setAnswerControls(mode, locked) {
const controls = mode === "quiz"
? [ui.quizAnswer, ui.clearQuiz, ui.submitQuiz]
: [ui.bossAnswer, ui.clearBoss, ui.submitBoss];
controls.forEach((control) => { control.disabled = locked; });
const canvas = mode === "quiz" ? $("#quizCanvas") : $("#bossCanvas");
canvas.classList.toggle("locked", locked);
}
function setFeedback(mode, status, message) {
const feedback = mode === "quiz" ? ui.quizFeedback : ui.bossFeedback;
const surface = mode === "quiz" ? ui.quizForm : ui.bossForm;
feedback.textContent = message;
feedback.dataset.status = status;
surface.dataset.feedback = status;
}
function appendFeedback(mode, message) {
const feedback = mode === "quiz" ? ui.quizFeedback : ui.bossFeedback;
feedback.textContent += message;
}
function safeVillainUrl(value) {
if (!value) return "/game-static/assets/villain-default.png";
if (value.startsWith("/game-static/assets/") || value.startsWith("https://")) return value;
return "/game-static/assets/villain-default.png";
}
function setVillainImage(image, value) {
const fallback = "/game-static/assets/villain-default.png";
image.onerror = () => { image.onerror = null; image.src = fallback; };
image.src = safeVillainUrl(value);
}
function answerPayload(mode, question, input, notebook) {
if (!state.lesson || !question) return null;
const text = input.value.trim();
const base = { lesson_id: state.lesson.id, question_id: question.id, mode };
if (text) return { ...base, answer_text: text };
const image = notebook.toDataUrlIfUsed();
if (!image) return null;
return { ...base, image_data_url: image };
}
function hydratePersistentUi() {
ui.agentName.textContent = state.user?.username || "-";
ui.activeMission.textContent = state.lesson?.topic || state.gameState?.active_topic || "Find a lesson";
}
function selectedAppearance() {
return {
shirt_color: $('input[name="shirtColor"]:checked')?.value || "red",
pants_color: $('input[name="pantsColor"]:checked')?.value || "navy",
hair_color: $('input[name="hairColor"]:checked')?.value || "dark_brown",
};
}
function updateCharacterPreview() {
const preview = $(".character-preview");
drawCharacterPreview(preview, selectedAppearance());
}
function buildingLabel(kind) {
if (kind === "school") return "School";
if (kind === "home") return "Home";
return "Grandma";
}
async function readAvatar() {
const file = ui.avatar.files?.[0];
if (!file) return;
state.avatarDataUrl = await fileToDataUrl(file);
}
async function startCamera() {
if (!navigator.mediaDevices?.getUserMedia) { ui.authStatus.textContent = "Webcam is unavailable."; return; }
const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false });
ui.cameraPreview.srcObject = stream;
ui.cameraPreview.hidden = false;
ui.captureCamera.hidden = false;
await ui.cameraPreview.play();
}
function captureCamera() {
const context = ui.cameraCanvas.getContext("2d");
context.drawImage(ui.cameraPreview, 0, 0, ui.cameraCanvas.width, ui.cameraCanvas.height);
state.avatarDataUrl = ui.cameraCanvas.toDataURL("image/png");
ui.cameraCanvas.hidden = false;
}
function fileToDataUrl(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = reject; reader.readAsDataURL(file);
});
}
function setLoading(visible, text = "Loading...") {
ui.loading.hidden = !visible;
ui.loadingText.textContent = text;
}