diff --git a/CLAUDE.md b/CLAUDE.md index cf38f7ede..bd78621f7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -258,6 +258,16 @@ Three singletons manage core state. All run on the main thread. Access via `Clas - **Security**: bearer tokens are never logged in Sentry breadcrumbs; manifests must not contain absolute paths/usernames. Upload requires an explicit user action in the GUI; CLI uses `--no-confirm` for CI. API base override: `QTMESH_API_BASE` (tests + self-hosted). - **Limits**: `qtmesh cloud limits` / MCP `cloud_limits` read server caps from `GET /v1/auth/me` when exposed; scan reports are capped at 5 MB client-side. +### Gamification / Progress Sync (epic #796, cloud epic qtmesh-cloud#79) + +- **GamificationManager** (`src/GamificationManager.h/cpp`): singleton orchestrator (also a QML singleton under both `WelcomeScreen 1.0` and `PropertiesPanel 1.0`). Instrumentation is two static one-liners: `GamificationManager::noteFeature("", Surface::Gui|Cli|Mcp)` at controller entry points (E-P2 #798) and `noteOperation("", {{"tris_before", n}, …})` where before/after metrics exist (E-P3 #799). `noteOperation` also counts the matching feature cluster, so op sites need only one call. Both are thread-safe (marshal to the main thread) and no-ops until the user consents. +- **Privacy invariants (E-P6 #802)**: default OFF — nothing is queued before the one-time non-blocking consent prompt (shown on first would-be event while signed in) is answered or "Sync my QtMesh progress" is enabled in Preferences → General. Metrics are filtered to numeric values only (`numericMetricsOnly`) so asset content/file names can never leak. Zero network when logged out or opted out. `DELETE /v1/me/gamification` + local queue/cache purge via the Preferences "Delete my gamification data" button. CLI honours `--no-telemetry`. +- **Queue + flush (E-P1 #797)**: `GamificationEventQueue` (`src/GamificationEventQueue.h/cpp`) is a persistent, cross-process-safe (QLockFile) JSON queue in `/gamification/queue.json`; every event carries a client UUID idempotency id (the server dedup key), capacity 500 with logged FIFO eviction. GUI flushes on a debounce + 90s heartbeat + graceful-shutdown, with exponential backoff (max 30 min); CLI enqueues during the subcommand and calls `flushBlocking()` before `_exit`. Batches go to `POST /v1/events/editor` / `POST /v1/events/operations` (max 200/batch); newly-earned achievements come back in the response. +- **Cloud contract**: feature/op keys are `[a-z0-9_]+`; the 25 discovery cluster keys live in `Gamification::featureCatalog()` (`src/GamificationTypes.h/cpp`) and MUST match qtmesh-cloud's `DISCOVERY_FEATURES`. `GET /v1/me/stats` parses into `Gamification::StatsSnapshot` (NB mixed casing on the wire: `stats`/`featureUsage` snake_case, `progress`/`achievements` camelCase); cached to `/gamification/stats_cache.json` for offline rendering. Milestone progress ("nearest unlockable") is computed client-side from `milestoneCatalog()` + counters. +- **Status surface (E-P4 #800)**: level + XP bar, streak and nearest-unlockable progress render in the `CloudAccountMenuButton` menu (refreshes stats opportunistically on menu open); "View My Achievements…" opens `https://qtmesh.dev/u/` (profile is private by default — `setProfilePublic` PUTs `/v1/me/gamification/prefs`). Unlocks show ONE coalesced `GamificationToast` (no animation, auto-hide, click-to-dismiss). +- **Discovery nudges (E-P5 #801)**: the welcome screen shows at most one dismissible "try this next" card from `GamificationManager.suggestion` — personalized to unused clusters when stats exist, generic rotation when logged out; rotation cursor + dismissals persist in QSettings (`Gamification/*` keys, see `AppSettingsKeys.h`). +- **Ops with metrics instrumented**: retopo (GUI+CLI), decimate/LOD (GUI+CLI), optimize (CLI), uv_unwrap (GUI), auto_rig (GUI sync/async/marker + CLI), skin_weights (GUI+CLI), fix (CLI), texture_atlas pack (GUI), isometric_sprites (GUI), vat_bake (GUI), vertex_color_bake (GUI), morph (GUI), motion_inbetween (GUI), pbr_synth (AIAssistManager). MCP tools map to clusters in `MCPServer::callTool`; CLI subcommands map in `CLIPipeline::run`. + ### MCP Server - **MCPServer** (`src/MCPServer.h/cpp`): JSON-RPC 2.0 over stdio + HTTP REST API on configurable port. diff --git a/qml/PreferencesDialog.qml b/qml/PreferencesDialog.qml index 5d185dd54..a5239dfb0 100644 --- a/qml/PreferencesDialog.qml +++ b/qml/PreferencesDialog.qml @@ -236,6 +236,194 @@ Rectangle { } Text { text: "Show welcome screen on startup"; font.pixelSize: 12; color: textColor; anchors.verticalCenter: parent.verticalCenter } } + + // ---- Progress Sync (gamification, #796 E-P6) ---- + Rectangle { width: parent.width; height: 1; color: borderColor } + + Text { + text: "Progress Sync (QtMesh Cloud)" + font.pixelSize: 12; font.bold: true; color: textColor + } + + Text { + text: "Tracks which tools you discover and your editing milestones " + + "(e.g. \u201cretopo: 42,180 \u2192 8,004 tris\u201d) on your QtMesh Cloud " + + "profile. Only feature names, counts and numeric before/after " + + "metrics are shared \u2014 never your models, textures or file names." + font.pixelSize: 11; font.italic: true; color: dimTextColor + wrapMode: Text.WordWrap; width: parent.width + } + + // Master toggle + Row { + spacing: 6 + width: parent.width + Rectangle { + width: 14; height: 14; anchors.verticalCenter: parent.verticalCenter + border.color: borderColor; border.width: 1; radius: 2 + color: GamificationManager.syncEnabled ? highlightColor : "transparent" + Text { anchors.centerIn: parent; text: GamificationManager.syncEnabled ? "\u2713" : ""; color: "white"; font.pixelSize: 10 } + MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor + onClicked: GamificationManager.syncEnabled = !GamificationManager.syncEnabled + } + } + Text { text: "Sync my QtMesh progress"; font.pixelSize: 12; color: textColor; anchors.verticalCenter: parent.verticalCenter } + } + + // Per-stream sub-toggles + Column { + width: parent.width + spacing: 6 + leftPadding: 20 + enabled: GamificationManager.syncEnabled + opacity: GamificationManager.syncEnabled ? 1.0 : 0.45 + + Row { + spacing: 6 + Rectangle { + width: 14; height: 14; anchors.verticalCenter: parent.verticalCenter + border.color: borderColor; border.width: 1; radius: 2 + color: GamificationManager.usageEnabled ? highlightColor : "transparent" + Text { anchors.centerIn: parent; text: GamificationManager.usageEnabled ? "\u2713" : ""; color: "white"; font.pixelSize: 10 } + MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor + onClicked: GamificationManager.usageEnabled = !GamificationManager.usageEnabled + } + } + Text { text: "Feature usage (tool discovery)"; font.pixelSize: 12; color: textColor; anchors.verticalCenter: parent.verticalCenter } + } + + Row { + spacing: 6 + Rectangle { + width: 14; height: 14; anchors.verticalCenter: parent.verticalCenter + border.color: borderColor; border.width: 1; radius: 2 + color: GamificationManager.opsEnabled ? highlightColor : "transparent" + Text { anchors.centerIn: parent; text: GamificationManager.opsEnabled ? "\u2713" : ""; color: "white"; font.pixelSize: 10 } + MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor + onClicked: GamificationManager.opsEnabled = !GamificationManager.opsEnabled + } + } + Text { text: "Operations history (numeric before/after metrics)"; font.pixelSize: 12; color: textColor; anchors.verticalCenter: parent.verticalCenter } + } + } + + // Nudges toggle + Row { + spacing: 6 + width: parent.width + Rectangle { + width: 14; height: 14; anchors.verticalCenter: parent.verticalCenter + border.color: borderColor; border.width: 1; radius: 2 + color: GamificationManager.nudgesEnabled ? highlightColor : "transparent" + Text { anchors.centerIn: parent; text: GamificationManager.nudgesEnabled ? "\u2713" : ""; color: "white"; font.pixelSize: 10 } + MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor + onClicked: GamificationManager.nudgesEnabled = !GamificationManager.nudgesEnabled + } + } + Text { text: "Show \u201ctry this next\u201d suggestions on the welcome screen"; font.pixelSize: 12; color: textColor; anchors.verticalCenter: parent.verticalCenter } + } + + // Public profile (account-level; needs a cloud session + // AND sync enabled — no cloud traffic when opted out) + Row { + spacing: 6 + width: parent.width + visible: GamificationManager.signedIn && GamificationManager.syncEnabled + onVisibleChanged: if (visible) GamificationManager.refreshCloudPrefs() + Component.onCompleted: if (visible) GamificationManager.refreshCloudPrefs() + + Rectangle { + width: 14; height: 14; anchors.verticalCenter: parent.verticalCenter + border.color: borderColor; border.width: 1; radius: 2 + color: GamificationManager.profilePublic ? highlightColor : "transparent" + Text { anchors.centerIn: parent; text: GamificationManager.profilePublic ? "✓" : ""; color: "white"; font.pixelSize: 10 } + MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor + onClicked: GamificationManager.setProfilePublic(!GamificationManager.profilePublic) + } + } + Text { text: "Public achievement profile (qtmesh.dev/u/…)"; font.pixelSize: 12; color: textColor; anchors.verticalCenter: parent.verticalCenter } + } + + // What is shared? (expandable example payload) + Column { + width: parent.width + spacing: 4 + property bool expanded: false + + Text { + text: (parent.expanded ? "\u25be" : "\u25b8") + " What exactly is shared?" + font.pixelSize: 11; color: highlightColor + MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor + onClicked: parent.parent.expanded = !parent.parent.expanded } + } + + Rectangle { + visible: parent.expanded + width: parent.width + height: exampleText.implicitHeight + 16 + color: Qt.darker(panelColor, 1.1) + border.color: borderColor; border.width: 1; radius: 3 + + Text { + id: exampleText + anchors { fill: parent; margins: 8 } + text: GamificationManager.examplePayload() + font.family: "Menlo, Consolas, monospace" + font.pixelSize: 10 + color: dimTextColor + wrapMode: Text.WrapAnywhere + } + } + } + + // Delete gamification data (two-click confirm) + Row { + spacing: 8 + width: parent.width + property bool confirming: false + + Rectangle { + width: 220; height: 26; radius: 3 + color: parent.confirming ? "#a33" : Qt.darker(panelColor, 1.1) + border.color: parent.confirming ? "#c55" : borderColor + border.width: 1 + + Text { + anchors.centerIn: parent + text: parent.parent.confirming + ? "Click again to permanently delete" + : "Delete my gamification data\u2026" + font.pixelSize: 11 + color: parent.parent.confirming ? "white" : textColor + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + if (parent.parent.confirming) { + parent.parent.confirming = false + GamificationManager.deleteCloudData() + } else { + parent.parent.confirming = true + confirmResetTimer.restart() + } + } + } + Timer { + id: confirmResetTimer + interval: 4000 + onTriggered: parent.parent.confirming = false + } + } + + Text { + text: "Removes achievements, XP, streaks and operations history from your account, and clears the local queue." + font.pixelSize: 10; font.italic: true; color: dimTextColor + wrapMode: Text.WordWrap + width: parent.width - 236 + anchors.verticalCenter: parent.verticalCenter + } + } } // --- Appearance Tab --- diff --git a/qml/WelcomeScreen.qml b/qml/WelcomeScreen.qml index bef8a49ec..106d7fa6d 100644 --- a/qml/WelcomeScreen.qml +++ b/qml/WelcomeScreen.qml @@ -317,6 +317,82 @@ Rectangle { } } + // ---- Gamification: "try this next" discovery nudge (E-P5) ---- + Rectangle { + id: suggestionCard + property var suggestion: GamificationManager.suggestion + visible: suggestion !== undefined && suggestion.featureKey !== undefined + Layout.fillWidth: true + implicitHeight: suggestionCol.implicitHeight + 20 + radius: 6 + color: PropertiesPanelController.headerColor + border.color: PropertiesPanelController.highlightColor + border.width: 1 + + ColumnLayout { + id: suggestionCol + anchors { + left: parent.left; right: parent.right + top: parent.top + margins: 10 + } + spacing: 3 + + RowLayout { + Layout.fillWidth: true + spacing: 6 + + Text { + text: (suggestionCard.suggestion && suggestionCard.suggestion.personalized + ? "✨ New to you: " : "✨ Try this: ") + + (suggestionCard.suggestion ? suggestionCard.suggestion.title : "") + color: PropertiesPanelController.textColor + font.pixelSize: 12 + font.bold: true + elide: Text.ElideRight + Layout.fillWidth: true + } + + // Rotate to another suggestion + Text { + text: "↻" + color: Qt.darker(PropertiesPanelController.textColor, 1.4) + font.pixelSize: 13 + MouseArea { + anchors.fill: parent + anchors.margins: -4 + cursorShape: Qt.PointingHandCursor + onClicked: GamificationManager.advanceSuggestion() + } + } + + // Dismiss this suggestion permanently + Text { + text: "✕" + color: Qt.darker(PropertiesPanelController.textColor, 1.4) + font.pixelSize: 12 + MouseArea { + anchors.fill: parent + anchors.margins: -4 + cursorShape: Qt.PointingHandCursor + onClicked: { + if (suggestionCard.suggestion) + GamificationManager.dismissSuggestion(suggestionCard.suggestion.featureKey) + } + } + } + } + + Text { + text: suggestionCard.suggestion ? suggestionCard.suggestion.blurb : "" + color: Qt.darker(PropertiesPanelController.textColor, 1.4) + font.pixelSize: 10 + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + } + } + // ---- Separator ---- Rectangle { Layout.fillWidth: true diff --git a/src/AIAssistManager.cpp b/src/AIAssistManager.cpp index b50258526..93ef9fa62 100644 --- a/src/AIAssistManager.cpp +++ b/src/AIAssistManager.cpp @@ -1,5 +1,6 @@ #include "AIAssistManager.h" +#include "GamificationManager.h" #include "NormalMapGenerator.h" #include "TextureUpscaler.h" #include "ModelDownloader.h" @@ -285,6 +286,15 @@ PbrMapSynthResult AIAssistManager::synthesizePbrMaps(const QString& albedoPath, if (!out.ok && out.error.isEmpty()) out.error = QStringLiteral("one or more requested maps could not be written"); + if (out.ok) { + const int mapsGenerated = (out.normalPath.isEmpty() ? 0 : 1) + + (out.roughnessPath.isEmpty() ? 0 : 1) + + (out.heightPath.isEmpty() ? 0 : 1); + GamificationManager::noteOperation( + QStringLiteral("pbr_synth"), + {{QStringLiteral("maps_generated"), mapsGenerated}}); + } + if (out.ok) emit synthesisCompleted(out.toVariantMap()); else emit synthesisError(out.error); return out; @@ -316,6 +326,7 @@ QString AIAssistManager::upscaleTexture(const QString& srcPath, int scale, bool { SentryReporter::addBreadcrumb(QStringLiteral("ai.assist.upscale"), QStringLiteral("upscale %1 x%2").arg(QFileInfo(srcPath).fileName()).arg(scale)); + GamificationManager::noteFeature(QStringLiteral("pbr_synth")); emit upscaleStarted(); auto failUp = [&](const QString& msg) -> QString { diff --git a/src/AnimationBlender.cpp b/src/AnimationBlender.cpp index 3135b3ca8..e961e1fb2 100644 --- a/src/AnimationBlender.cpp +++ b/src/AnimationBlender.cpp @@ -1,4 +1,5 @@ #include "AnimationBlender.h" +#include "GamificationManager.h" #include "AnimationControlController.h" #include "SelectionSet.h" #include "SentryReporter.h" @@ -508,6 +509,8 @@ QString AnimationBlender::bake(const QString& clipName, int fps) static_cast(std::ceil(length * static_cast(fps))) + 1); const float step = length / static_cast(sampleCount - 1); + GamificationManager::noteFeature(QStringLiteral("animation_blend")); + if (skel->hasAnimation(clipStd)) skel->removeAnimation(clipStd); Ogre::Animation* anim = skel->createAnimation(clipStd, length); anim->setInterpolationMode(Ogre::Animation::IM_LINEAR); diff --git a/src/AnimationControlController.cpp b/src/AnimationControlController.cpp index f7d058b49..95ee37a34 100644 --- a/src/AnimationControlController.cpp +++ b/src/AnimationControlController.cpp @@ -1,4 +1,5 @@ #include "AnimationControlController.h" +#include "GamificationManager.h" #include "PropertiesPanelController.h" #include "SelectionSet.h" #include "Manager.h" @@ -1643,6 +1644,11 @@ QVariantMap AnimationControlController::inbetweenWindow(double t0, double t1, emit keyframeTicksChanged(); emit currentKeyframeChanged(); + GamificationManager::noteOperation( + QStringLiteral("motion_inbetween"), + {{QStringLiteral("keyframes_inserted"), r.keyframesInserted}, + {QStringLiteral("tracks_affected"), r.tracksAffected}}); + QString msg = QStringLiteral("Inserted %1 keyframes across %2 track(s) via %3") .arg(r.keyframesInserted).arg(r.tracksAffected) .arg(r.usedModel ? QStringLiteral("RMIB model") @@ -1678,6 +1684,7 @@ QVariantMap AnimationControlController::generateMotion(const QString& prompt, SentryReporter::addBreadcrumb(QStringLiteral("ai.assist.text_to_motion"), QStringLiteral("GUI generate_motion")); + GamificationManager::noteFeature(QStringLiteral("animation_blend")); // Acquire the canonical clip: EXPERIMENTAL trained model first (when opted in), // else the reliable TEMPLATE library — which is also the automatic fallback. diff --git a/src/AppSettingsKeys.h b/src/AppSettingsKeys.h index b11b644ff..e68a98fcf 100644 --- a/src/AppSettingsKeys.h +++ b/src/AppSettingsKeys.h @@ -158,6 +158,65 @@ inline const QString& updaterSkippedVersion() return k; } +// ---- Gamification / progress sync (#796) ---- + +/** @brief One-time consent prompt answered (accept OR decline). Nothing is + * ever queued or sent before this is true (E-P6: default off). */ +inline const QString& gamificationConsentAcknowledged() +{ + static const QString k(QStringLiteral("Gamification/consentAcknowledged")); + return k; +} + +/** @brief Whether the one-time consent prompt has been shown. */ +inline const QString& gamificationConsentPrompted() +{ + static const QString k(QStringLiteral("Gamification/consentPrompted")); + return k; +} + +/** @brief Master "Sync my QtMesh progress" toggle (default off). */ +inline const QString& gamificationSyncEnabled() +{ + static const QString k(QStringLiteral("Gamification/syncEnabled")); + return k; +} + +/** @brief Sub-toggle: feature-usage (discovery) events. */ +inline const QString& gamificationUsageEnabled() +{ + static const QString k(QStringLiteral("Gamification/usageEnabled")); + return k; +} + +/** @brief Sub-toggle: operations-history (before/after metrics) events. */ +inline const QString& gamificationOpsEnabled() +{ + static const QString k(QStringLiteral("Gamification/opsEnabled")); + return k; +} + +/** @brief "Try this next" welcome-screen nudges on/off (E-P5). */ +inline const QString& gamificationNudgesEnabled() +{ + static const QString k(QStringLiteral("Gamification/nudgesEnabled")); + return k; +} + +/** @brief Feature keys the user dismissed from the nudge card. */ +inline const QString& gamificationDismissedSuggestions() +{ + static const QString k(QStringLiteral("Gamification/dismissedSuggestions")); + return k; +} + +/** @brief Rotation cursor so the nudge card cycles between app runs. */ +inline const QString& gamificationSuggestionCursor() +{ + static const QString k(QStringLiteral("Gamification/suggestionCursor")); + return k; +} + /** @brief Default preset rig for new empty scenes (Slice E #487). */ inline const QString& lightingDefaultRig() { diff --git a/src/AutoRigController.cpp b/src/AutoRigController.cpp index 517ce55a3..50cb9f0d4 100644 --- a/src/AutoRigController.cpp +++ b/src/AutoRigController.cpp @@ -1,5 +1,6 @@ #include "AutoRigController.h" #include "AutoRig.h" +#include "GamificationManager.h" #include "UniRigPredictor.h" #include "SkinWeights.h" #include "SelectionSet.h" @@ -306,6 +307,12 @@ QVariantMap AutoRigController::autoRigSelected(const QString& templateName, if (!report.fallbackReason.isEmpty()) result["fallbackReason"] = report.fallbackReason; if (!report.error.isEmpty()) result["error"] = report.error; + if (report.applied) + GamificationManager::noteOperation( + QStringLiteral("auto_rig"), + {{QStringLiteral("bones_created"), report.boneCount}, + {QStringLiteral("meshes_skinned"), skinned ? 1 : 0}}); + if (report.applied) emit rigged(result); else emit error(report.error.isEmpty() ? QStringLiteral("Auto-rig failed") : report.error); @@ -338,6 +345,12 @@ void AutoRigController::emitRigResult(const AutoRig::Report& report, bool skinne if (!report.fallbackReason.isEmpty()) result["fallbackReason"] = report.fallbackReason; if (!report.error.isEmpty()) result["error"] = report.error; + if (report.applied) + GamificationManager::noteOperation( + QStringLiteral("auto_rig"), + {{QStringLiteral("bones_created"), report.boneCount}, + {QStringLiteral("meshes_skinned"), skinned ? 1 : 0}}); + if (report.applied) emit rigged(result); else emit error(report.error.isEmpty() ? QStringLiteral("Auto-rig failed") : report.error); @@ -622,6 +635,12 @@ QVariantMap AutoRigController::commitMarkerRig(bool alsoSkin) result["skinned"] = skinned; if (!report.error.isEmpty()) result["error"] = report.error; + if (report.applied) + GamificationManager::noteOperation( + QStringLiteral("auto_rig"), + {{QStringLiteral("bones_created"), report.boneCount}, + {QStringLiteral("meshes_skinned"), skinned ? 1 : 0}}); + if (report.applied) emit rigged(result); else emit error(report.error.isEmpty() ? QStringLiteral("Auto-rig failed") : report.error); return result; diff --git a/src/BatchExporter.cpp b/src/BatchExporter.cpp index c8d42da53..2a494a019 100644 --- a/src/BatchExporter.cpp +++ b/src/BatchExporter.cpp @@ -1,5 +1,6 @@ #include "BatchExporter.h" #include "CLIPipeline.h" +#include "GamificationManager.h" #include #include @@ -12,6 +13,8 @@ int BatchExporter::runCliPipeline(int argc, char* argv[]) { return CLIPipeline:: void BatchExporter::execute() { + GamificationManager::noteFeature(QStringLiteral("batch_export")); + int success = 0, fail = 0; for (int i = 0; i < static_cast(mInputFiles.size()); ++i) diff --git a/src/CLIPipeline.cpp b/src/CLIPipeline.cpp index 34bce51a7..f5ce03207 100644 --- a/src/CLIPipeline.cpp +++ b/src/CLIPipeline.cpp @@ -1,5 +1,6 @@ #include "CLIPipeline.h" #include "CloudCLIPipeline.h" +#include "GamificationManager.h" #include "Manager.h" #include "MeshImporterExporter.h" #include "AnimationMerger.h" @@ -1474,6 +1475,12 @@ int CLIPipeline::run(int argc, char* argv[]) // pollute the CLI pipeline output (JSON, info text, etc.) redirectStdout(); + // --no-telemetry also suppresses gamification events at every call site + // for this process — without this, operation notes inside the subcommands + // would land in the persistent queue and flush on a later run (#796). + if (s_noTelemetry) + GamificationManager::setEmissionSuspended(true); + // Telemetry: --no-telemetry permanently opts out. // On first run (no stored preference), show a one-time notice and enable. // In ephemeral environments (Docker), QTMESH_NO_TELEMETRY_NOTICE=1 @@ -1539,6 +1546,42 @@ int CLIPipeline::run(int argc, char* argv[]) rc = 2; } + // Gamification discovery (#798): a successful subcommand counts as use of + // its feature cluster. Only queued/sent when the user enabled progress + // sync (consent, E-P6) and has a cloud session; --no-telemetry blocks it. + if (rc == 0 && !s_noTelemetry) { + static const QHash cmdFeatureMap = { + {QStringLiteral("retopo"), QStringLiteral("retopo")}, + {QStringLiteral("decimate"), QStringLiteral("decimate_lod")}, + {QStringLiteral("lod"), QStringLiteral("decimate_lod")}, + {QStringLiteral("optimize"), QStringLiteral("decimate_lod")}, + {QStringLiteral("vertex-cache"), QStringLiteral("decimate_lod")}, + {QStringLiteral("uv"), QStringLiteral("uv_unwrap")}, + {QStringLiteral("skin"), QStringLiteral("skin_weights")}, + {QStringLiteral("rig"), QStringLiteral("auto_rig")}, + {QStringLiteral("anim"), QStringLiteral("animation_blend")}, + {QStringLiteral("morph"), QStringLiteral("morph")}, + {QStringLiteral("vat"), QStringLiteral("vat_bake")}, + {QStringLiteral("bake-vertex-colors"), QStringLiteral("vertex_color_bake")}, + {QStringLiteral("atlas"), QStringLiteral("texture_atlas")}, + {QStringLiteral("atlas-apply"), QStringLiteral("texture_atlas")}, + {QStringLiteral("pack-textures"), QStringLiteral("texture_atlas")}, + {QStringLiteral("normal-from-height"), QStringLiteral("pbr_synth")}, + {QStringLiteral("isometric"), QStringLiteral("isometric_sprites")}, + {QStringLiteral("turntable"), QStringLiteral("turntable")}, + {QStringLiteral("scan"), QStringLiteral("cli_scan")}, + {QStringLiteral("generate3d"), QStringLiteral("image_to_3d")}, + {QStringLiteral("material"), QStringLiteral("material_editor")}, + {QStringLiteral("segment"), QStringLiteral("ai_assist")}, + }; + const QString feature = cmdFeatureMap.value(cmd); + if (!feature.isEmpty()) + GamificationManager::noteFeature(feature, GamificationManager::Surface::Cli); + // One-shot process: flush whatever is queued (including operation + // events noted inside the subcommands) before _exit. + GamificationManager::instance()->flushBlocking(4000); + } + SentryReporter::finishTransaction(cliTxn); SentryReporter::shutdown(); _exit(rc); @@ -1862,6 +1905,13 @@ int CLIPipeline::cmdFix(int argc, char* argv[]) } cliWrite(report); + GamificationManager::noteOperation( + QStringLiteral("fix"), + {{QStringLiteral("verts_before"), static_cast(vertsBefore)}, + {QStringLiteral("verts_after"), static_cast(vertsAfter)}, + {QStringLiteral("tris_before"), static_cast(trisBefore)}, + {QStringLiteral("tris_after"), static_cast(trisAfter)}}, + GamificationManager::Surface::Cli); return 0; } @@ -6669,6 +6719,12 @@ int CLIPipeline::cmdDecimate(int argc, char* argv[]) } emitDecimationReport(report, fi, cmdArgs.outputPath, cmdArgs.jsonOutput); + if (report.applied) + GamificationManager::noteOperation( + QStringLiteral("decimate_lod"), + {{QStringLiteral("tris_before"), report.totalTrianglesBefore}, + {QStringLiteral("tris_after"), report.totalTrianglesAfter}}, + GamificationManager::Surface::Cli); return 0; } @@ -7019,6 +7075,12 @@ int CLIPipeline::cmdOptimize(int argc, char* argv[]) .arg(report.totalTrianglesBefore) .arg(report.totalTrianglesAfter); s.details = MeshDecimator::toJson(report); + if (report.applied) + GamificationManager::noteOperation( + QStringLiteral("optimize"), + {{QStringLiteral("tris_before"), report.totalTrianglesBefore}, + {QStringLiteral("tris_after"), report.totalTrianglesAfter}}, + GamificationManager::Surface::Cli); // Mirror cmdDecimate: when a positive reduction was asked for // but didn't apply, that's a hard error — the asset isn't // suitable for in-place reduction. Don't silently emit a @@ -8699,6 +8761,12 @@ int CLIPipeline::cmdRetopo(int argc, char* argv[]) cliWrite(QuadRetopo::reportToText(report) + QString("Wrote: %1\n").arg(QFileInfo(outputPath).fileName())); } + GamificationManager::noteOperation( + QStringLiteral("retopo"), + {{QStringLiteral("tris_before"), report.totalTrianglesBefore}, + {QStringLiteral("tris_after"), report.totalTrianglesAfterRetopo}, + {QStringLiteral("quad_ratio_after"), report.quadDominance()}}, + GamificationManager::Surface::Cli); return 0; } @@ -8829,6 +8897,11 @@ int CLIPipeline::cmdSkin(int argc, char* argv[]) cliWrite(SkinWeights::reportToText(report) + QString("Wrote: %1\n").arg(QFileInfo(outputPath).fileName())); } + GamificationManager::noteOperation( + QStringLiteral("skin_weights"), + {{QStringLiteral("verts_weighted"), report.totalVerticesProcessed}, + {QStringLiteral("max_influences"), opts.maxInfluencesPerVertex}}, + GamificationManager::Surface::Cli); return 0; } @@ -8972,6 +9045,11 @@ int CLIPipeline::cmdRig(int argc, char* argv[]) : QString()) + QString("Wrote: %1\n").arg(QFileInfo(outputPath).fileName())); } + GamificationManager::noteOperation( + QStringLiteral("auto_rig"), + {{QStringLiteral("bones_created"), report.boneCount}, + {QStringLiteral("meshes_skinned"), skinned ? 1 : 0}}, + GamificationManager::Surface::Cli); return 0; } diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index f54c467b7..9293d27b5 100755 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -174,6 +174,10 @@ CloudAccountMenuButton.cpp DependencyResolver.cpp ProjectPackager.cpp QtMeshCloudSession.cpp +GamificationTypes.cpp +GamificationEventQueue.cpp +GamificationManager.cpp +GamificationToast.cpp CloudUploadDialog.cpp CloudProjectsController.cpp CloudDeepLink.cpp @@ -322,6 +326,10 @@ CloudUploadPlanner.h DependencyResolver.h ProjectPackager.h QtMeshCloudSession.h +GamificationTypes.h +GamificationEventQueue.h +GamificationManager.h +GamificationToast.h CloudUploadDialog.h CloudProjectsController.h CloudDeepLink.h diff --git a/src/CloudAccountMenuButton.cpp b/src/CloudAccountMenuButton.cpp index c5b0cc763..397ee3b5f 100644 --- a/src/CloudAccountMenuButton.cpp +++ b/src/CloudAccountMenuButton.cpp @@ -2,6 +2,8 @@ #include "AppSettingsKeys.h" #include "CloudCredentialStore.h" +#include "GamificationManager.h" +#include "GamificationTypes.h" #include "SentryReporter.h" #include @@ -9,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -195,6 +198,10 @@ CloudAccountMenuButton::CloudAccountMenuButton(QWidget* parent) connect(m_menu, &QMenu::aboutToShow, this, [this]() { SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), QStringLiteral("Cloud toolbar menu opened")); + // Opportunistic stats refresh so the status block stays current + // without polling (renders last-cached immediately, updates async). + if (CloudCredentialStore::hasSession()) + GamificationManager::instance()->refreshStatsIfStale(); refresh(); }); @@ -254,6 +261,73 @@ void CloudAccountMenuButton::buildMenu() m_menu->addAction(m_headerAction); m_headerSeparator = m_menu->addSeparator(); + // ---- Gamification status block (E-P4 #800) ---- + m_gamifyWidget = new QWidget(m_menu); + m_gamifyWidget->setObjectName(QStringLiteral("cloudAccountGamifyStatus")); + auto* gamifyLayout = new QVBoxLayout(m_gamifyWidget); + gamifyLayout->setContentsMargins(14, 6, 14, 8); + gamifyLayout->setSpacing(3); + + m_gamifyLevelLabel = new QLabel(m_gamifyWidget); + m_gamifyLevelLabel->setObjectName(QStringLiteral("cloudAccountGamifyLevel")); + m_gamifyLevelLabel->setStyleSheet(QStringLiteral( + "color: #ececec; font-size: 12px; font-weight: 600; background: transparent;")); + + const QString barStyle = QStringLiteral( + "QProgressBar { background: #1f1f1f; border: none; border-radius: 2px;" + " min-height: 4px; max-height: 4px; }" + "QProgressBar::chunk { background: #4a7aa8; border-radius: 2px; }"); + m_gamifyXpBar = new QProgressBar(m_gamifyWidget); + m_gamifyXpBar->setObjectName(QStringLiteral("cloudAccountGamifyXpBar")); + m_gamifyXpBar->setTextVisible(false); + m_gamifyXpBar->setStyleSheet(barStyle); + + m_gamifyNextLabel = new QLabel(m_gamifyWidget); + m_gamifyNextLabel->setObjectName(QStringLiteral("cloudAccountGamifyNext")); + m_gamifyNextLabel->setStyleSheet(QStringLiteral( + "color: #9a9a9a; font-size: 11px; background: transparent;")); + m_gamifyNextLabel->setWordWrap(true); + + m_gamifyNextBar = new QProgressBar(m_gamifyWidget); + m_gamifyNextBar->setObjectName(QStringLiteral("cloudAccountGamifyNextBar")); + m_gamifyNextBar->setTextVisible(false); + m_gamifyNextBar->setStyleSheet(barStyle); + + gamifyLayout->addWidget(m_gamifyLevelLabel); + gamifyLayout->addWidget(m_gamifyXpBar); + gamifyLayout->addSpacing(4); + gamifyLayout->addWidget(m_gamifyNextLabel); + gamifyLayout->addWidget(m_gamifyNextBar); + + // Like the header above, the gamification entries are physically added / + // removed from the menu in updateGamificationSection() — QMenu (macOS in + // particular) keeps painting hidden QWidgetActions and mis-tracks item + // hover geometry when actions are merely setVisible(false). + m_gamifyAction = new QWidgetAction(m_menu); + m_gamifyAction->setDefaultWidget(m_gamifyWidget); + m_gamifyAction->setEnabled(false); + + m_achievementsAction = new QAction(tr("View My Achievements…"), m_menu); + m_achievementsAction->setObjectName(QStringLiteral("actionQtMeshCloudAchievements")); + connect(m_achievementsAction, &QAction::triggered, this, []() { + SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), + QStringLiteral("Cloud toolbar: View Achievements")); + GamificationManager::instance()->openProfile(); + }); + + m_enableSyncAction = new QAction(tr("Enable Progress Sync…"), m_menu); + m_enableSyncAction->setObjectName(QStringLiteral("actionQtMeshCloudEnableSync")); + connect(m_enableSyncAction, &QAction::triggered, this, [this]() { + SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), + QStringLiteral("Cloud toolbar: Enable Progress Sync")); + GamificationManager::instance()->acceptConsent(); + GamificationManager::instance()->refreshStats(); + refresh(); + }); + + m_gamifySeparator = new QAction(m_menu); + m_gamifySeparator->setSeparator(true); + m_openProjectsAction = m_menu->addAction(tr("My Cloud Projects…")); m_openProjectsAction->setObjectName(QStringLiteral("actionQtMeshCloudOpenProjects")); connect(m_openProjectsAction, &QAction::triggered, this, [this]() { @@ -323,6 +397,84 @@ void CloudAccountMenuButton::updateHeader(const QString& displayName, bool signe m_menu->update(); } +void CloudAccountMenuButton::updateGamificationSection(bool signedIn) +{ + if (!m_gamifyAction) + return; + + auto* gamify = GamificationManager::instance(); + const bool syncOn = gamify->syncEnabled(); + const bool showStats = signedIn && syncOn && gamify->statsAvailable(); + const bool showSyncing = signedIn && syncOn && !gamify->statsAvailable(); + const bool showEnable = signedIn && !syncOn; + const bool showAchievements = signedIn && syncOn && !gamify->profileUrl().isEmpty(); + + // Rebuild the section by physically removing / re-inserting the actions + // (before "My Cloud Projects…") — see the note in buildMenu(). + for (QAction* action : {static_cast(m_gamifyAction), m_achievementsAction, + m_enableSyncAction, m_gamifySeparator}) { + if (m_menu->actions().contains(action)) + m_menu->removeAction(action); + } + + if (showStats || showSyncing) + m_menu->insertAction(m_openProjectsAction, m_gamifyAction); + if (showEnable) + m_menu->insertAction(m_openProjectsAction, m_enableSyncAction); + if (showAchievements) + m_menu->insertAction(m_openProjectsAction, m_achievementsAction); + if (showStats || showSyncing || showEnable || showAchievements) + m_menu->insertAction(m_openProjectsAction, m_gamifySeparator); + + m_menu->updateGeometry(); + m_menu->adjustSize(); + m_menu->update(); + + if (showSyncing) { + // Enabled but the first stats fetch hasn't landed yet (it refreshes + // async on menu open) — say so instead of showing an empty block. + m_gamifyLevelLabel->setText(tr("Syncing progress…")); + m_gamifyXpBar->setVisible(false); + m_gamifyNextLabel->setVisible(false); + m_gamifyNextBar->setVisible(false); + return; + } + if (!showStats) + return; + + QString levelText = tr("Level %1 · %2 XP").arg(gamify->level()).arg(gamify->xp()); + if (gamify->currentStreak() > 0) + levelText += tr(" · 🔥 %n-day streak", nullptr, gamify->currentStreak()); + m_gamifyLevelLabel->setText(levelText); + + m_gamifyXpBar->setVisible(true); + m_gamifyXpBar->setRange(0, qMax(1, gamify->xpSpan())); + m_gamifyXpBar->setValue(qBound(0, gamify->xpIntoLevel(), qMax(1, gamify->xpSpan()))); + m_gamifyXpBar->setToolTip(tr("%1 / %2 XP to level %3") + .arg(gamify->xpIntoLevel()) + .arg(gamify->xpSpan()) + .arg(gamify->level() + 1)); + + const QVariantList next = gamify->nextUnlockables(); + const bool hasNext = !next.isEmpty(); + m_gamifyNextLabel->setVisible(hasNext); + m_gamifyNextBar->setVisible(hasNext); + if (hasNext) { + const QVariantMap u = next.first().toMap(); + m_gamifyNextLabel->setText(tr("Next: %1 — %2 (%3/%4)") + .arg(u.value(QStringLiteral("title")).toString(), + u.value(QStringLiteral("description")).toString()) + .arg(u.value(QStringLiteral("current")).toLongLong()) + .arg(u.value(QStringLiteral("threshold")).toLongLong())); + const int threshold = + qMax(1, static_cast(u.value(QStringLiteral("threshold")).toLongLong())); + m_gamifyNextBar->setRange(0, threshold); + m_gamifyNextBar->setValue( + qBound(0, static_cast(u.value(QStringLiteral("current")).toLongLong()), + threshold)); + } +} + void CloudAccountMenuButton::refresh() { CloudCredentialStore::migrateLegacySettingsIfNeeded(); @@ -341,6 +493,7 @@ void CloudAccountMenuButton::refresh() avatar->setSignedIn(signedIn, initials); updateHeader(display, signedIn); + updateGamificationSection(signedIn); m_openProjectsAction->setEnabled(signedIn); if (signedIn) diff --git a/src/CloudAccountMenuButton.h b/src/CloudAccountMenuButton.h index 87a90c8c4..f9336f37f 100644 --- a/src/CloudAccountMenuButton.h +++ b/src/CloudAccountMenuButton.h @@ -6,6 +6,7 @@ class QAction; class QLabel; class QMenu; +class QProgressBar; class QToolButton; class QWidgetAction; @@ -42,6 +43,7 @@ class CloudAccountMenuButton : public QWidget { void buildMenu(); void applyMenuStyle(); void updateHeader(const QString& displayName, bool signedIn); + void updateGamificationSection(bool signedIn); QToolButton* m_button = nullptr; QMenu* m_menu = nullptr; @@ -56,6 +58,17 @@ class CloudAccountMenuButton : public QWidget { QAction* m_uploadAction = nullptr; QAction* m_feedbackAction = nullptr; QAction* m_openProjectsAction = nullptr; + // Gamification status block (E-P4 #800): level + XP bar, streak, nearest + // unlockable achievement, "View my achievements" deep link. + QWidget* m_gamifyWidget = nullptr; + QLabel* m_gamifyLevelLabel = nullptr; + QProgressBar* m_gamifyXpBar = nullptr; + QLabel* m_gamifyNextLabel = nullptr; + QProgressBar* m_gamifyNextBar = nullptr; + QWidgetAction* m_gamifyAction = nullptr; + QAction* m_gamifySeparator = nullptr; + QAction* m_achievementsAction = nullptr; + QAction* m_enableSyncAction = nullptr; bool m_uploadAssetAvailable = false; }; diff --git a/src/CloudAccountMenuButton_test.cpp b/src/CloudAccountMenuButton_test.cpp index 53bd02aa1..d586d2aff 100644 --- a/src/CloudAccountMenuButton_test.cpp +++ b/src/CloudAccountMenuButton_test.cpp @@ -53,6 +53,12 @@ class CloudAccountMenuButtonTest : public ::testing::Test { QCoreApplication::setOrganizationName(QStringLiteral("QtMeshEditorTests")); QCoreApplication::setApplicationName(QStringLiteral("CloudAccountMenuButtonTest")); QSettings().clear(); + // Mark the one-time legacy migration done so refresh() never probes + // the OS keychain from tests — on a dev machine with a real legacy + // session it would import the developer's token into the test scope + // (breaking every signed-out expectation) and can raise keychain + // prompts. + QSettings().setValue(AppSettingsKeys::cloudLegacyMigrationDone(), true); CloudCredentialStore::clearSession(); } diff --git a/src/CloudProjectsController.cpp b/src/CloudProjectsController.cpp index 9c8985e31..a02dd78b0 100644 --- a/src/CloudProjectsController.cpp +++ b/src/CloudProjectsController.cpp @@ -1,6 +1,7 @@ #include "CloudProjectsController.h" #include "CloudCredentialStore.h" +#include "GamificationManager.h" #include "QtMeshCloudSession.h" #include "SentryReporter.h" @@ -242,6 +243,7 @@ void CloudProjectsController::closeProjectFiles() m_activeOwnerSlug.clear(); m_activeProjectSlug.clear(); m_projectFiles.clear(); + GamificationManager::setProjectContext(QString(), QString()); if (hadView) { emit loadingProjectFilesChanged(); emit projectFilesChanged(); @@ -493,6 +495,8 @@ void CloudProjectsController::beginProjectFilesView(const QString& projectId, m_activeOwnerSlug = ownerSlug; m_activeProjectSlug = projectSlug; m_activeProjectName = projectName.isEmpty() ? projectSlug : projectName; + // Subsequent operation events attach to this cloud project (#799). + GamificationManager::setProjectContext(ownerSlug, projectSlug); m_projectFiles.clear(); m_loadingProjectFiles = true; emit activeProjectChanged(); diff --git a/src/GamificationEventQueue.cpp b/src/GamificationEventQueue.cpp new file mode 100644 index 000000000..fede410ba --- /dev/null +++ b/src/GamificationEventQueue.cpp @@ -0,0 +1,255 @@ +#include "GamificationEventQueue.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +constexpr int kLockTimeoutMs = 1500; +constexpr int kMaxIdLength = 128; +} // namespace + +GamificationEventQueue::GamificationEventQueue(const QString& filePath, int capacity) + : m_filePath(filePath) + , m_capacity(qMax(1, capacity)) +{ + withFileLock([this]() { loadLocked(); }); +} + +QJsonObject GamificationEventQueue::entryToJson(const Entry& entry) +{ + QJsonObject o; + o.insert(QStringLiteral("id"), entry.id); + o.insert(QStringLiteral("kind"), entry.kind); + if (!entry.owner.isEmpty()) + o.insert(QStringLiteral("owner"), entry.owner); + o.insert(QStringLiteral("body"), entry.body); + o.insert(QStringLiteral("queuedAt"), static_cast(entry.queuedAt)); + return o; +} + +GamificationEventQueue::Entry GamificationEventQueue::entryFromJson(const QJsonObject& object) +{ + Entry e; + e.id = object.value(QStringLiteral("id")).toString(); + e.kind = object.value(QStringLiteral("kind")).toString(); + e.owner = object.value(QStringLiteral("owner")).toString(); + e.body = object.value(QStringLiteral("body")).toObject(); + e.queuedAt = static_cast(object.value(QStringLiteral("queuedAt")).toDouble()); + return e; +} + +bool GamificationEventQueue::withFileLock(const std::function& fn) const +{ + // The lock file cannot be created in a missing directory (first run). + QDir().mkpath(QFileInfo(m_filePath).absolutePath()); + QLockFile lock(m_filePath + QStringLiteral(".lock")); + lock.setStaleLockTime(30 * 1000); + if (!lock.tryLock(kLockTimeoutMs)) { + qWarning() << "GamificationEventQueue: could not lock" << m_filePath; + return false; + } + fn(); + return true; +} + +void GamificationEventQueue::loadLocked() +{ + m_entries.clear(); + QFile file(m_filePath); + if (!file.exists() || !file.open(QIODevice::ReadOnly)) + return; + const QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); + if (!doc.isObject()) + return; + const QJsonArray events = doc.object().value(QStringLiteral("events")).toArray(); + QSet seen; + for (const QJsonValue& v : events) { + Entry e = entryFromJson(v.toObject()); + if (e.id.isEmpty() || e.kind.isEmpty() || seen.contains(e.id) + || m_removedIds.contains(e.id)) + continue; + seen.insert(e.id); + m_entries.append(e); + } +} + +bool GamificationEventQueue::saveLocked() const +{ + const QFileInfo info(m_filePath); + QDir().mkpath(info.absolutePath()); + + QJsonArray events; + for (const Entry& e : m_entries) + events.append(entryToJson(e)); + QJsonObject root; + root.insert(QStringLiteral("version"), 1); + root.insert(QStringLiteral("events"), events); + + QSaveFile file(m_filePath); + if (!file.open(QIODevice::WriteOnly)) { + qWarning() << "GamificationEventQueue: could not write" << m_filePath; + return false; + } + file.write(QJsonDocument(root).toJson(QJsonDocument::Compact)); + return file.commit(); +} + +void GamificationEventQueue::mergeFromDisk() +{ + // Union of in-memory entries and whatever another process persisted, + // keeping disk (older) entries first so eviction stays FIFO. Ids this + // instance removed are filtered on both sides (tombstones), so an + // acknowledge/clear can never be undone by a stale snapshot. + const QList mine = m_entries; + loadLocked(); // already skips m_removedIds + QSet seen; + for (const Entry& e : std::as_const(m_entries)) + seen.insert(e.id); + for (const Entry& e : mine) { + if (!seen.contains(e.id) && !m_removedIds.contains(e.id)) { + seen.insert(e.id); + m_entries.append(e); + } + } +} + +void GamificationEventQueue::enforceCapacity() +{ + while (m_entries.size() > m_capacity) { + const Entry dropped = m_entries.takeFirst(); + ++m_evicted; + qWarning() << "GamificationEventQueue: capacity" << m_capacity + << "exceeded — evicting oldest event" << dropped.id; + } +} + +void GamificationEventQueue::tombstone(const QSet& ids) +{ + m_removedIds.unite(ids); + // The tombstones only need to outlive concurrent stale snapshots, not the + // process — cap the set so a long editor session can't grow it unbounded. + if (m_removedIds.size() > 4 * m_capacity) + m_removedIds.clear(); +} + +bool GamificationEventQueue::append(const Entry& entry) +{ + if (entry.id.isEmpty() || entry.id.size() > kMaxIdLength || entry.kind.isEmpty()) + return false; + for (const Entry& e : std::as_const(m_entries)) { + if (e.id == entry.id) + return true; // already queued + } + + bool saved = false; + const bool locked = withFileLock([this, &entry, &saved]() { + mergeFromDisk(); + bool present = false; + for (const Entry& e : std::as_const(m_entries)) { + if (e.id == entry.id) { + present = true; + break; + } + } + if (!present) + m_entries.append(entry); + enforceCapacity(); + saved = saveLocked(); + }); + if (!locked) + m_entries.append(entry); // keep in memory; retried on next append + return locked && saved; +} + +QList GamificationEventQueue::peek(const QString& kind, + int maxCount) const +{ + QList out; + for (const Entry& e : m_entries) { + if (!kind.isEmpty() && e.kind != kind) + continue; + out.append(e); + if (maxCount >= 0 && out.size() >= maxCount) + break; + } + return out; +} + +void GamificationEventQueue::acknowledge(const QStringList& ids) +{ + if (ids.isEmpty()) + return; + const QSet acked(ids.cbegin(), ids.cend()); + tombstone(acked); + withFileLock([this, &acked]() { + mergeFromDisk(); + for (int i = m_entries.size() - 1; i >= 0; --i) { + if (acked.contains(m_entries.at(i).id)) + m_entries.removeAt(i); + } + saveLocked(); + }); + // Even when the lock failed, drop them from memory — the tombstones keep + // the next successful merge/save from resurrecting them. + for (int i = m_entries.size() - 1; i >= 0; --i) { + if (acked.contains(m_entries.at(i).id)) + m_entries.removeAt(i); + } +} + +bool GamificationEventQueue::clear() +{ + QSet ids; + bool saved = false; + const bool locked = withFileLock([this, &ids, &saved]() { + mergeFromDisk(); + for (const Entry& e : std::as_const(m_entries)) + ids.insert(e.id); + m_entries.clear(); + saved = saveLocked(); + if (!saved) + loadLocked(); // save failed — re-hydrate so memory matches disk + }); + if (!locked || !saved) { + // Persisted wipe failed: state was restored so the caller can retry, + // and we report the failure instead of pretending the data is gone. + return false; + } + tombstone(ids); + return true; +} + +bool GamificationEventQueue::removeKind(const QString& kind) +{ + if (kind.isEmpty()) + return true; + QSet ids; + bool saved = false; + const bool locked = withFileLock([this, &kind, &ids, &saved]() { + mergeFromDisk(); + for (int i = m_entries.size() - 1; i >= 0; --i) { + if (m_entries.at(i).kind == kind) { + ids.insert(m_entries.at(i).id); + m_entries.removeAt(i); + } + } + saved = saveLocked(); + if (!saved) + loadLocked(); // save failed — re-hydrate so memory matches disk + }); + if (!locked || !saved) + return false; + tombstone(ids); + return true; +} + +void GamificationEventQueue::reload() +{ + withFileLock([this]() { loadLocked(); }); +} diff --git a/src/GamificationEventQueue.h b/src/GamificationEventQueue.h new file mode 100644 index 000000000..f0645a28b --- /dev/null +++ b/src/GamificationEventQueue.h @@ -0,0 +1,95 @@ +#ifndef GAMIFICATION_EVENT_QUEUE_H +#define GAMIFICATION_EVENT_QUEUE_H + +#include +#include +#include +#include +#include + +#include + +/// Persistent, offline-tolerant queue of pending gamification events (#797). +/// +/// Append-only JSON file in the app data dir; every entry carries a +/// client-generated idempotency id (also the server-side dedup key), so a +/// flush retried after a crash or a lost response can never double-award. +/// Bounded: oldest entries are FIFO-evicted past `capacity()` with a logged +/// warning (never silently). Cross-process safe (GUI + CLI can enqueue +/// concurrently) via a QLockFile around every read-modify-write; ids removed +/// by this instance are tombstoned so a stale in-memory snapshot can never +/// write them back. +/// +/// Pure data — no network, no Ogre. GamificationManager owns the flush policy. +class GamificationEventQueue { +public: + struct Entry { + QString id; ///< idempotency id (server dedup key), <=128 chars + QString kind; ///< "feature" | "operation" + /// Cloud user slug active when the event was recorded; empty when it + /// was queued logged-out ("unclaimed" — flushable by any account). + /// Prevents user A's events from posting to user B's account after + /// an account switch. + QString owner; + QJsonObject body; ///< exact event object for the cloud endpoint + qint64 queuedAt = 0; ///< epoch ms (local bookkeeping only) + }; + + /// @p filePath JSON file backing the queue (created on first append). + explicit GamificationEventQueue(const QString& filePath, int capacity = 500); + + QString filePath() const { return m_filePath; } + int capacity() const { return m_capacity; } + + /// Appends one entry (reloading the file first so concurrent writers + /// merge instead of clobbering). Returns false when the entry is invalid + /// or the file could not be locked/written; the entry is kept in memory + /// and retried on the next append/save. + bool append(const Entry& entry); + + /// Up to @p maxCount oldest entries of @p kind ("" = any kind). + QList peek(const QString& kind, int maxCount) const; + + /// Removes acknowledged ids and persists. Safe to call with ids that are + /// no longer present. + void acknowledge(const QStringList& ids); + + /// Drops every pending entry (privacy "delete my data" / stream opt-out + /// paths). Returns false when the persisted wipe failed (lock or write) — + /// in-memory entries are kept in that case so the caller can retry. + bool clear(); + + /// Drops every pending entry of @p kind (stream opt-out). Returns false + /// when the persisted removal failed. + bool removeKind(const QString& kind); + + int size() const { return m_entries.size(); } + bool isEmpty() const { return m_entries.isEmpty(); } + + /// Number of entries FIFO-evicted over this instance's lifetime. + int evictedCount() const { return m_evicted; } + + /// Re-reads the backing file (e.g. after another process appended). + void reload(); + + static QJsonObject entryToJson(const Entry& entry); + static Entry entryFromJson(const QJsonObject& object); + +private: + bool withFileLock(const std::function& fn) const; + void loadLocked(); + bool saveLocked() const; + void mergeFromDisk(); + void enforceCapacity(); + void tombstone(const QSet& ids); + + QString m_filePath; + int m_capacity = 500; + QList m_entries; + /// Ids this instance removed (ack/clear/removeKind), excluded from every + /// disk merge so removals survive concurrent writers' stale snapshots. + QSet m_removedIds; + int m_evicted = 0; +}; + +#endif // GAMIFICATION_EVENT_QUEUE_H diff --git a/src/GamificationEventQueue_test.cpp b/src/GamificationEventQueue_test.cpp new file mode 100644 index 000000000..be8db5384 --- /dev/null +++ b/src/GamificationEventQueue_test.cpp @@ -0,0 +1,189 @@ +#include + +#include +#include +#include + +#include "GamificationEventQueue.h" + +namespace { + +GamificationEventQueue::Entry makeEntry(const QString& id, + const QString& kind = QStringLiteral("feature")) +{ + GamificationEventQueue::Entry e; + e.id = id; + e.kind = kind; + QJsonObject body; + body.insert(QStringLiteral("id"), id); + body.insert(QStringLiteral("feature"), QStringLiteral("retopo")); + e.body = body; + e.queuedAt = 1783000000000LL; + return e; +} + +} // namespace + +class GamificationEventQueueTest : public ::testing::Test { +protected: + void SetUp() override + { + ASSERT_TRUE(m_dir.isValid()); + m_path = m_dir.filePath(QStringLiteral("queue.json")); + } + + QTemporaryDir m_dir; + QString m_path; +}; + +TEST_F(GamificationEventQueueTest, AppendPersistsAcrossInstances) +{ + { + GamificationEventQueue queue(m_path); + EXPECT_TRUE(queue.isEmpty()); + EXPECT_TRUE(queue.append(makeEntry(QStringLiteral("a")))); + EXPECT_TRUE(queue.append(makeEntry(QStringLiteral("b"), QStringLiteral("operation")))); + EXPECT_EQ(queue.size(), 2); + } + GamificationEventQueue reloaded(m_path); + EXPECT_EQ(reloaded.size(), 2); + const auto features = reloaded.peek(QStringLiteral("feature"), 10); + ASSERT_EQ(features.size(), 1); + EXPECT_EQ(features.first().id, QStringLiteral("a")); + EXPECT_EQ(features.first().body.value(QStringLiteral("feature")).toString(), + QStringLiteral("retopo")); + EXPECT_EQ(reloaded.peek(QStringLiteral("operation"), 10).size(), 1); + EXPECT_EQ(reloaded.peek(QString(), 10).size(), 2); +} + +TEST_F(GamificationEventQueueTest, DuplicateIdsAreIgnored) +{ + GamificationEventQueue queue(m_path); + EXPECT_TRUE(queue.append(makeEntry(QStringLiteral("a")))); + EXPECT_TRUE(queue.append(makeEntry(QStringLiteral("a")))); + EXPECT_EQ(queue.size(), 1); +} + +TEST_F(GamificationEventQueueTest, InvalidEntriesRejected) +{ + GamificationEventQueue queue(m_path); + EXPECT_FALSE(queue.append(makeEntry(QString()))); + EXPECT_FALSE(queue.append(makeEntry(QString(129, QLatin1Char('x'))))); + GamificationEventQueue::Entry noKind = makeEntry(QStringLiteral("k")); + noKind.kind.clear(); + EXPECT_FALSE(queue.append(noKind)); + EXPECT_TRUE(queue.isEmpty()); +} + +TEST_F(GamificationEventQueueTest, AcknowledgeRemovesAndPersists) +{ + GamificationEventQueue queue(m_path); + queue.append(makeEntry(QStringLiteral("a"))); + queue.append(makeEntry(QStringLiteral("b"))); + queue.append(makeEntry(QStringLiteral("c"))); + queue.acknowledge({QStringLiteral("a"), QStringLiteral("c"), + QStringLiteral("never-existed")}); + EXPECT_EQ(queue.size(), 1); + GamificationEventQueue reloaded(m_path); + ASSERT_EQ(reloaded.size(), 1); + EXPECT_EQ(reloaded.peek(QString(), 10).first().id, QStringLiteral("b")); +} + +TEST_F(GamificationEventQueueTest, CapacityEvictsOldestFifo) +{ + GamificationEventQueue queue(m_path, /*capacity=*/3); + for (int i = 0; i < 5; ++i) + queue.append(makeEntry(QStringLiteral("id%1").arg(i))); + EXPECT_EQ(queue.size(), 3); + EXPECT_EQ(queue.evictedCount(), 2); + const auto entries = queue.peek(QString(), 10); + EXPECT_EQ(entries.first().id, QStringLiteral("id2")); + EXPECT_EQ(entries.last().id, QStringLiteral("id4")); +} + +TEST_F(GamificationEventQueueTest, ClearDropsEverything) +{ + GamificationEventQueue queue(m_path); + queue.append(makeEntry(QStringLiteral("a"))); + EXPECT_TRUE(queue.clear()); + EXPECT_TRUE(queue.isEmpty()); + GamificationEventQueue reloaded(m_path); + EXPECT_TRUE(reloaded.isEmpty()); +} + +TEST_F(GamificationEventQueueTest, RemoveKindDropsOnlyThatKind) +{ + GamificationEventQueue queue(m_path); + queue.append(makeEntry(QStringLiteral("f1"))); + queue.append(makeEntry(QStringLiteral("o1"), QStringLiteral("operation"))); + queue.append(makeEntry(QStringLiteral("f2"))); + EXPECT_TRUE(queue.removeKind(QStringLiteral("feature"))); + EXPECT_EQ(queue.size(), 1); + EXPECT_EQ(queue.peek(QString(), 10).first().kind, QStringLiteral("operation")); + GamificationEventQueue reloaded(m_path); + EXPECT_EQ(reloaded.size(), 1); +} + +TEST_F(GamificationEventQueueTest, StaleSnapshotCannotResurrectRemovedIds) +{ + // Process A holds entries in memory; process B acknowledges them on + // disk. A's next append must not write the removed ids back. + GamificationEventQueue a(m_path); + a.append(makeEntry(QStringLiteral("stale"))); + + GamificationEventQueue b(m_path); + b.acknowledge({QStringLiteral("stale")}); + EXPECT_TRUE(b.isEmpty()); + + b.append(makeEntry(QStringLiteral("fresh"))); // b writes; 'stale' stays gone + GamificationEventQueue reloaded(m_path); + ASSERT_EQ(reloaded.size(), 1); + EXPECT_EQ(reloaded.peek(QString(), 10).first().id, QStringLiteral("fresh")); + + // Same guarantee within one instance after clear(). + GamificationEventQueue c(m_path); + EXPECT_TRUE(c.clear()); + c.append(makeEntry(QStringLiteral("post-clear"))); + EXPECT_EQ(c.size(), 1); + EXPECT_EQ(c.peek(QString(), 10).first().id, QStringLiteral("post-clear")); +} + +TEST_F(GamificationEventQueueTest, OwnerRoundTripsThroughPersistence) +{ + { + GamificationEventQueue queue(m_path); + auto owned = makeEntry(QStringLiteral("owned")); + owned.owner = QStringLiteral("ada"); + queue.append(owned); + queue.append(makeEntry(QStringLiteral("unclaimed"))); + } + GamificationEventQueue reloaded(m_path); + const auto entries = reloaded.peek(QString(), 10); + ASSERT_EQ(entries.size(), 2); + EXPECT_EQ(entries.first().owner, QStringLiteral("ada")); + EXPECT_TRUE(entries.last().owner.isEmpty()); +} + +TEST_F(GamificationEventQueueTest, ConcurrentWritersMergeById) +{ + // Two queue instances over the same file (GUI + CLI process model). + GamificationEventQueue a(m_path); + GamificationEventQueue b(m_path); + a.append(makeEntry(QStringLiteral("from-a"))); + b.append(makeEntry(QStringLiteral("from-b"))); + // b merged a's persisted entry during its own read-modify-write. + EXPECT_EQ(b.size(), 2); + GamificationEventQueue reloaded(m_path); + EXPECT_EQ(reloaded.size(), 2); +} + +TEST_F(GamificationEventQueueTest, EntryJsonRoundTrip) +{ + const auto e = makeEntry(QStringLiteral("round"), QStringLiteral("operation")); + const auto back = GamificationEventQueue::entryFromJson( + GamificationEventQueue::entryToJson(e)); + EXPECT_EQ(back.id, e.id); + EXPECT_EQ(back.kind, e.kind); + EXPECT_EQ(back.body, e.body); + EXPECT_EQ(back.queuedAt, e.queuedAt); +} diff --git a/src/GamificationManager.cpp b/src/GamificationManager.cpp new file mode 100644 index 000000000..6c533d115 --- /dev/null +++ b/src/GamificationManager.cpp @@ -0,0 +1,929 @@ +#include "GamificationManager.h" + +#include "AppSettingsKeys.h" +#include "CloudCredentialStore.h" +#include "QtMeshCloudClient.h" +#include "SentryReporter.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { + +constexpr int kFlushDebounceMs = 5000; +constexpr int kFlushIntervalMs = 90 * 1000; +constexpr qint64 kMaxBackoffMs = 30 * 60 * 1000; + +QString gamificationDir() +{ + // Same root as sd_models/ai_models/hdri (see SDManager, AIAssistManager). + return QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + + QStringLiteral("/gamification"); +} + +/// Operations whose op key doubles as (or maps onto) a discovery feature +/// cluster, so one noteOperation() call also counts as feature usage. +QString featureAliasForOp(const QString& opType) +{ + if (opType == QStringLiteral("optimize") || opType == QStringLiteral("decimate_lod")) + return QStringLiteral("decimate_lod"); + if (opType == QStringLiteral("fix")) + return {}; // fix is an op type only (feeds `issues_fixed`) + if (Gamification::featureInfo(opType)) + return opType; + return {}; +} + +/// Guardrail: metrics are content-free numeric aggregates only. Anything +/// non-numeric is dropped here so no caller can accidentally leak strings +/// (file names, prompts, ...) into the operations history. +QJsonObject numericMetricsOnly(const QVariantMap& metrics) +{ + QJsonObject out; + for (auto it = metrics.constBegin(); it != metrics.constEnd(); ++it) { + if (!Gamification::isValidEventKey(it.key())) + continue; + bool okInt = false; + const qint64 asInt = it.value().toLongLong(&okInt); + if (okInt && it.value().typeId() != QMetaType::Double) { + out.insert(it.key(), static_cast(asInt)); + continue; + } + bool okDouble = false; + const double asDouble = it.value().toDouble(&okDouble); + if (okDouble) + out.insert(it.key(), asDouble); + } + return out; +} + +std::atomic g_emissionSuspended{false}; + +} // namespace + +GamificationManager* GamificationManager::s_singleton = nullptr; + +void GamificationManager::setEmissionSuspended(bool suspended) +{ + g_emissionSuspended.store(suspended); +} + +bool GamificationManager::emissionSuspended() +{ + return g_emissionSuspended.load(); +} + +GamificationManager* GamificationManager::instance() +{ + if (!s_singleton) + s_singleton = new GamificationManager(); + return s_singleton; +} + +GamificationManager* GamificationManager::qmlInstance(QQmlEngine* engine, QJSEngine* scriptEngine) +{ + Q_UNUSED(engine) + Q_UNUSED(scriptEngine) + GamificationManager* inst = instance(); + QQmlEngine::setObjectOwnership(inst, QQmlEngine::CppOwnership); + return inst; +} + +void GamificationManager::kill() +{ + delete s_singleton; + s_singleton = nullptr; +} + +GamificationManager::GamificationManager(QObject* parent) + : QObject(parent) + , m_queue(gamificationDir() + QStringLiteral("/queue.json")) +{ + loadCachedStats(); + + // Rotate the welcome suggestion once per app run. + { + QSettings settings; + const int cursor = settings.value(AppSettingsKeys::gamificationSuggestionCursor(), 0).toInt(); + settings.setValue(AppSettingsKeys::gamificationSuggestionCursor(), cursor + 1); + } + + m_flushTimer = new QTimer(this); + m_flushTimer->setSingleShot(true); + connect(m_flushTimer, &QTimer::timeout, this, &GamificationManager::flushNow); + if (!m_queue.isEmpty()) + m_flushTimer->start(kFlushDebounceMs); + + // Periodic retry/flush heartbeat (only does work when something is queued). + auto* heartbeat = new QTimer(this); + heartbeat->setInterval(kFlushIntervalMs); + connect(heartbeat, &QTimer::timeout, this, &GamificationManager::flushNow); + heartbeat->start(); + + if (QCoreApplication::instance()) { + connect(QCoreApplication::instance(), &QCoreApplication::aboutToQuit, this, [this]() { + // Best-effort graceful-shutdown flush; the queue persists anything + // that doesn't make it out in time. + if (!m_flushInFlight) + flushBlocking(3000); + }); + } + + // First stats refresh shortly after startup, off the critical path. + QTimer::singleShot(8000, this, [this]() { + if (signedIn() && syncEnabled()) + refreshStats(); + }); +} + +GamificationManager::~GamificationManager() = default; + +QString GamificationManager::surfaceName(Surface surface) +{ + switch (surface) { + case Surface::Cli: return QStringLiteral("cli"); + case Surface::Mcp: return QStringLiteral("mcp"); + case Surface::Gui: break; + } + return QStringLiteral("gui"); +} + +QString GamificationManager::newEventId() +{ + return QUuid::createUuid().toString(QUuid::WithoutBraces); +} + +// ---- Static entry points ------------------------------------------------- + +void GamificationManager::noteFeature(const QString& featureKey, Surface surface) +{ + auto* app = QCoreApplication::instance(); + if (!app || emissionSuspended()) + return; + if (QThread::currentThread() != app->thread()) { + QMetaObject::invokeMethod(app, [featureKey, surface]() { + noteFeature(featureKey, surface); + }, Qt::QueuedConnection); + return; + } + instance()->noteFeatureInternal(featureKey, surface); +} + +void GamificationManager::noteOperation(const QString& opType, const QVariantMap& metrics, + Surface surface) +{ + auto* app = QCoreApplication::instance(); + if (!app || emissionSuspended()) + return; + if (QThread::currentThread() != app->thread()) { + QMetaObject::invokeMethod(app, [opType, metrics, surface]() { + noteOperation(opType, metrics, surface); + }, Qt::QueuedConnection); + return; + } + instance()->noteOperationInternal(opType, metrics, surface); +} + +void GamificationManager::setProjectContext(const QString& ownerSlug, const QString& projectSlug) +{ + auto* app = QCoreApplication::instance(); + if (!app) + return; + GamificationManager* inst = instance(); + inst->m_activeOwnerSlug = ownerSlug.trimmed(); + inst->m_activeProjectSlug = projectSlug.trimmed(); +} + +// ---- Consent / gating ---------------------------------------------------- + +bool GamificationManager::signedIn() const +{ + return CloudCredentialStore::hasSession(); +} + +bool GamificationManager::consentAcknowledged() const +{ + return QSettings().value(AppSettingsKeys::gamificationConsentAcknowledged(), false).toBool(); +} + +bool GamificationManager::syncEnabled() const +{ + return QSettings().value(AppSettingsKeys::gamificationSyncEnabled(), false).toBool(); +} + +bool GamificationManager::usageEnabled() const +{ + return QSettings().value(AppSettingsKeys::gamificationUsageEnabled(), true).toBool(); +} + +bool GamificationManager::opsEnabled() const +{ + return QSettings().value(AppSettingsKeys::gamificationOpsEnabled(), true).toBool(); +} + +bool GamificationManager::nudgesEnabled() const +{ + return QSettings().value(AppSettingsKeys::gamificationNudgesEnabled(), true).toBool(); +} + +bool GamificationManager::usageEmissionActive() const +{ + return consentAcknowledged() && syncEnabled() && usageEnabled(); +} + +bool GamificationManager::opsEmissionActive() const +{ + return consentAcknowledged() && syncEnabled() && opsEnabled(); +} + +void GamificationManager::setSyncEnabled(bool enabled) +{ + QSettings settings; + settings.setValue(AppSettingsKeys::gamificationSyncEnabled(), enabled); + // Turning the master toggle on from Preferences is explicit consent. + if (enabled) + settings.setValue(AppSettingsKeys::gamificationConsentAcknowledged(), true); + emit prefsChanged(); + SentryReporter::addBreadcrumb(QStringLiteral("gamify.prefs"), + QStringLiteral("Progress sync %1") + .arg(enabled ? QStringLiteral("enabled") + : QStringLiteral("disabled"))); + if (enabled) { + scheduleFlushSoon(); + refreshStats(); + } +} + +void GamificationManager::setUsageEnabled(bool enabled) +{ + QSettings().setValue(AppSettingsKeys::gamificationUsageEnabled(), enabled); + // Disabling a stream also drops its already-queued events — otherwise a + // later flush would still send data the user just opted out of. + if (!enabled) + m_queue.removeKind(QStringLiteral("feature")); + emit prefsChanged(); +} + +void GamificationManager::setOpsEnabled(bool enabled) +{ + QSettings().setValue(AppSettingsKeys::gamificationOpsEnabled(), enabled); + if (!enabled) + m_queue.removeKind(QStringLiteral("operation")); + emit prefsChanged(); +} + +void GamificationManager::setNudgesEnabled(bool enabled) +{ + QSettings().setValue(AppSettingsKeys::gamificationNudgesEnabled(), enabled); + emit prefsChanged(); + emit suggestionChanged(); +} + +void GamificationManager::acceptConsent() +{ + QSettings settings; + settings.setValue(AppSettingsKeys::gamificationConsentAcknowledged(), true); + settings.setValue(AppSettingsKeys::gamificationSyncEnabled(), true); + emit prefsChanged(); + SentryReporter::addBreadcrumb(QStringLiteral("gamify.prefs"), + QStringLiteral("Progress-sync consent accepted")); + refreshStats(); + scheduleFlushSoon(); +} + +void GamificationManager::declineConsent() +{ + QSettings settings; + settings.setValue(AppSettingsKeys::gamificationConsentAcknowledged(), true); + settings.setValue(AppSettingsKeys::gamificationSyncEnabled(), false); + emit prefsChanged(); + SentryReporter::addBreadcrumb(QStringLiteral("gamify.prefs"), + QStringLiteral("Progress-sync consent declined")); +} + +bool GamificationManager::maybeRequestConsent() +{ + // One-time, non-blocking, and only meaningful when a cloud session exists. + QSettings settings; + if (settings.value(AppSettingsKeys::gamificationConsentPrompted(), false).toBool()) + return false; + if (!signedIn()) + return false; + // Only consume the one-time prompt when someone can actually show it — + // a headless CLI/MCP process has no listener and must not burn the + // user's only chance to see the GUI dialog. + static const QMetaMethod promptSignal = + QMetaMethod::fromSignal(&GamificationManager::consentPromptRequested); + if (!isSignalConnected(promptSignal)) + return false; + settings.setValue(AppSettingsKeys::gamificationConsentPrompted(), true); + emit consentPromptRequested(); + return true; +} + +QString GamificationManager::examplePayload() const +{ + QJsonObject featureEvent; + featureEvent.insert(QStringLiteral("id"), QStringLiteral("2f0b7c1e-…")); + featureEvent.insert(QStringLiteral("feature"), QStringLiteral("retopo")); + featureEvent.insert(QStringLiteral("at"), 1783000000000.0); + featureEvent.insert(QStringLiteral("surface"), QStringLiteral("gui")); + + QJsonObject metrics; + metrics.insert(QStringLiteral("tris_before"), 42180); + metrics.insert(QStringLiteral("tris_after"), 8004); + QJsonObject opEvent; + opEvent.insert(QStringLiteral("id"), QStringLiteral("9a41d3fa-…")); + opEvent.insert(QStringLiteral("op"), QStringLiteral("retopo")); + opEvent.insert(QStringLiteral("at"), 1783000000000.0); + opEvent.insert(QStringLiteral("surface"), QStringLiteral("gui")); + opEvent.insert(QStringLiteral("metrics"), metrics); + + QJsonObject root; + root.insert(QStringLiteral("feature_usage_event"), featureEvent); + root.insert(QStringLiteral("operation_event"), opEvent); + return QString::fromUtf8(QJsonDocument(root).toJson(QJsonDocument::Indented)); +} + +// ---- Instrumentation ----------------------------------------------------- + +void GamificationManager::noteFeatureInternal(const QString& featureKey, Surface surface) +{ + if (!Gamification::isValidEventKey(featureKey)) + return; + if (!consentAcknowledged()) { + maybeRequestConsent(); + return; + } + if (!syncEnabled() || !usageEnabled()) + return; + if (m_sessionNotedFeatures.contains(featureKey)) + return; // once per feature per session + m_sessionNotedFeatures.insert(featureKey); + + QJsonObject body; + body.insert(QStringLiteral("id"), newEventId()); + body.insert(QStringLiteral("feature"), featureKey); + body.insert(QStringLiteral("at"), + static_cast(QDateTime::currentMSecsSinceEpoch())); + body.insert(QStringLiteral("surface"), surfaceName(surface)); + + GamificationEventQueue::Entry entry; + entry.id = body.value(QStringLiteral("id")).toString(); + entry.kind = QStringLiteral("feature"); + entry.owner = currentEventOwner(); + entry.body = body; + entry.queuedAt = QDateTime::currentMSecsSinceEpoch(); + m_queue.append(entry); + + SentryReporter::addBreadcrumb(QStringLiteral("gamify.feature"), + QStringLiteral("feature.used %1 (%2)") + .arg(featureKey, surfaceName(surface))); + emit suggestionChanged(); // the used feature may have been the nudge + scheduleFlushSoon(); +} + +void GamificationManager::noteOperationInternal(const QString& opType, + const QVariantMap& metrics, Surface surface) +{ + if (!Gamification::isValidEventKey(opType)) + return; + + // An operation is also feature usage of its cluster (single call site). + const QString featureKey = featureAliasForOp(opType); + if (!featureKey.isEmpty()) + noteFeatureInternal(featureKey, surface); + + if (!consentAcknowledged()) { + maybeRequestConsent(); + return; + } + if (!syncEnabled() || !opsEnabled()) + return; + + QJsonObject body; + body.insert(QStringLiteral("id"), newEventId()); + body.insert(QStringLiteral("op"), opType); + body.insert(QStringLiteral("at"), + static_cast(QDateTime::currentMSecsSinceEpoch())); + body.insert(QStringLiteral("surface"), surfaceName(surface)); + body.insert(QStringLiteral("metrics"), numericMetricsOnly(metrics)); + if (!m_activeOwnerSlug.isEmpty() && !m_activeProjectSlug.isEmpty()) { + body.insert(QStringLiteral("ownerSlug"), m_activeOwnerSlug); + body.insert(QStringLiteral("projectSlug"), m_activeProjectSlug); + } + + GamificationEventQueue::Entry entry; + entry.id = body.value(QStringLiteral("id")).toString(); + entry.kind = QStringLiteral("operation"); + entry.owner = currentEventOwner(); + entry.body = body; + entry.queuedAt = QDateTime::currentMSecsSinceEpoch(); + m_queue.append(entry); + + SentryReporter::addBreadcrumb(QStringLiteral("gamify.operation"), + QStringLiteral("operation.completed %1 (%2)") + .arg(opType, surfaceName(surface))); + scheduleFlushSoon(); +} + +// ---- Flush ---------------------------------------------------------------- + +void GamificationManager::scheduleFlushSoon() +{ + if (m_flushTimer && !m_flushTimer->isActive()) + m_flushTimer->start(kFlushDebounceMs); +} + +QString GamificationManager::currentEventOwner() const +{ + if (!signedIn()) + return {}; + return QSettings().value(AppSettingsKeys::cloudUserSlug()).toString().trimmed(); +} + +QList GamificationManager::flushableBatch( + const QString& kind, QStringList* dropIds) const +{ + // Only entries recorded for the CURRENT account (or logged-out + // "unclaimed" ones) may flush; entries stamped for a different account + // must never post to this one — they get dropped instead. + const QString owner = currentEventOwner(); + QList out; + const QList all = + m_queue.peek(kind, -1); + for (const GamificationEventQueue::Entry& e : all) { + if (e.owner.isEmpty() || e.owner == owner) { + if (out.size() < QtMeshCloudClient::kGamificationMaxBatch) + out.append(e); + } else if (dropIds) { + dropIds->append(e.id); + } + } + return out; +} + +GamificationManager::FlushOutcome GamificationManager::performFlush( + const QString& token, + const QList& featureBatch, + const QList& operationBatch, + int timeoutMs) +{ + FlushOutcome outcome; + outcome.attempted = true; + outcome.ok = true; + + const auto flushBatch = [&](const QList& batch, + auto poster) { + if (batch.isEmpty()) + return; + QJsonArray items; + for (const GamificationEventQueue::Entry& e : batch) + items.append(e.body); + const QtMeshCloudClient::GamificationEventsResult result = + poster(token, items, timeoutMs); + if (result.ok) { + outcome.accepted += result.accepted; + for (const GamificationEventQueue::Entry& e : batch) + outcome.ackedIds.append(e.id); + for (const QJsonValue& v : result.newAchievements) + outcome.newAchievements.append( + Gamification::Achievement::fromJson(v.toObject()).toVariantMap()); + } else { + outcome.ok = false; + outcome.error = result.errorString; + } + }; + + flushBatch(featureBatch, [](const QString& t, const QJsonArray& items, int timeout) { + return QtMeshCloudClient::postEditorEvents(t, items, timeout); + }); + flushBatch(operationBatch, [](const QString& t, const QJsonArray& items, int timeout) { + return QtMeshCloudClient::postOperationEvents(t, items, timeout); + }); + return outcome; +} + +void GamificationManager::applyFlushOutcome(const FlushOutcome& outcome) +{ + if (!outcome.attempted) + return; + if (!outcome.ackedIds.isEmpty()) + m_queue.acknowledge(outcome.ackedIds); + + if (outcome.ok) { + m_consecutiveFlushFailures = 0; + m_nextFlushAllowedAt = 0; + } else { + ++m_consecutiveFlushFailures; + const qint64 backoff = qMin( + kMaxBackoffMs, 60000LL * (1LL << qMin(m_consecutiveFlushFailures - 1, 5))); + m_nextFlushAllowedAt = QDateTime::currentMSecsSinceEpoch() + backoff; + SentryReporter::addBreadcrumb(QStringLiteral("gamify.flush"), + QStringLiteral("flush failed (%1), backoff %2 ms") + .arg(outcome.error) + .arg(backoff), + QStringLiteral("warning")); + } + + if (!outcome.newAchievements.isEmpty()) + emit achievementsUnlocked(outcome.newAchievements); + if (outcome.accepted > 0 || !outcome.newAchievements.isEmpty()) + refreshStats(); +} + +void GamificationManager::flushNow() +{ + if (m_flushInFlight || m_deleteInFlight || m_queue.isEmpty()) + return; + if (!consentAcknowledged() || !syncEnabled()) + return; + if (!signedIn()) + return; // zero network when logged out; queue holds + if (QDateTime::currentMSecsSinceEpoch() < m_nextFlushAllowedAt) + return; + + const QString token = CloudCredentialStore::loadSession().token; + if (token.isEmpty()) + return; + + // Snapshot the batches on the main thread; the worker only does network. + // Streams disabled since queueing are NOT sent (their queued entries were + // dropped by the toggle, but gate again in case the setting changed + // out-of-band); entries stamped for a different account are dropped. + QStringList foreignIds; + const QList featureBatch = + usageEnabled() ? flushableBatch(QStringLiteral("feature"), &foreignIds) + : QList(); + const QList operationBatch = + opsEnabled() ? flushableBatch(QStringLiteral("operation"), &foreignIds) + : QList(); + if (!foreignIds.isEmpty()) + m_queue.acknowledge(foreignIds); + if (featureBatch.isEmpty() && operationBatch.isEmpty()) + return; + + m_flushInFlight = true; + QPointer self(this); + QThread* worker = QThread::create([self, token, featureBatch, operationBatch]() { + const FlushOutcome outcome = performFlush(token, featureBatch, operationBatch); + auto* app = QCoreApplication::instance(); + if (!app) + return; // app teardown raced the worker; nothing to deliver to + QMetaObject::invokeMethod(app, [self, outcome]() { + if (!self) + return; + self->m_flushInFlight = false; + self->applyFlushOutcome(outcome); + // A delete-my-data request arrived while this flush was in + // flight: run it now that the flush can no longer race it. + if (self->m_deleteRequestedDuringFlush) { + self->m_deleteRequestedDuringFlush = false; + self->deleteCloudData(); + } + }); + }); + connect(worker, &QThread::finished, worker, &QObject::deleteLater); + worker->start(); +} + +int GamificationManager::flushBlocking(int timeoutMs) +{ + if (m_flushInFlight || m_deleteInFlight || m_queue.isEmpty()) + return 0; + if (!consentAcknowledged() || !syncEnabled() || !signedIn()) + return 0; + const QString token = CloudCredentialStore::loadSession().token; + if (token.isEmpty()) + return 0; + + QStringList foreignIds; + const QList featureBatch = + usageEnabled() ? flushableBatch(QStringLiteral("feature"), &foreignIds) + : QList(); + const QList operationBatch = + opsEnabled() ? flushableBatch(QStringLiteral("operation"), &foreignIds) + : QList(); + if (!foreignIds.isEmpty()) + m_queue.acknowledge(foreignIds); + if (featureBatch.isEmpty() && operationBatch.isEmpty()) + return 0; + + m_flushInFlight = true; + const FlushOutcome outcome = + performFlush(token, featureBatch, operationBatch, qMax(500, timeoutMs)); + m_flushInFlight = false; + if (!outcome.ackedIds.isEmpty()) + m_queue.acknowledge(outcome.ackedIds); + if (!outcome.newAchievements.isEmpty()) + emit achievementsUnlocked(outcome.newAchievements); + return outcome.accepted; +} + +// ---- Stats ----------------------------------------------------------------- + +QString GamificationManager::statsCachePath() const +{ + return gamificationDir() + QStringLiteral("/stats_cache.json"); +} + +void GamificationManager::loadCachedStats() +{ + QFile file(statsCachePath()); + if (!file.open(QIODevice::ReadOnly)) + return; + const QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); + if (doc.isObject()) + m_snapshot = Gamification::StatsSnapshot::fromJson(doc.object()); +} + +void GamificationManager::saveCachedStats(const QJsonObject& raw) const +{ + QDir().mkpath(gamificationDir()); + QFile file(statsCachePath()); + if (file.open(QIODevice::WriteOnly)) + file.write(QJsonDocument(raw).toJson(QJsonDocument::Compact)); +} + +void GamificationManager::refreshStats() +{ + if (m_statsRefreshInFlight) + return; + if (!signedIn() || !syncEnabled()) + return; + const QString token = CloudCredentialStore::loadSession().token; + if (token.isEmpty()) + return; + + m_statsRefreshInFlight = true; + QPointer self(this); + QThread* worker = QThread::create([self, token]() { + const QtMeshCloudClient::GamificationStatsResult result = + QtMeshCloudClient::fetchGamificationStats(token); + auto* app = QCoreApplication::instance(); + if (!app) + return; // app teardown raced the worker; nothing to deliver to + QMetaObject::invokeMethod(app, [self, result]() { + if (!self) + return; + self->m_statsRefreshInFlight = false; + if (!result.ok) + return; + self->m_lastStatsRefreshAt = QDateTime::currentMSecsSinceEpoch(); + self->m_snapshot = Gamification::StatsSnapshot::fromJson(result.stats); + self->saveCachedStats(result.stats); + emit self->statsChanged(); + emit self->suggestionChanged(); + }); + }); + connect(worker, &QThread::finished, worker, &QObject::deleteLater); + worker->start(); +} + +void GamificationManager::refreshStatsIfStale(qint64 maxAgeMs) +{ + if (QDateTime::currentMSecsSinceEpoch() - m_lastStatsRefreshAt >= maxAgeMs) + refreshStats(); +} + +void GamificationManager::handleSessionChanged() +{ + if (!signedIn()) { + // Keep the queue (it flushes after the next sign-in) but drop the + // previous account's stats so nothing leaks across users. + m_snapshot = Gamification::StatsSnapshot(); + QFile::remove(statsCachePath()); + emit statsChanged(); + } else { + refreshStats(); + scheduleFlushSoon(); + } + emit sessionChanged(); + emit suggestionChanged(); +} + +// ---- Status surface -------------------------------------------------------- + +QVariantList GamificationManager::nextUnlockables() const +{ + QVariantList out; + if (!m_snapshot.valid) + return out; + const QList next = m_snapshot.nextUnlockables(2); + for (const Gamification::NextUnlockable& u : next) + out.append(u.toVariantMap()); + return out; +} + +QString GamificationManager::profileUrl() const +{ + const QString slug = QSettings().value(AppSettingsKeys::cloudUserSlug()).toString().trimmed(); + return QtMeshCloudClient::profileUrl(slug); +} + +void GamificationManager::openProfile() +{ + const QString url = profileUrl(); + if (url.isEmpty()) + return; + SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), + QStringLiteral("Gamification: open web profile")); + QDesktopServices::openUrl(QUrl(url)); +} + +// ---- Discovery suggestion --------------------------------------------------- + +QVariantMap GamificationManager::suggestion() const +{ + QVariantMap out; + if (!nudgesEnabled()) + return out; + + const QStringList dismissed = + QSettings().value(AppSettingsKeys::gamificationDismissedSuggestions()).toStringList(); + + // Personalized (unused clusters) when stats are known; otherwise a generic + // rotation through the catalog for logged-out users. + QStringList candidates = m_snapshot.valid + ? m_snapshot.unusedFeatureKeys() + : [] { + QStringList all; + for (const auto& f : Gamification::featureCatalog()) + all.append(f.key); + return all; + }(); + for (const QString& key : dismissed) + candidates.removeAll(key); + for (const QString& key : m_sessionNotedFeatures) + candidates.removeAll(key); + if (candidates.isEmpty()) + return out; + + const int cursor = + QSettings().value(AppSettingsKeys::gamificationSuggestionCursor(), 0).toInt(); + const QString key = candidates.at(qAbs(cursor) % candidates.size()); + const Gamification::FeatureInfo* info = Gamification::featureInfo(key); + if (!info) + return out; + + out.insert(QStringLiteral("featureKey"), info->key); + out.insert(QStringLiteral("title"), info->title); + out.insert(QStringLiteral("blurb"), info->blurb); + out.insert(QStringLiteral("personalized"), m_snapshot.valid); + return out; +} + +void GamificationManager::dismissSuggestion(const QString& featureKey) +{ + QSettings settings; + QStringList dismissed = + settings.value(AppSettingsKeys::gamificationDismissedSuggestions()).toStringList(); + if (!dismissed.contains(featureKey)) + dismissed.append(featureKey); + settings.setValue(AppSettingsKeys::gamificationDismissedSuggestions(), dismissed); + emit suggestionChanged(); +} + +void GamificationManager::advanceSuggestion() +{ + QSettings settings; + const int cursor = settings.value(AppSettingsKeys::gamificationSuggestionCursor(), 0).toInt(); + settings.setValue(AppSettingsKeys::gamificationSuggestionCursor(), cursor + 1); + emit suggestionChanged(); +} + +// ---- Privacy --------------------------------------------------------------- + +void GamificationManager::deleteCloudData() +{ + // Serialize with flushing: a flush already in flight could recreate + // server-side rows right after the purge, so defer until it settles; + // m_deleteInFlight blocks any NEW flush from starting meanwhile. + m_deleteInFlight = true; + if (m_flushInFlight) { + m_deleteRequestedDuringFlush = true; + return; + } + if (m_flushTimer) + m_flushTimer->stop(); + + // Local half first — queued events must stop being upload-eligible the + // moment the user asks, regardless of how the server call goes. + const bool localCleared = m_queue.clear(); + m_sessionNotedFeatures.clear(); + m_snapshot = Gamification::StatsSnapshot(); + QFile::remove(statsCachePath()); + emit statsChanged(); + emit suggestionChanged(); + + const QString token = CloudCredentialStore::loadSession().token; + if (token.isEmpty()) { + m_deleteInFlight = false; + emit deleteCloudDataFinished( + localCleared, localCleared + ? QString() + : tr("Could not clear the local event queue — try again.")); + return; + } + + performCloudDelete(token); +} + +void GamificationManager::performCloudDelete(const QString& token) +{ + QPointer self(this); + QThread* worker = QThread::create([self, token]() { + const QtMeshCloudClient::UploadResult result = + QtMeshCloudClient::deleteGamificationData(token); + auto* app = QCoreApplication::instance(); + if (!app) + return; // app teardown raced the worker; nothing to deliver to + QMetaObject::invokeMethod(app, [self, result]() { + if (!self) + return; + self->m_deleteInFlight = false; + SentryReporter::addBreadcrumb(QStringLiteral("gamify.prefs"), + result.ok + ? QStringLiteral("Gamification data deleted") + : QStringLiteral("Gamification data delete failed")); + emit self->deleteCloudDataFinished(result.ok, result.errorString); + }); + }); + connect(worker, &QThread::finished, worker, &QObject::deleteLater); + worker->start(); +} + +void GamificationManager::refreshCloudPrefs() +{ + // Opted-out users get zero gamification network traffic, including the + // prefs fetch the Preferences pane triggers. + if (!syncEnabled()) + return; + const QString token = CloudCredentialStore::loadSession().token; + if (token.isEmpty()) + return; + QPointer self(this); + QThread* worker = QThread::create([self, token]() { + const QtMeshCloudClient::GamificationPrefsResult result = + QtMeshCloudClient::fetchGamificationPrefs(token); + auto* app = QCoreApplication::instance(); + if (!app) + return; // app teardown raced the worker; nothing to deliver to + QMetaObject::invokeMethod(app, [self, result]() { + if (!self || !result.ok) + return; + self->m_profilePublic = result.profilePublic; + emit self->cloudPrefsChanged(); + }); + }); + connect(worker, &QThread::finished, worker, &QObject::deleteLater); + worker->start(); +} + +void GamificationManager::setProfilePublic(bool isPublic) +{ + if (!syncEnabled()) + return; + const QString token = CloudCredentialStore::loadSession().token; + if (token.isEmpty()) + return; + QPointer self(this); + QThread* worker = QThread::create([self, token, isPublic]() { + // The cloud PUT merges partial bodies onto the stored prefs, so only + // the field being changed is sent. + QJsonObject patch; + patch.insert(QStringLiteral("profilePublic"), isPublic); + const QtMeshCloudClient::GamificationPrefsResult result = + QtMeshCloudClient::setGamificationPrefs(token, patch); + auto* app = QCoreApplication::instance(); + if (!app) + return; // app teardown raced the worker; nothing to deliver to + QMetaObject::invokeMethod(app, [self, result]() { + GamificationManager* mgr = self.data(); + if (!mgr) + return; + if (result.ok) + mgr->m_profilePublic = result.profilePublic; + emit mgr->cloudPrefsChanged(); + }); + }); + connect(worker, &QThread::finished, worker, &QObject::deleteLater); + worker->start(); +} diff --git a/src/GamificationManager.h b/src/GamificationManager.h new file mode 100644 index 000000000..74b7d8c04 --- /dev/null +++ b/src/GamificationManager.h @@ -0,0 +1,222 @@ +#ifndef GAMIFICATION_MANAGER_H +#define GAMIFICATION_MANAGER_H + +#include "GamificationEventQueue.h" +#include "GamificationTypes.h" + +#include +#include +#include +#include +#include +#include + +class QTimer; + +/// Central gamification orchestrator (#796): consent + privacy gating (E-P6), +/// the offline event queue and cloud flush loop (E-P1), the `noteFeature` / +/// `noteOperation` instrumentation entry points (E-P2/E-P3), cached +/// `/v1/me/stats` for the in-app status surface (E-P4), and the welcome-screen +/// discovery suggestion (E-P5). +/// +/// Privacy invariants (epic guardrails): +/// - Nothing is queued before the user acknowledges the consent prompt or +/// enables "Sync my QtMesh progress" in Preferences (default OFF). +/// - Zero network activity when logged out or opted out; events queued while +/// offline are held locally and flushed once authenticated + online. +/// - Only feature keys, timestamps and numeric metrics are ever sent — +/// never asset content or file names. +class GamificationManager : public QObject +{ + Q_OBJECT + Q_PROPERTY(bool signedIn READ signedIn NOTIFY sessionChanged) + Q_PROPERTY(bool consentAcknowledged READ consentAcknowledged NOTIFY prefsChanged) + Q_PROPERTY(bool syncEnabled READ syncEnabled WRITE setSyncEnabled NOTIFY prefsChanged) + Q_PROPERTY(bool usageEnabled READ usageEnabled WRITE setUsageEnabled NOTIFY prefsChanged) + Q_PROPERTY(bool opsEnabled READ opsEnabled WRITE setOpsEnabled NOTIFY prefsChanged) + Q_PROPERTY(bool nudgesEnabled READ nudgesEnabled WRITE setNudgesEnabled NOTIFY prefsChanged) + Q_PROPERTY(bool statsAvailable READ statsAvailable NOTIFY statsChanged) + Q_PROPERTY(int level READ level NOTIFY statsChanged) + Q_PROPERTY(int xp READ xp NOTIFY statsChanged) + Q_PROPERTY(int xpIntoLevel READ xpIntoLevel NOTIFY statsChanged) + Q_PROPERTY(int xpSpan READ xpSpan NOTIFY statsChanged) + Q_PROPERTY(double xpFraction READ xpFraction NOTIFY statsChanged) + Q_PROPERTY(int currentStreak READ currentStreak NOTIFY statsChanged) + Q_PROPERTY(int achievementsEarned READ achievementsEarned NOTIFY statsChanged) + Q_PROPERTY(QVariantList nextUnlockables READ nextUnlockables NOTIFY statsChanged) + Q_PROPERTY(QVariantMap suggestion READ suggestion NOTIFY suggestionChanged) + Q_PROPERTY(QString profileUrl READ profileUrl NOTIFY sessionChanged) + Q_PROPERTY(bool profilePublic READ profilePublic NOTIFY cloudPrefsChanged) + +public: + enum class Surface { Gui, Cli, Mcp }; + + static GamificationManager* instance(); + static GamificationManager* qmlInstance(QQmlEngine* engine, QJSEngine* scriptEngine); + static void kill(); + + // ---- Instrumentation entry points (one-liners at controller entry) ---- + + /// Records use of a feature cluster (E-P2). Thread-safe; no-op when the + /// user is opted out / has not consented. Deduped per session. + static void noteFeature(const QString& featureKey, Surface surface = Surface::Gui); + + /// Records a completed operation with content-free numeric metrics + /// (E-P3), e.g. noteOperation("retopo", {{"tris_before", 42180}, + /// {"tris_after", 8004}}). Also notes the matching feature cluster. + static void noteOperation(const QString& opType, const QVariantMap& metrics, + Surface surface = Surface::Gui); + + /// Cloud project context attached to subsequent operations (both empty to + /// clear). Set while browsing/uploading a cloud project. + static void setProjectContext(const QString& ownerSlug, const QString& projectSlug); + + /// Process-wide kill switch: while suspended, noteFeature/noteOperation + /// are no-ops at every call site (CLI --no-telemetry sets this before + /// dispatching the subcommand). + static void setEmissionSuspended(bool suspended); + static bool emissionSuspended(); + + // ---- Flush / stats ---- + + /// Async flush of the pending queue (no-op logged-out/opted-out/backoff). + Q_INVOKABLE void flushNow(); + + /// Synchronous flush for one-shot CLI processes; returns events flushed. + int flushBlocking(int timeoutMs = 5000); + + Q_INVOKABLE void refreshStats(); + void refreshStatsIfStale(qint64 maxAgeMs = 5 * 60 * 1000); + + /// Called by MainWindow after sign-in/out so the surface updates. + void handleSessionChanged(); + + const Gamification::StatsSnapshot& snapshot() const { return m_snapshot; } + int pendingEventCount() const { return m_queue.size(); } + + // ---- Prefs / consent (E-P6) ---- + bool signedIn() const; + bool consentAcknowledged() const; + bool syncEnabled() const; + bool usageEnabled() const; + bool opsEnabled() const; + bool nudgesEnabled() const; + void setSyncEnabled(bool enabled); + void setUsageEnabled(bool enabled); + void setOpsEnabled(bool enabled); + void setNudgesEnabled(bool enabled); + + /// True when usage events may be recorded right now. + bool usageEmissionActive() const; + /// True when operation events may be recorded right now. + bool opsEmissionActive() const; + + Q_INVOKABLE void acceptConsent(); + Q_INVOKABLE void declineConsent(); + + /// Example event payloads for the "What is shared?" preferences section. + Q_INVOKABLE QString examplePayload() const; + + /// DELETE /v1/me/gamification + clears the local queue and cached stats. + Q_INVOKABLE void deleteCloudData(); + + /// PUT profilePublic to the account (fetches current prefs, merges). + Q_INVOKABLE void setProfilePublic(bool isPublic); + Q_INVOKABLE void refreshCloudPrefs(); + bool profilePublic() const { return m_profilePublic; } + + // ---- Status surface (E-P4) ---- + bool statsAvailable() const { return m_snapshot.valid; } + int level() const { return m_snapshot.level; } + int xp() const { return static_cast(m_snapshot.xp); } + int xpIntoLevel() const { return static_cast(m_snapshot.intoLevel); } + int xpSpan() const { return static_cast(m_snapshot.span); } + double xpFraction() const { return m_snapshot.fraction; } + int currentStreak() const { return m_snapshot.currentStreak; } + int achievementsEarned() const { return m_snapshot.achievements.size(); } + QVariantList nextUnlockables() const; + QString profileUrl() const; + Q_INVOKABLE void openProfile(); + + // ---- Discovery suggestion (E-P5) ---- + QVariantMap suggestion() const; + Q_INVOKABLE void dismissSuggestion(const QString& featureKey); + Q_INVOKABLE void advanceSuggestion(); + +signals: + void prefsChanged(); + void sessionChanged(); + void statsChanged(); + void suggestionChanged(); + void cloudPrefsChanged(); + /// Newly-earned achievements from a flush — list of maps with + /// key/title/description/tier/xp (E-P4 toast; coalesced, max 1 toast). + void achievementsUnlocked(const QVariantList& achievements); + /// One-time non-blocking consent prompt should be shown (E-P6). + void consentPromptRequested(); + void deleteCloudDataFinished(bool ok, const QString& error); + +private: + explicit GamificationManager(QObject* parent = nullptr); + ~GamificationManager() override; + + void noteFeatureInternal(const QString& featureKey, Surface surface); + void noteOperationInternal(const QString& opType, const QVariantMap& metrics, + Surface surface); + bool maybeRequestConsent(); + void scheduleFlushSoon(); + /// Owner slug for events queued right now (empty when logged out). + QString currentEventOwner() const; + /// Snapshot a flushable batch of @p kind for the current account, and + /// collect (into @p dropIds) queued ids that belong to a DIFFERENT + /// account — those are never sent and get dropped. + QList flushableBatch(const QString& kind, + QStringList* dropIds) const; + /// Runs the server-side purge; local queue/cache were already cleared. + void performCloudDelete(const QString& token); + + struct FlushOutcome { + bool attempted = false; + bool ok = false; + int accepted = 0; + QStringList ackedIds; + QVariantList newAchievements; + QString error; + }; + /// Blocking network flush of pre-snapshotted batches (safe to run on a + /// worker thread — does not touch the queue or any member state). + static FlushOutcome performFlush(const QString& token, + const QList& featureBatch, + const QList& operationBatch, + int timeoutMs = 30000); + void applyFlushOutcome(const FlushOutcome& outcome); + + void loadCachedStats(); + void saveCachedStats(const QJsonObject& raw) const; + QString statsCachePath() const; + + static QString surfaceName(Surface surface); + static QString newEventId(); + + GamificationEventQueue m_queue; + Gamification::StatsSnapshot m_snapshot; + QSet m_sessionNotedFeatures; + QString m_activeOwnerSlug; + QString m_activeProjectSlug; + + QTimer* m_flushTimer = nullptr; + bool m_flushInFlight = false; + bool m_statsRefreshInFlight = false; + /// Set while a delete-my-data request is pending or in flight: blocks + /// flushes so a racing flush can't recreate server-side data. + bool m_deleteInFlight = false; + bool m_deleteRequestedDuringFlush = false; + int m_consecutiveFlushFailures = 0; + qint64 m_nextFlushAllowedAt = 0; + qint64 m_lastStatsRefreshAt = 0; + bool m_profilePublic = false; + + static GamificationManager* s_singleton; +}; + +#endif // GAMIFICATION_MANAGER_H diff --git a/src/GamificationManager_test.cpp b/src/GamificationManager_test.cpp new file mode 100644 index 000000000..3f42fd397 --- /dev/null +++ b/src/GamificationManager_test.cpp @@ -0,0 +1,427 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "AppSettingsKeys.h" +#include "CloudCredentialStore.h" +#include "GamificationManager.h" + +namespace { + +/// Minimal HTTP mock for the gamification endpoints. +struct GamifyHttpMock { + QTcpServer server; + QStringList paths; + QList bodies; + QByteArray statsBody; + + bool listen() + { + statsBody = QByteArrayLiteral( + R"({"stats":{"xp":25,"level":1,"current_streak":1,"longest_streak":1,)" + R"("last_active_day":"2026-07-05"},)" + R"("progress":{"level":1,"levelFloorXp":0,"nextLevelXp":100,)" + R"("intoLevel":25,"span":100,"fraction":0.25},)" + R"("achievements":[],"featureUsage":[],"counters":{},)" + R"("recentlyEarned":[],"recentOperations":[]})"); + + QObject::connect(&server, &QTcpServer::newConnection, [this]() { + QTcpSocket* socket = server.nextPendingConnection(); + auto buffer = std::make_shared(); + QObject::connect(socket, &QTcpSocket::readyRead, socket, [this, socket, buffer]() { + buffer->append(socket->readAll()); + const int headerEnd = buffer->indexOf("\r\n\r\n"); + if (headerEnd < 0) + return; + const QByteArray head = buffer->left(headerEnd); + int contentLength = 0; + for (const QByteArray& line : head.split('\n')) { + if (line.toLower().startsWith("content-length:")) + contentLength = line.mid(15).trimmed().toInt(); + } + if (buffer->size() < headerEnd + 4 + contentLength) + return; + + const QString requestLine = QString::fromUtf8(head.left(head.indexOf('\r'))); + const QString method = requestLine.section(QLatin1Char(' '), 0, 0); + const QString path = requestLine.section(QLatin1Char(' '), 1, 1); + paths.append(method + QLatin1Char(' ') + path); + const QJsonObject body = + QJsonDocument::fromJson(buffer->mid(headerEnd + 4)).object(); + bodies.append(body); + + QByteArray response; + if (path == QStringLiteral("/v1/me/stats")) { + response = statsBody; + } else if (path == QStringLiteral("/v1/events/editor") + || path == QStringLiteral("/v1/events/operations")) { + const QString field = path.endsWith(QStringLiteral("editor")) + ? QStringLiteral("events") + : QStringLiteral("operations"); + QJsonObject r; + r.insert(QStringLiteral("accepted"), + body.value(field).toArray().size()); + QJsonArray achievements; + if (field == QStringLiteral("events")) { + QJsonObject a; + a.insert(QStringLiteral("key"), QStringLiteral("first_retopo")); + a.insert(QStringLiteral("title"), QStringLiteral("Retopologist")); + a.insert(QStringLiteral("tier"), QStringLiteral("bronze")); + a.insert(QStringLiteral("xp"), 25); + achievements.append(a); + } + r.insert(QStringLiteral("newAchievements"), achievements); + response = QJsonDocument(r).toJson(QJsonDocument::Compact); + } else if (path == QStringLiteral("/v1/me/gamification")) { + response = QByteArrayLiteral(R"({"ok":true})"); + } else { + response = QByteArrayLiteral(R"({"error":"not found"})"); + } + + QByteArray out = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n"; + out += "Content-Length: " + QByteArray::number(response.size()); + out += "\r\nConnection: close\r\n\r\n" + response; + socket->write(out); + socket->flush(); + socket->waitForBytesWritten(2000); + socket->disconnectFromHost(); + }); + }); + return server.listen(QHostAddress::LocalHost); + } + + QString baseUrl() const + { + return QStringLiteral("http://127.0.0.1:%1").arg(server.serverPort()); + } +}; + +} // namespace + +class GamificationManagerTest : public ::testing::Test { +protected: + void SetUp() override + { + m_prevOrg = QCoreApplication::organizationName(); + m_prevApp = QCoreApplication::applicationName(); + QCoreApplication::setOrganizationName(QStringLiteral("QtMeshEditorTests")); + QCoreApplication::setApplicationName(QStringLiteral("GamificationManagerTest")); + QStandardPaths::setTestModeEnabled(true); + QSettings().clear(); + CloudCredentialStore::resetCacheForTesting(); + cleanStorage(); + m_prevApiBase = qgetenv("QTMESH_API_BASE"); + GamificationManager::setEmissionSuspended(false); + GamificationManager::kill(); + } + + void TearDown() override + { + GamificationManager::setEmissionSuspended(false); + GamificationManager::kill(); + QSettings().clear(); + CloudCredentialStore::resetCacheForTesting(); + cleanStorage(); + if (m_prevApiBase.isEmpty()) + qunsetenv("QTMESH_API_BASE"); + else + qputenv("QTMESH_API_BASE", m_prevApiBase); + QStandardPaths::setTestModeEnabled(false); + QCoreApplication::setOrganizationName(m_prevOrg); + QCoreApplication::setApplicationName(m_prevApp); + } + + static void cleanStorage() + { + const QString dir = + QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + + QStringLiteral("/gamification"); + QDir(dir).removeRecursively(); + } + + static void enableSync() + { + QSettings settings; + settings.setValue(AppSettingsKeys::gamificationConsentAcknowledged(), true); + settings.setValue(AppSettingsKeys::gamificationSyncEnabled(), true); + } + + static void signIn() + { + CloudSession session; + session.token = QStringLiteral("qtm_sess_test_token"); + session.expiresAt = 9999999999999LL; + session.email = QStringLiteral("test@example.com"); + ASSERT_TRUE(CloudCredentialStore::saveSession(session)); + } + + QString m_prevOrg; + QString m_prevApp; + QByteArray m_prevApiBase; +}; + +TEST_F(GamificationManagerTest, NoConsentMeansNothingQueued) +{ + auto* gamify = GamificationManager::instance(); + EXPECT_FALSE(gamify->consentAcknowledged()); + EXPECT_FALSE(gamify->syncEnabled()); + GamificationManager::noteFeature(QStringLiteral("retopo")); + GamificationManager::noteOperation(QStringLiteral("retopo"), + {{QStringLiteral("tris_before"), 100}}); + EXPECT_EQ(gamify->pendingEventCount(), 0); +} + +TEST_F(GamificationManagerTest, ConsentPromptRequestedOncePerInstallWhenSignedIn) +{ + signIn(); + auto* gamify = GamificationManager::instance(); + QSignalSpy spy(gamify, &GamificationManager::consentPromptRequested); + GamificationManager::noteFeature(QStringLiteral("retopo")); + EXPECT_EQ(spy.count(), 1); + GamificationManager::noteFeature(QStringLiteral("uv_unwrap")); + EXPECT_EQ(spy.count(), 1); // once, ever + EXPECT_EQ(gamify->pendingEventCount(), 0); +} + +TEST_F(GamificationManagerTest, NoteFeatureQueuesOncePerSession) +{ + enableSync(); + auto* gamify = GamificationManager::instance(); + GamificationManager::noteFeature(QStringLiteral("retopo")); + GamificationManager::noteFeature(QStringLiteral("retopo")); + GamificationManager::noteFeature(QStringLiteral("uv_unwrap")); + GamificationManager::noteFeature(QStringLiteral("NOT A KEY")); + EXPECT_EQ(gamify->pendingEventCount(), 2); +} + +TEST_F(GamificationManagerTest, NoteOperationFiltersNonNumericMetricsAndAliasesFeature) +{ + enableSync(); + auto* gamify = GamificationManager::instance(); + GamificationManager::noteOperation( + QStringLiteral("retopo"), + {{QStringLiteral("tris_before"), 42180}, + {QStringLiteral("tris_after"), 8004}, + {QStringLiteral("quad_ratio_after"), 0.82}, + {QStringLiteral("sneaky_path"), QStringLiteral("/Users/x/secret.fbx")}, + {QStringLiteral("Bad Key"), 3}}); + // 1 operation event + 1 aliased feature.used event. + EXPECT_EQ(gamify->pendingEventCount(), 2); + + // Inspect the persisted queue: the operation body must be numeric-only. + const QString queuePath = + QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + + QStringLiteral("/gamification/queue.json"); + QFile f(queuePath); + ASSERT_TRUE(f.open(QIODevice::ReadOnly)); + const QJsonArray events = + QJsonDocument::fromJson(f.readAll()).object().value(QStringLiteral("events")).toArray(); + bool foundOp = false; + for (const QJsonValue& v : events) { + const QJsonObject entry = v.toObject(); + if (entry.value(QStringLiteral("kind")).toString() != QStringLiteral("operation")) + continue; + foundOp = true; + const QJsonObject body = entry.value(QStringLiteral("body")).toObject(); + EXPECT_EQ(body.value(QStringLiteral("op")).toString(), QStringLiteral("retopo")); + const QJsonObject metrics = body.value(QStringLiteral("metrics")).toObject(); + EXPECT_EQ(metrics.value(QStringLiteral("tris_before")).toDouble(), 42180.0); + EXPECT_DOUBLE_EQ(metrics.value(QStringLiteral("quad_ratio_after")).toDouble(), 0.82); + EXPECT_FALSE(metrics.contains(QStringLiteral("sneaky_path"))); + EXPECT_FALSE(metrics.contains(QStringLiteral("Bad Key"))); + } + EXPECT_TRUE(foundOp); +} + +TEST_F(GamificationManagerTest, StreamOptOutsBlockTheirEvents) +{ + enableSync(); + QSettings().setValue(AppSettingsKeys::gamificationUsageEnabled(), false); + auto* gamify = GamificationManager::instance(); + GamificationManager::noteFeature(QStringLiteral("retopo")); + EXPECT_EQ(gamify->pendingEventCount(), 0); + + QSettings().setValue(AppSettingsKeys::gamificationOpsEnabled(), false); + GamificationManager::noteOperation(QStringLiteral("auto_rig"), + {{QStringLiteral("bones_created"), 19}}); + EXPECT_EQ(gamify->pendingEventCount(), 0); +} + +TEST_F(GamificationManagerTest, FlushBlockingIsNoOpWhenLoggedOut) +{ + enableSync(); + auto* gamify = GamificationManager::instance(); + GamificationManager::noteFeature(QStringLiteral("retopo")); + EXPECT_EQ(gamify->pendingEventCount(), 1); + // Logged out: zero network, events held. + EXPECT_EQ(gamify->flushBlocking(), 0); + EXPECT_EQ(gamify->pendingEventCount(), 1); +} + +TEST_F(GamificationManagerTest, FlushBlockingPostsBatchesAndAcks) +{ + GamifyHttpMock mock; + ASSERT_TRUE(mock.listen()); + qputenv("QTMESH_API_BASE", mock.baseUrl().toUtf8()); + + enableSync(); + signIn(); + auto* gamify = GamificationManager::instance(); + GamificationManager::noteFeature(QStringLiteral("retopo")); + GamificationManager::noteOperation(QStringLiteral("retopo"), + {{QStringLiteral("tris_before"), 100}, + {QStringLiteral("tris_after"), 50}}); + // retopo op also aliases the retopo feature — already noted, so: 1 + // feature event + 1 operation event. + EXPECT_EQ(gamify->pendingEventCount(), 2); + + QSignalSpy unlocked(gamify, &GamificationManager::achievementsUnlocked); + const int accepted = gamify->flushBlocking(); + EXPECT_EQ(accepted, 2); + EXPECT_EQ(gamify->pendingEventCount(), 0); + ASSERT_EQ(unlocked.count(), 1); + const QVariantList achievements = unlocked.first().first().toList(); + ASSERT_EQ(achievements.size(), 1); + EXPECT_EQ(achievements.first().toMap().value(QStringLiteral("title")).toString(), + QStringLiteral("Retopologist")); + + // Both endpoints hit, with the exact contract paths. + EXPECT_TRUE(mock.paths.contains(QStringLiteral("POST /v1/events/editor"))); + EXPECT_TRUE(mock.paths.contains(QStringLiteral("POST /v1/events/operations"))); + + // Event bodies carry id/feature/at/surface per the wire contract. + for (int i = 0; i < mock.paths.size(); ++i) { + if (mock.paths.at(i) == QStringLiteral("POST /v1/events/editor")) { + const QJsonArray events = mock.bodies.at(i).value(QStringLiteral("events")).toArray(); + ASSERT_EQ(events.size(), 1); + const QJsonObject e = events.first().toObject(); + EXPECT_FALSE(e.value(QStringLiteral("id")).toString().isEmpty()); + EXPECT_EQ(e.value(QStringLiteral("feature")).toString(), QStringLiteral("retopo")); + EXPECT_GT(e.value(QStringLiteral("at")).toDouble(), 0.0); + EXPECT_EQ(e.value(QStringLiteral("surface")).toString(), QStringLiteral("gui")); + } + } +} + +TEST_F(GamificationManagerTest, DeleteCloudDataClearsLocallyWhenLoggedOut) +{ + enableSync(); + auto* gamify = GamificationManager::instance(); + GamificationManager::noteFeature(QStringLiteral("retopo")); + EXPECT_EQ(gamify->pendingEventCount(), 1); + QSignalSpy done(gamify, &GamificationManager::deleteCloudDataFinished); + gamify->deleteCloudData(); + ASSERT_EQ(done.count(), 1); + EXPECT_TRUE(done.first().first().toBool()); + EXPECT_EQ(gamify->pendingEventCount(), 0); +} + +TEST_F(GamificationManagerTest, SuggestionRotationAndDismissal) +{ + auto* gamify = GamificationManager::instance(); + const QVariantMap first = gamify->suggestion(); + ASSERT_TRUE(first.contains(QStringLiteral("featureKey"))); // generic, logged out + const QString firstKey = first.value(QStringLiteral("featureKey")).toString(); + + gamify->dismissSuggestion(firstKey); + const QVariantMap second = gamify->suggestion(); + EXPECT_NE(second.value(QStringLiteral("featureKey")).toString(), firstKey); + + gamify->setNudgesEnabled(false); + EXPECT_TRUE(gamify->suggestion().isEmpty()); + gamify->setNudgesEnabled(true); + EXPECT_FALSE(gamify->suggestion().isEmpty()); +} + +TEST_F(GamificationManagerTest, ConsentPromptNotConsumedWithoutListener) +{ + // Headless contexts (CLI/MCP) have no MainWindow connected to + // consentPromptRequested — they must not burn the one-time GUI prompt. + signIn(); + GamificationManager::instance(); // create WITHOUT a QSignalSpy attached + GamificationManager::noteFeature(QStringLiteral("retopo")); + EXPECT_FALSE(QSettings() + .value(AppSettingsKeys::gamificationConsentPrompted(), false) + .toBool()); +} + +TEST_F(GamificationManagerTest, EmissionSuspendedBlocksAllNotes) +{ + enableSync(); + auto* gamify = GamificationManager::instance(); + GamificationManager::setEmissionSuspended(true); + GamificationManager::noteFeature(QStringLiteral("retopo")); + GamificationManager::noteOperation(QStringLiteral("retopo"), + {{QStringLiteral("tris_before"), 10}}); + EXPECT_EQ(gamify->pendingEventCount(), 0); + GamificationManager::setEmissionSuspended(false); + GamificationManager::noteFeature(QStringLiteral("retopo")); + EXPECT_EQ(gamify->pendingEventCount(), 1); +} + +TEST_F(GamificationManagerTest, DisablingStreamDropsItsQueuedEvents) +{ + enableSync(); + auto* gamify = GamificationManager::instance(); + GamificationManager::noteFeature(QStringLiteral("retopo")); + GamificationManager::noteOperation(QStringLiteral("auto_rig"), + {{QStringLiteral("bones_created"), 19}}); + // retopo feature + auto_rig feature alias + auto_rig operation + EXPECT_EQ(gamify->pendingEventCount(), 3); + gamify->setOpsEnabled(false); + EXPECT_EQ(gamify->pendingEventCount(), 2); // operation dropped + gamify->setUsageEnabled(false); + EXPECT_EQ(gamify->pendingEventCount(), 0); // features dropped +} + +TEST_F(GamificationManagerTest, EventsFromAnotherAccountAreDroppedNotSent) +{ + GamifyHttpMock mock; + ASSERT_TRUE(mock.listen()); + qputenv("QTMESH_API_BASE", mock.baseUrl().toUtf8()); + + enableSync(); + signIn(); + QSettings().setValue(AppSettingsKeys::cloudUserSlug(), QStringLiteral("user-a")); + auto* gamify = GamificationManager::instance(); + GamificationManager::noteFeature(QStringLiteral("retopo")); + EXPECT_EQ(gamify->pendingEventCount(), 1); + + // Account switch: same machine, different user. + QSettings().setValue(AppSettingsKeys::cloudUserSlug(), QStringLiteral("user-b")); + EXPECT_EQ(gamify->flushBlocking(), 0); + // user-a's event was dropped, not posted to user-b's account. + EXPECT_EQ(gamify->pendingEventCount(), 0); + EXPECT_TRUE(mock.paths.isEmpty()); +} + +TEST_F(GamificationManagerTest, AcceptAndDeclineConsent) +{ + auto* gamify = GamificationManager::instance(); + gamify->acceptConsent(); + EXPECT_TRUE(gamify->consentAcknowledged()); + EXPECT_TRUE(gamify->syncEnabled()); + gamify->declineConsent(); + EXPECT_TRUE(gamify->consentAcknowledged()); + EXPECT_FALSE(gamify->syncEnabled()); +} + +TEST_F(GamificationManagerTest, ExamplePayloadIsContentFreeJson) +{ + auto* gamify = GamificationManager::instance(); + const QString example = gamify->examplePayload(); + const QJsonObject root = QJsonDocument::fromJson(example.toUtf8()).object(); + EXPECT_TRUE(root.contains(QStringLiteral("feature_usage_event"))); + EXPECT_TRUE(root.contains(QStringLiteral("operation_event"))); +} diff --git a/src/GamificationToast.cpp b/src/GamificationToast.cpp new file mode 100644 index 000000000..53f0d9d43 --- /dev/null +++ b/src/GamificationToast.cpp @@ -0,0 +1,103 @@ +#include "GamificationToast.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace { +constexpr int kAutoHideMs = 6000; +constexpr int kMargin = 16; + +QPointer g_activeToast; +} // namespace + +void GamificationToast::showAchievements(QWidget* parentWindow, const QVariantList& achievements) +{ + if (!parentWindow || achievements.isEmpty()) + return; + if (!g_activeToast) + g_activeToast = new GamificationToast(parentWindow); + g_activeToast->appendAchievements(achievements); +} + +GamificationToast::GamificationToast(QWidget* parentWindow) + : QWidget(parentWindow) +{ + setAttribute(Qt::WA_DeleteOnClose); + setAttribute(Qt::WA_TranslucentBackground); + setCursor(Qt::PointingHandCursor); + + auto* layout = new QHBoxLayout(this); + layout->setContentsMargins(16, 12, 16, 12); + m_label = new QLabel(this); + m_label->setStyleSheet(QStringLiteral( + "color: #ececec; font-size: 12px; background: transparent;")); + m_label->setTextFormat(Qt::RichText); + layout->addWidget(m_label); + + m_hideTimer = new QTimer(this); + m_hideTimer->setSingleShot(true); + connect(m_hideTimer, &QTimer::timeout, this, &QWidget::close); +} + +void GamificationToast::appendAchievements(const QVariantList& achievements) +{ + for (const QVariant& v : achievements) { + const QVariantMap a = v.toMap(); + const QString title = a.value(QStringLiteral("title")).toString(); + if (title.isEmpty() || m_titles.contains(title)) + continue; + m_titles.append(title); + m_totalXp += a.value(QStringLiteral("xp")).toInt(); + } + if (m_titles.isEmpty()) { + close(); + return; + } + updateText(); + adjustSize(); + reposition(); + show(); + raise(); + m_hideTimer->start(kAutoHideMs); +} + +void GamificationToast::updateText() +{ + QString text = QStringLiteral("🏆 %1").arg(m_titles.first().toHtmlEscaped()); + if (m_titles.size() > 1) + text += tr(" and %n more achievement(s)", nullptr, m_titles.size() - 1); + if (m_totalXp > 0) + text += QStringLiteral(" +%1 XP").arg(m_totalXp); + m_label->setText(tr("Achievement unlocked — %1").arg(text)); +} + +void GamificationToast::reposition() +{ + QWidget* p = parentWidget(); + if (!p) + return; + move(p->width() - width() - kMargin, kMargin + 40); +} + +void GamificationToast::mousePressEvent(QMouseEvent* event) +{ + Q_UNUSED(event) + close(); +} + +void GamificationToast::paintEvent(QPaintEvent* event) +{ + Q_UNUSED(event) + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing, true); + QPainterPath path; + path.addRoundedRect(rect().adjusted(0, 0, -1, -1), 8, 8); + painter.fillPath(path, QColor(0x2b, 0x2b, 0x2b, 0xf0)); + painter.setPen(QColor(0x4a, 0x7a, 0xa8)); + painter.drawPath(path); +} diff --git a/src/GamificationToast.h b/src/GamificationToast.h new file mode 100644 index 000000000..8191f32b2 --- /dev/null +++ b/src/GamificationToast.h @@ -0,0 +1,38 @@ +#ifndef GAMIFICATION_TOAST_H +#define GAMIFICATION_TOAST_H + +#include +#include + +class QLabel; +class QTimer; + +/// One restrained, dismissible achievement-unlock toast (E-P4 #800). +/// Anchored to the parent window's top-right corner; auto-hides. Multiple +/// unlocks coalesce into a single toast ("… and 2 more") — never more than +/// one toast at a time, no animation (reduced-motion-safe). +class GamificationToast : public QWidget +{ + Q_OBJECT +public: + /// Shows (or merges into) the toast for @p achievements — a list of maps + /// with title/xp as delivered by GamificationManager::achievementsUnlocked. + static void showAchievements(QWidget* parentWindow, const QVariantList& achievements); + +protected: + void mousePressEvent(QMouseEvent* event) override; + void paintEvent(QPaintEvent* event) override; + +private: + explicit GamificationToast(QWidget* parentWindow); + void appendAchievements(const QVariantList& achievements); + void reposition(); + void updateText(); + + QLabel* m_label = nullptr; + QTimer* m_hideTimer = nullptr; + QStringList m_titles; + int m_totalXp = 0; +}; + +#endif // GAMIFICATION_TOAST_H diff --git a/src/GamificationTypes.cpp b/src/GamificationTypes.cpp new file mode 100644 index 000000000..6e165d061 --- /dev/null +++ b/src/GamificationTypes.cpp @@ -0,0 +1,305 @@ +#include "GamificationTypes.h" + +#include +#include + +#include + +namespace Gamification { + +const QList& featureCatalog() +{ + // Keys MUST match qtmesh-cloud's DISCOVERY_FEATURES (src/gamification.ts). + static const QList catalog = { + {QStringLiteral("retopo"), QStringLiteral("Quad Retopology"), + QStringLiteral("Convert triangle meshes into clean quad-dominant topology.")}, + {QStringLiteral("decimate_lod"), QStringLiteral("Decimation & LODs"), + QStringLiteral("Reduce triangle counts and generate LOD chains for game-ready assets.")}, + {QStringLiteral("uv_unwrap"), QStringLiteral("UV Unwrap"), + QStringLiteral("Auto-unwrap non-overlapping UVs, or use box/cylinder/sphere projection.")}, + {QStringLiteral("texture_paint"), QStringLiteral("Texture Paint"), + QStringLiteral("Paint directly onto your model's textures in the viewport.")}, + {QStringLiteral("pbr_synth"), QStringLiteral("PBR Map Synthesis"), + QStringLiteral("Generate normal, roughness and height maps from a single diffuse texture.")}, + {QStringLiteral("material_editor"), QStringLiteral("Material Editor"), + QStringLiteral("Edit materials with live preview, one-click presets and PBR templates.")}, + {QStringLiteral("auto_rig"), QStringLiteral("Auto-Rig"), + QStringLiteral("Embed a skeleton into a static mesh — template fit or guided markers.")}, + {QStringLiteral("skin_weights"), QStringLiteral("Auto Skin Weights"), + QStringLiteral("Compute smooth-bind skin weights for a mesh + skeleton in one click.")}, + {QStringLiteral("animation_blend"), QStringLiteral("Animation Tools"), + QStringLiteral("Blend, merge, resample and retarget skeletal animations.")}, + {QStringLiteral("morph"), QStringLiteral("Morph Targets"), + QStringLiteral("Create and animate morph (blend shape) targets.")}, + {QStringLiteral("motion_inbetween"), QStringLiteral("AI In-Betweening"), + QStringLiteral("Fill gaps between keyframes with smooth, plausible motion.")}, + {QStringLiteral("pose_library"), QStringLiteral("Pose Library"), + QStringLiteral("Save and reuse skeleton poses across animations.")}, + {QStringLiteral("vat_bake"), QStringLiteral("VAT Baking"), + QStringLiteral("Bake vertex animation textures for engine-side playback.")}, + {QStringLiteral("vertex_color_bake"), QStringLiteral("Vertex Color Bake"), + QStringLiteral("Bake lighting or textures into per-vertex colors.")}, + {QStringLiteral("texture_atlas"), QStringLiteral("Texture Atlas"), + QStringLiteral("Pack many textures into one atlas and remap mesh UVs onto it.")}, + {QStringLiteral("isometric_sprites"), QStringLiteral("Isometric Sprites"), + QStringLiteral("Render 8-direction isometric sprite atlases from any model.")}, + {QStringLiteral("turntable"), QStringLiteral("Turntable Render"), + QStringLiteral("Render turntable sprite sheets for previews and showcases.")}, + {QStringLiteral("ai_assist"), QStringLiteral("AI Assist"), + QStringLiteral("Local AI helpers: segmentation, texture upscaling and more.")}, + {QStringLiteral("image_to_3d"), QStringLiteral("Image → 3D"), + QStringLiteral("Reconstruct a textured 3D mesh from a single image.")}, + {QStringLiteral("stable_diffusion"), QStringLiteral("AI Texture Generation"), + QStringLiteral("Generate mesh-aware textures from a text prompt.")}, + {QStringLiteral("batch_export"), QStringLiteral("Batch Export"), + QStringLiteral("Convert and package many assets in one pass.")}, + {QStringLiteral("cli_scan"), QStringLiteral("CLI Asset Scan"), + QStringLiteral("Lint asset folders locally or in CI with `qtmesh scan`.")}, + {QStringLiteral("mcp_server"), QStringLiteral("MCP Server"), + QStringLiteral("Drive the editor from AI agents over the Model Context Protocol.")}, + {QStringLiteral("cloud_upload"), QStringLiteral("Cloud Upload"), + QStringLiteral("Publish projects to QtMesh Cloud with scan reports and share links.")}, + {QStringLiteral("lighting"), QStringLiteral("Scene Lighting"), + QStringLiteral("Light your scene with point/spot/directional lights or an HDR environment.")}, + }; + return catalog; +} + +const FeatureInfo* featureInfo(const QString& key) +{ + for (const FeatureInfo& f : featureCatalog()) { + if (f.key == key) + return &f; + } + return nullptr; +} + +bool isValidEventKey(const QString& key) +{ + static const QRegularExpression re(QStringLiteral("^[a-z0-9_]+$")); + return !key.isEmpty() && key.size() <= 64 && re.match(key).hasMatch(); +} + +Achievement Achievement::fromJson(const QJsonObject& o) +{ + Achievement a; + a.key = o.value(QStringLiteral("key")).toString(); + a.category = o.value(QStringLiteral("category")).toString(); + a.title = o.value(QStringLiteral("title")).toString(); + a.description = o.value(QStringLiteral("description")).toString(); + a.icon = o.value(QStringLiteral("icon")).toString(); + a.tier = o.value(QStringLiteral("tier")).toString(); + a.xp = o.value(QStringLiteral("xp")).toInt(); + a.hidden = o.value(QStringLiteral("hidden")).toBool(); + a.earnedAt = static_cast(o.value(QStringLiteral("earnedAt")).toDouble()); + return a; +} + +QVariantMap Achievement::toVariantMap() const +{ + QVariantMap m; + m.insert(QStringLiteral("key"), key); + m.insert(QStringLiteral("category"), category); + m.insert(QStringLiteral("title"), title); + m.insert(QStringLiteral("description"), description); + m.insert(QStringLiteral("icon"), icon); + m.insert(QStringLiteral("tier"), tier); + m.insert(QStringLiteral("xp"), xp); + m.insert(QStringLiteral("hidden"), hidden); + m.insert(QStringLiteral("earnedAt"), earnedAt); + return m; +} + +const QList& milestoneCatalog() +{ + // Mirrors qtmesh-cloud SEED_ACHIEVEMENTS counters + streak thresholds so + // the editor can render "nearest unlockable" progress bars offline. + static const QList catalog = { + {QStringLiteral("discovery_10"), QStringLiteral("Explorer"), + QStringLiteral("Try 10 different tools"), + QStringLiteral("features_discovered"), 10, false}, + {QStringLiteral("discovery_all"), QStringLiteral("Cartographer"), + QStringLiteral("Try every tool"), + QStringLiteral("features_discovered"), + static_cast(featureCatalog().size()), true}, + {QStringLiteral("optimized_1m_tris"), QStringLiteral("Triangle Slayer"), + QStringLiteral("Optimize 1,000,000 triangles total"), + QStringLiteral("tris_optimized"), 1000000, false}, + {QStringLiteral("rigged_10_meshes"), QStringLiteral("Master Rigger"), + QStringLiteral("Rig 10 meshes"), + QStringLiteral("meshes_rigged"), 10, false}, + {QStringLiteral("fixed_100_issues"), QStringLiteral("Fixer"), + QStringLiteral("Fix 100 scan issues"), + QStringLiteral("issues_fixed"), 100, false}, + {QStringLiteral("streak_3"), QStringLiteral("Warming Up"), + QStringLiteral("3-day scan streak"), + QStringLiteral("current_streak"), 3, false}, + {QStringLiteral("streak_7"), QStringLiteral("Consistent"), + QStringLiteral("7-day scan streak"), + QStringLiteral("current_streak"), 7, false}, + {QStringLiteral("streak_30"), QStringLiteral("Committed"), + QStringLiteral("30-day scan streak"), + QStringLiteral("current_streak"), 30, false}, + {QStringLiteral("streak_100"), QStringLiteral("Relentless"), + QStringLiteral("100-day scan streak"), + QStringLiteral("current_streak"), 100, false}, + }; + return catalog; +} + +QVariantMap NextUnlockable::toVariantMap() const +{ + QVariantMap m; + m.insert(QStringLiteral("key"), key); + m.insert(QStringLiteral("title"), title); + m.insert(QStringLiteral("description"), description); + m.insert(QStringLiteral("current"), current); + m.insert(QStringLiteral("threshold"), threshold); + m.insert(QStringLiteral("fraction"), fraction); + return m; +} + +StatsSnapshot StatsSnapshot::fromJson(const QJsonObject& root) +{ + StatsSnapshot s; + if (root.isEmpty()) + return s; + + const QJsonObject stats = root.value(QStringLiteral("stats")).toObject(); + s.xp = static_cast(stats.value(QStringLiteral("xp")).toDouble()); + s.level = qMax(1, stats.value(QStringLiteral("level")).toInt(1)); + s.currentStreak = stats.value(QStringLiteral("current_streak")).toInt(); + s.longestStreak = stats.value(QStringLiteral("longest_streak")).toInt(); + s.lastActiveDay = stats.value(QStringLiteral("last_active_day")).toString(); + + const QJsonObject progress = root.value(QStringLiteral("progress")).toObject(); + s.levelFloorXp = static_cast(progress.value(QStringLiteral("levelFloorXp")).toDouble()); + s.nextLevelXp = static_cast(progress.value(QStringLiteral("nextLevelXp")).toDouble(100)); + s.intoLevel = static_cast(progress.value(QStringLiteral("intoLevel")).toDouble()); + s.span = static_cast(progress.value(QStringLiteral("span")).toDouble(100)); + s.fraction = progress.value(QStringLiteral("fraction")).toDouble(); + + for (const QJsonValue& v : root.value(QStringLiteral("achievements")).toArray()) + s.achievements.append(Achievement::fromJson(v.toObject())); + for (const QJsonValue& v : root.value(QStringLiteral("recentlyEarned")).toArray()) + s.recentlyEarned.append(Achievement::fromJson(v.toObject())); + + const QJsonObject counters = root.value(QStringLiteral("counters")).toObject(); + for (auto it = counters.constBegin(); it != counters.constEnd(); ++it) + s.counters.insert(it.key(), static_cast(it.value().toDouble())); + + for (const QJsonValue& v : root.value(QStringLiteral("featureUsage")).toArray()) { + const QJsonObject row = v.toObject(); + const QString key = row.value(QStringLiteral("feature_key")).toString(); + if (!key.isEmpty()) + s.featureUseCounts.insert(key, row.value(QStringLiteral("use_count")).toInt(1)); + } + + s.valid = true; + return s; +} + +QJsonObject StatsSnapshot::toJson() const +{ + QJsonObject stats; + stats.insert(QStringLiteral("xp"), static_cast(xp)); + stats.insert(QStringLiteral("level"), level); + stats.insert(QStringLiteral("current_streak"), currentStreak); + stats.insert(QStringLiteral("longest_streak"), longestStreak); + stats.insert(QStringLiteral("last_active_day"), lastActiveDay); + + QJsonObject progress; + progress.insert(QStringLiteral("levelFloorXp"), static_cast(levelFloorXp)); + progress.insert(QStringLiteral("nextLevelXp"), static_cast(nextLevelXp)); + progress.insert(QStringLiteral("intoLevel"), static_cast(intoLevel)); + progress.insert(QStringLiteral("span"), static_cast(span)); + progress.insert(QStringLiteral("fraction"), fraction); + + auto achievementJson = [](const Achievement& a) { + QJsonObject o; + o.insert(QStringLiteral("key"), a.key); + o.insert(QStringLiteral("category"), a.category); + o.insert(QStringLiteral("title"), a.title); + o.insert(QStringLiteral("description"), a.description); + o.insert(QStringLiteral("icon"), a.icon); + o.insert(QStringLiteral("tier"), a.tier); + o.insert(QStringLiteral("xp"), a.xp); + o.insert(QStringLiteral("hidden"), a.hidden); + o.insert(QStringLiteral("earnedAt"), static_cast(a.earnedAt)); + return o; + }; + + QJsonArray achievementsArr; + for (const Achievement& a : achievements) + achievementsArr.append(achievementJson(a)); + QJsonArray recentArr; + for (const Achievement& a : recentlyEarned) + recentArr.append(achievementJson(a)); + + QJsonObject countersObj; + for (auto it = counters.constBegin(); it != counters.constEnd(); ++it) + countersObj.insert(it.key(), static_cast(it.value())); + + QJsonArray usageArr; + for (auto it = featureUseCounts.constBegin(); it != featureUseCounts.constEnd(); ++it) { + QJsonObject row; + row.insert(QStringLiteral("feature_key"), it.key()); + row.insert(QStringLiteral("use_count"), it.value()); + usageArr.append(row); + } + + QJsonObject root; + root.insert(QStringLiteral("stats"), stats); + root.insert(QStringLiteral("progress"), progress); + root.insert(QStringLiteral("achievements"), achievementsArr); + root.insert(QStringLiteral("recentlyEarned"), recentArr); + root.insert(QStringLiteral("counters"), countersObj); + root.insert(QStringLiteral("featureUsage"), usageArr); + return root; +} + +bool StatsSnapshot::hasEarned(const QString& achievementKey) const +{ + return std::any_of(achievements.cbegin(), achievements.cend(), + [&](const Achievement& a) { return a.key == achievementKey; }); +} + +QList StatsSnapshot::nextUnlockables(int maxCount) const +{ + QList out; + for (const MilestoneInfo& m : milestoneCatalog()) { + if (m.hidden || hasEarned(m.key)) + continue; + NextUnlockable u; + u.key = m.key; + u.title = m.title; + u.description = m.description; + u.threshold = m.threshold; + if (m.counter == QStringLiteral("current_streak")) + u.current = currentStreak; + else + u.current = counters.value(m.counter, 0); + u.current = qBound(0, u.current, m.threshold); + u.fraction = m.threshold > 0 ? static_cast(u.current) / m.threshold : 0.0; + out.append(u); + } + std::stable_sort(out.begin(), out.end(), + [](const NextUnlockable& a, const NextUnlockable& b) { + return a.fraction > b.fraction; + }); + if (maxCount >= 0 && out.size() > maxCount) + out = out.mid(0, maxCount); + return out; +} + +QStringList StatsSnapshot::unusedFeatureKeys() const +{ + QStringList out; + for (const FeatureInfo& f : featureCatalog()) { + if (!featureUseCounts.contains(f.key)) + out.append(f.key); + } + return out; +} + +} // namespace Gamification diff --git a/src/GamificationTypes.h b/src/GamificationTypes.h new file mode 100644 index 000000000..8e99f133e --- /dev/null +++ b/src/GamificationTypes.h @@ -0,0 +1,117 @@ +#ifndef GAMIFICATION_TYPES_H +#define GAMIFICATION_TYPES_H + +#include +#include +#include +#include +#include +#include + +/// Pure-data types for the QtMesh Cloud gamification contract (#796). +/// Mirrors the cloud side (qtmesh-cloud `src/gamification.ts`): the 24 +/// discovery feature-cluster keys, the client-visible milestone catalog used +/// to compute "nearest unlockable" locally, and the parsed `GET /v1/me/stats` +/// snapshot. No Ogre / no network — unit-testable. +namespace Gamification { + +/// One editor feature cluster (exact `feature` key for POST /v1/events/editor). +struct FeatureInfo { + QString key; ///< server key, [a-z0-9_]+ (e.g. "retopo") + QString title; ///< editor-facing name for nudges ("Quad Retopology") + QString blurb; ///< one-line "try this" description +}; + +/// All 24 discovery clusters, in the cloud catalog order. +const QList& featureCatalog(); + +/// Catalog entry for @p key, or nullptr when unknown. +const FeatureInfo* featureInfo(const QString& key); + +/// True when @p key is a valid server-side feature/op key ([a-z0-9_]+, <=64). +bool isValidEventKey(const QString& key); + +/// An achievement as serialized by the cloud (`serializeAchievement`). +struct Achievement { + QString key; + QString category; ///< streak | quality | discovery | milestone + QString title; + QString description; + QString icon; + QString tier; ///< bronze | silver | gold | platinum + int xp = 0; + bool hidden = false; + qint64 earnedAt = 0; ///< epoch ms; 0 when not earned / not present + + static Achievement fromJson(const QJsonObject& o); + QVariantMap toVariantMap() const; +}; + +/// Client-side mirror of a counter-driven (or streak) milestone so the editor +/// can compute progress toward locked achievements without a catalog endpoint. +struct MilestoneInfo { + QString key; + QString title; + QString description; + QString counter; ///< user counter name, or "current_streak" for streaks + qint64 threshold = 0; + bool hidden = false; +}; + +/// Non-discovery milestones the editor knows how to show progress for. +const QList& milestoneCatalog(); + +/// A locked achievement with locally computed progress (for E-P4). +struct NextUnlockable { + QString key; + QString title; + QString description; + qint64 current = 0; + qint64 threshold = 0; + double fraction = 0.0; ///< 0..1 + + QVariantMap toVariantMap() const; +}; + +/// Parsed `GET /v1/me/stats` payload. NB the wire mixes snake_case (`stats`, +/// `featureUsage`) and camelCase (`progress`, `achievements`) — this struct +/// normalizes all of it. +struct StatsSnapshot { + bool valid = false; + + // stats (snake_case on the wire) + qint64 xp = 0; + int level = 1; + int currentStreak = 0; + int longestStreak = 0; + QString lastActiveDay; ///< "YYYY-MM-DD" UTC or empty + + // progress (camelCase on the wire) + qint64 levelFloorXp = 0; + qint64 nextLevelXp = 100; + qint64 intoLevel = 0; + qint64 span = 100; + double fraction = 0.0; + + QList achievements; ///< earned, includes hidden + QList recentlyEarned; ///< first 5 + QHash counters; ///< counter name -> total + QHash featureUseCounts; ///< feature_key -> use_count + + static StatsSnapshot fromJson(const QJsonObject& root); + /// Round-trip for the on-disk cache (offline last-known rendering). + QJsonObject toJson() const; + + bool hasEarned(const QString& achievementKey) const; + + /// Locked, non-hidden milestones with progress, best-first (E-P4). + QList nextUnlockables(int maxCount = 2) const; + + /// Feature cluster keys the user has never used (E-P5 nudge candidates), + /// in catalog order. + QStringList unusedFeatureKeys() const; +}; + +} // namespace Gamification + +#endif // GAMIFICATION_TYPES_H diff --git a/src/GamificationTypes_test.cpp b/src/GamificationTypes_test.cpp new file mode 100644 index 000000000..25356ac6e --- /dev/null +++ b/src/GamificationTypes_test.cpp @@ -0,0 +1,157 @@ +#include + +#include +#include +#include + +#include "GamificationTypes.h" + +using namespace Gamification; + +namespace { + +// Wire-shaped /v1/me/stats payload (NB: `stats`/`featureUsage` are +// snake_case, `progress`/`achievements` camelCase — matches qtmesh-cloud). +QJsonObject sampleStatsJson() +{ + const QByteArray raw = R"({ + "stats": { "xp": 820, "level": 3, "current_streak": 2, + "longest_streak": 5, "last_active_day": "2026-07-03", + "best_score": 100, "gate_green_streak": 7 }, + "progress": { "level": 3, "levelFloorXp": 400, "nextLevelXp": 900, + "intoLevel": 420, "span": 500, "fraction": 0.84 }, + "achievements": [ + { "key": "first_retopo", "earnedAt": 1783136400000, + "category": "discovery", "title": "Retopologist", + "description": "Ran quad retopology", "icon": "hexagon", + "tier": "bronze", "xp": 25 }, + { "key": "streak_3", "earnedAt": 1783136400001, + "category": "streak", "title": "Warming Up", + "description": "3-day scan streak", "icon": "flame", + "tier": "bronze", "xp": 20 } + ], + "featureUsage": [ + { "feature_key": "retopo", "first_used_at": 1783000000000, "use_count": 4 }, + { "feature_key": "uv_unwrap", "first_used_at": 1783000000001, "use_count": 1 } + ], + "counters": { "tris_optimized": 500000, "features_discovered": 2 }, + "recentlyEarned": [ + { "key": "first_retopo", "earnedAt": 1783136400000, + "category": "discovery", "title": "Retopologist", + "description": "Ran quad retopology", "icon": "hexagon", + "tier": "bronze", "xp": 25 } + ], + "recentOperations": [ + { "op": "retopo", "at": 1783000000000, + "metrics": { "tris_before": 42180, "tris_after": 8004 } } + ] + })"; + return QJsonDocument::fromJson(raw).object(); +} + +} // namespace + +TEST(GamificationTypes, FeatureCatalogMatchesCloudContract) +{ + const auto& catalog = featureCatalog(); + EXPECT_EQ(catalog.size(), 25); // qtmesh-cloud DISCOVERY_FEATURES count + for (const FeatureInfo& f : catalog) { + EXPECT_TRUE(isValidEventKey(f.key)) << f.key.toStdString(); + EXPECT_FALSE(f.title.isEmpty()); + EXPECT_FALSE(f.blurb.isEmpty()); + } + // Exact contract keys (cloud DISCOVERY_FEATURES) — spot-check ends. + EXPECT_EQ(catalog.first().key, QStringLiteral("retopo")); + EXPECT_EQ(catalog.last().key, QStringLiteral("lighting")); + EXPECT_NE(featureInfo(QStringLiteral("mcp_server")), nullptr); + EXPECT_NE(featureInfo(QStringLiteral("lighting")), nullptr); + EXPECT_EQ(featureInfo(QStringLiteral("nope")), nullptr); +} + +TEST(GamificationTypes, EventKeyValidation) +{ + EXPECT_TRUE(isValidEventKey(QStringLiteral("retopo"))); + EXPECT_TRUE(isValidEventKey(QStringLiteral("decimate_lod"))); + EXPECT_TRUE(isValidEventKey(QStringLiteral("a1_b2"))); + EXPECT_FALSE(isValidEventKey(QString())); + EXPECT_FALSE(isValidEventKey(QStringLiteral("Retopo"))); + EXPECT_FALSE(isValidEventKey(QStringLiteral("has space"))); + EXPECT_FALSE(isValidEventKey(QStringLiteral("dash-ed"))); + EXPECT_FALSE(isValidEventKey(QString(65, QLatin1Char('a')))); +} + +TEST(GamificationTypes, StatsSnapshotParsesWirePayload) +{ + const StatsSnapshot s = StatsSnapshot::fromJson(sampleStatsJson()); + ASSERT_TRUE(s.valid); + EXPECT_EQ(s.xp, 820); + EXPECT_EQ(s.level, 3); + EXPECT_EQ(s.currentStreak, 2); + EXPECT_EQ(s.longestStreak, 5); + EXPECT_EQ(s.lastActiveDay, QStringLiteral("2026-07-03")); + EXPECT_EQ(s.levelFloorXp, 400); + EXPECT_EQ(s.nextLevelXp, 900); + EXPECT_EQ(s.intoLevel, 420); + EXPECT_EQ(s.span, 500); + EXPECT_DOUBLE_EQ(s.fraction, 0.84); + ASSERT_EQ(s.achievements.size(), 2); + EXPECT_EQ(s.achievements.first().key, QStringLiteral("first_retopo")); + EXPECT_EQ(s.achievements.first().xp, 25); + EXPECT_EQ(s.achievements.first().earnedAt, 1783136400000LL); + EXPECT_TRUE(s.hasEarned(QStringLiteral("streak_3"))); + EXPECT_FALSE(s.hasEarned(QStringLiteral("streak_7"))); + EXPECT_EQ(s.featureUseCounts.value(QStringLiteral("retopo")), 4); + EXPECT_EQ(s.counters.value(QStringLiteral("tris_optimized")), 500000); + ASSERT_EQ(s.recentlyEarned.size(), 1); +} + +TEST(GamificationTypes, StatsSnapshotRoundTripsThroughCacheJson) +{ + const StatsSnapshot s = StatsSnapshot::fromJson(sampleStatsJson()); + const StatsSnapshot t = StatsSnapshot::fromJson(s.toJson()); + ASSERT_TRUE(t.valid); + EXPECT_EQ(t.xp, s.xp); + EXPECT_EQ(t.level, s.level); + EXPECT_EQ(t.currentStreak, s.currentStreak); + EXPECT_EQ(t.intoLevel, s.intoLevel); + EXPECT_EQ(t.span, s.span); + EXPECT_EQ(t.achievements.size(), s.achievements.size()); + EXPECT_EQ(t.featureUseCounts, s.featureUseCounts); + EXPECT_EQ(t.counters, s.counters); +} + +TEST(GamificationTypes, InvalidStatsJsonIsNotValid) +{ + EXPECT_FALSE(StatsSnapshot::fromJson(QJsonObject()).valid); +} + +TEST(GamificationTypes, NextUnlockablesSortedByProgressAndSkipEarnedOrHidden) +{ + const StatsSnapshot s = StatsSnapshot::fromJson(sampleStatsJson()); + const QList next = s.nextUnlockables(10); + ASSERT_FALSE(next.isEmpty()); + // streak_3 is earned → not listed; discovery_all is hidden → not listed. + for (const NextUnlockable& u : next) { + EXPECT_NE(u.key, QStringLiteral("streak_3")); + EXPECT_NE(u.key, QStringLiteral("discovery_all")); + } + // Sorted best-first. + for (int i = 1; i < next.size(); ++i) + EXPECT_GE(next.at(i - 1).fraction, next.at(i).fraction); + // streak_7 progress = 2/7; tris 500k/1M = 0.5 should rank above it. + EXPECT_EQ(next.first().key, QStringLiteral("optimized_1m_tris")); + EXPECT_EQ(next.first().current, 500000); + EXPECT_EQ(next.first().threshold, 1000000); + // Cap honored. + EXPECT_EQ(s.nextUnlockables(2).size(), 2); +} + +TEST(GamificationTypes, UnusedFeatureKeysExcludesUsed) +{ + const StatsSnapshot s = StatsSnapshot::fromJson(sampleStatsJson()); + const QStringList unused = s.unusedFeatureKeys(); + EXPECT_EQ(unused.size(), featureCatalog().size() - 2); + EXPECT_FALSE(unused.contains(QStringLiteral("retopo"))); + EXPECT_FALSE(unused.contains(QStringLiteral("uv_unwrap"))); + EXPECT_TRUE(unused.contains(QStringLiteral("auto_rig"))); +} diff --git a/src/HDR/HdrEnvironmentController.cpp b/src/HDR/HdrEnvironmentController.cpp index ec612ed03..88dbecbd9 100644 --- a/src/HDR/HdrEnvironmentController.cpp +++ b/src/HDR/HdrEnvironmentController.cpp @@ -1,5 +1,6 @@ #include "HDR/HdrEnvironmentController.h" +#include "GamificationManager.h" #include "HDR/HDREnvironmentManager.h" #include "HDR/HdrViewportController.h" #include "SentryReporter.h" @@ -304,6 +305,7 @@ bool HdrEnvironmentController::loadEnvironment(const QString& pathOrBundledName) SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), QStringLiteral("hdr.loadEnvironment=%1") .arg(QFileInfo(pathOrBundledName).fileName())); + GamificationManager::noteFeature(QStringLiteral("lighting")); } return ok; } diff --git a/src/ImageTo3D/MeshGenController.cpp b/src/ImageTo3D/MeshGenController.cpp index 0ad125c96..8c8a47c80 100644 --- a/src/ImageTo3D/MeshGenController.cpp +++ b/src/ImageTo3D/MeshGenController.cpp @@ -1,5 +1,7 @@ #include "MeshGenController.h" +#include "GamificationManager.h" + #include "MeshGenPredictor.h" #include "MeshGenBuilder.h" #include "BackgroundRemover.h" @@ -217,6 +219,8 @@ void MeshGenController::generate(const QString& imagePath, int resolution, const int textureSize = options.contains(QLatin1String("texture_size")) ? options.value(QLatin1String("texture_size")).toInt() : 1024; + GamificationManager::noteFeature(QStringLiteral("image_to_3d")); + // Mark busy BEFORE ensureModelBlocking() — it spins a nested QEventLoop for the // first-use download, during which the QML button would otherwise stay enabled // and could re-enter generate(), racing over m_pending. setBusy disables it. diff --git a/src/IsometricSpritesController.cpp b/src/IsometricSpritesController.cpp index 4d7f46a98..0da188cfb 100644 --- a/src/IsometricSpritesController.cpp +++ b/src/IsometricSpritesController.cpp @@ -1,5 +1,6 @@ #include "IsometricSpritesController.h" +#include "GamificationManager.h" #include "Manager.h" #include "ModelIsometricRenderer.h" #include "SelectionSet.h" @@ -276,6 +277,10 @@ QVariantMap IsometricSpritesController::exportSelected(const QString &outputPath result["sheetHeight"] = sheet.height(); result["directionOrder"] = ModelIsometricRenderer::directionOrderConvention(); result["error"] = QString(); + GamificationManager::noteOperation( + QStringLiteral("isometric_sprites"), + {{QStringLiteral("frames_rendered"), directions * frameCount}, + {QStringLiteral("directions"), directions}}); emit exportFinished(true, outputPath, QString()); return result; } diff --git a/src/LLMManager.cpp b/src/LLMManager.cpp index 5270809c6..e449f2f7f 100644 --- a/src/LLMManager.cpp +++ b/src/LLMManager.cpp @@ -1,4 +1,5 @@ #include "LLMManager.h" +#include "GamificationManager.h" #include #include #include @@ -476,6 +477,8 @@ void LLMManager::generateMaterial(const QString &prompt, const QString ¤tM return; } + GamificationManager::noteFeature(QStringLiteral("ai_assist")); + // LCOV_EXCL_START — requires a loaded LLM model // Store for potential retry m_pendingPrompt = prompt; diff --git a/src/LightsController.cpp b/src/LightsController.cpp index ac1d546b0..b3a43b34d 100644 --- a/src/LightsController.cpp +++ b/src/LightsController.cpp @@ -1,5 +1,6 @@ #include "LightsController.h" +#include "GamificationManager.h" #include "LightManager.h" #include "Manager.h" #include "SelectionSet.h" @@ -147,6 +148,7 @@ void LightsController::addLight(Ogre::Light::LightTypes type, bool atViewport) SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), QStringLiteral("Create light: %1") .arg(LightManager::defaultBaseNameForType(type))); + GamificationManager::noteFeature(QStringLiteral("lighting")); UndoManager::getSingleton()->push(new CreateLightCommand(LightSnapshot::fromHandle(handle))); selectLightHandle(handle.name); diff --git a/src/MCPServer.cpp b/src/MCPServer.cpp index 59bd4d89e..16482f486 100644 --- a/src/MCPServer.cpp +++ b/src/MCPServer.cpp @@ -1,5 +1,6 @@ #include "MCPServer.h" #include "mainwindow.h" +#include "GamificationManager.h" #include "Manager.h" #include "MaterialEditorQML.h" #include "MaterialPresetLibrary.h" @@ -744,6 +745,46 @@ QJsonObject MCPServer::callTool(const QString &name, const QJsonObject &args) if (toolResult.contains("isError") && toolResult["isError"].toBool()) { SentryReporter::addBreadcrumb("ai.tool_call", QStringLiteral("Tool error: %1").arg(name), "error"); + } else { + // Gamification discovery (#798): first successful MCP tool call marks + // the mcp_server cluster; tools with a mapped editor cluster also + // count toward that cluster's discovery (deduped per session). + GamificationManager::noteFeature(QStringLiteral("mcp_server"), + GamificationManager::Surface::Mcp); + static const QHash toolFeatureMap = { + {QStringLiteral("retopologize"), QStringLiteral("retopo")}, + {QStringLiteral("decimate_mesh"), QStringLiteral("decimate_lod")}, + {QStringLiteral("generate_lods"), QStringLiteral("decimate_lod")}, + {QStringLiteral("optimize_mesh"), QStringLiteral("decimate_lod")}, + {QStringLiteral("auto_uv_unwrap"), QStringLiteral("uv_unwrap")}, + {QStringLiteral("uv_unwrap_selection"), QStringLiteral("uv_unwrap")}, + {QStringLiteral("uv_project"), QStringLiteral("uv_unwrap")}, + {QStringLiteral("uv_set_seams"), QStringLiteral("uv_unwrap")}, + {QStringLiteral("compute_skin_weights"), QStringLiteral("skin_weights")}, + {QStringLiteral("auto_rig"), QStringLiteral("auto_rig")}, + {QStringLiteral("motion_in_between"), QStringLiteral("motion_inbetween")}, + {QStringLiteral("generate_motion"), QStringLiteral("animation_blend")}, + {QStringLiteral("merge_animations"), QStringLiteral("animation_blend")}, + {QStringLiteral("segment_mesh"), QStringLiteral("ai_assist")}, + {QStringLiteral("generate_mesh_from_image"), QStringLiteral("image_to_3d")}, + {QStringLiteral("generate_pbr_maps"), QStringLiteral("pbr_synth")}, + {QStringLiteral("upscale_texture"), QStringLiteral("pbr_synth")}, + {QStringLiteral("generate_normal_map"), QStringLiteral("pbr_synth")}, + {QStringLiteral("pack_textures"), QStringLiteral("texture_atlas")}, + {QStringLiteral("pack_atlas"), QStringLiteral("texture_atlas")}, + {QStringLiteral("apply_atlas"), QStringLiteral("texture_atlas")}, + {QStringLiteral("generate_isometric_sprites"), QStringLiteral("isometric_sprites")}, + {QStringLiteral("bake_vat"), QStringLiteral("vat_bake")}, + {QStringLiteral("list_morph_targets"), QStringLiteral("morph")}, + {QStringLiteral("describe_material"), QStringLiteral("material_editor")}, + {QStringLiteral("apply_material_preset"), QStringLiteral("material_editor")}, + {QStringLiteral("create_material"), QStringLiteral("material_editor")}, + {QStringLiteral("generate_mesh_texture"), QStringLiteral("stable_diffusion")}, + {QStringLiteral("cloud_upload"), QStringLiteral("cloud_upload")}, + }; + const QString feature = toolFeatureMap.value(name); + if (!feature.isEmpty()) + GamificationManager::noteFeature(feature, GamificationManager::Surface::Mcp); } if (txn) SentryReporter::finishTransaction(txn); diff --git a/src/MaterialEditorQML.cpp b/src/MaterialEditorQML.cpp index 13a57142a..74765bad3 100644 --- a/src/MaterialEditorQML.cpp +++ b/src/MaterialEditorQML.cpp @@ -1,4 +1,5 @@ #include "MaterialEditorQML.h" +#include "GamificationManager.h" #include "HDR/HdrMaterialScript.h" #include "MaterialPreviewRenderer.h" #include "Manager.h" @@ -353,6 +354,7 @@ static void wirePbrSlotsForFFP(Ogre::Material* mat) bool MaterialEditorQML::applyMaterial() { SentryReporter::addBreadcrumb("ui.material", "Apply material"); + GamificationManager::noteFeature(QStringLiteral("material_editor")); // Safety check for Ogre availability if (!isOgreAvailable()) { const QString scriptForOgre = HdrMaterialScript::stripEnvironmentLines(m_materialText); @@ -3558,6 +3560,8 @@ QString MaterialEditorQML::packTextureChannels(const QString& redPath, spec.includeAlpha = includeAlpha; auto r = TextureChannelPacker::packToFile(spec, outputPath); + if (r.ok) + GamificationManager::noteFeature(QStringLiteral("texture_atlas")); return r.ok ? QString() : r.error; } @@ -3626,6 +3630,9 @@ QString MaterialEditorQML::generateNormalMap(const QString& sourcePath, spec.invertG = invertG; auto r = NormalMapGenerator::generateToFile(spec, outputPath); + if (r.ok) + GamificationManager::noteOperation(QStringLiteral("pbr_synth"), + {{QStringLiteral("maps_generated"), 1}}); return r.ok ? QString() : r.error; } @@ -3735,6 +3742,10 @@ QString MaterialEditorQML::packAtlas(const QStringList& sourcePaths, if (!r.ok) return r.error; SentryReporter::addBreadcrumb("file.export", QString("Atlas %1 tiles -> %2").arg(r.tiles.size()).arg(QFileInfo(outputPath).fileName())); + GamificationManager::noteOperation( + QStringLiteral("texture_atlas"), + {{QStringLiteral("textures_packed"), static_cast(r.tiles.size())}, + {QStringLiteral("atlas_size"), atlasWidth}}); if (!manifestPath.isEmpty()) { const QString json = TextureAtlasPacker::manifestToJson(r, spec.padding); diff --git a/src/MaterialPresetLibrary.cpp b/src/MaterialPresetLibrary.cpp index 9cf4dddae..56f872ace 100644 --- a/src/MaterialPresetLibrary.cpp +++ b/src/MaterialPresetLibrary.cpp @@ -1,4 +1,5 @@ #include "MaterialPresetLibrary.h" +#include "GamificationManager.h" #include "HDR/HDREnvironmentManager.h" #include "Manager.h" #include "RTShaderHelper.h" @@ -241,6 +242,8 @@ void MaterialPresetLibrary::applyPreset(const QString& name) { auto* sel = SelectionSet::getSingleton(); + GamificationManager::noteFeature(QStringLiteral("material_editor")); + const bool isHdrPreset = isHdrPresetName(name); if (isHdrPreset) { SentryReporter::addBreadcrumb(QStringLiteral("render.hdr.preset"), name); diff --git a/src/MeshDecimatorController.cpp b/src/MeshDecimatorController.cpp index 36dc2fcba..3085032d1 100644 --- a/src/MeshDecimatorController.cpp +++ b/src/MeshDecimatorController.cpp @@ -1,4 +1,5 @@ #include "MeshDecimatorController.h" +#include "GamificationManager.h" #include "MeshDecimator.h" #include "MeshOptimizerLod.h" #include "Manager.h" @@ -256,6 +257,10 @@ void MeshDecimatorController::applyReductionWithAlgo(double reduction, const QSt refreshBaseline(); emit previewChanged(); emit baseChanged(); + GamificationManager::noteOperation( + QStringLiteral("decimate_lod"), + {{QStringLiteral("tris_before"), report.totalTrianglesBefore}, + {QStringLiteral("tris_after"), report.totalTrianglesAfter}}); emit applied(report.totalTrianglesBefore, report.totalTrianglesAfter); } // LCOV_EXCL_STOP diff --git a/src/MeshLodController.cpp b/src/MeshLodController.cpp index 65899c032..26c2a8ae9 100644 --- a/src/MeshLodController.cpp +++ b/src/MeshLodController.cpp @@ -1,4 +1,5 @@ #include "MeshLodController.h" +#include "GamificationManager.h" #include "Manager.h" #include "SelectionSet.h" #include "MeshImporterExporter.h" @@ -274,6 +275,10 @@ void MeshLodController::generateLods(int count, const QVariantList& reductions, } } + GamificationManager::noteOperation( + QStringLiteral("decimate_lod"), + {{QStringLiteral("lod_levels_generated"), count}}); + emit lodChanged(); emit generationSucceeded(count); } diff --git a/src/MorphAnimationManager.cpp b/src/MorphAnimationManager.cpp index c9fd2cf58..3a1600c68 100644 --- a/src/MorphAnimationManager.cpp +++ b/src/MorphAnimationManager.cpp @@ -10,6 +10,8 @@ The MIT License #include "MorphAnimationManager.h" +#include "GamificationManager.h" + #include "EditModeController.h" #include "EditableMesh.h" #include "SelectionSet.h" @@ -261,6 +263,10 @@ bool MorphAnimationManager::addMorphTargetFromCurrentEdit(const QString& name) if (!undo) return false; undo->push(new AddMorphTargetCommand(entity, name, slices)); + GamificationManager::noteOperation( + QStringLiteral("morph"), + {{QStringLiteral("targets_count"), morphTargetsForSelection().size()}}); + emit morphTargetsChanged(); return true; } diff --git a/src/PoseLibrary.cpp b/src/PoseLibrary.cpp index 9ef97f395..20862b2ec 100644 --- a/src/PoseLibrary.cpp +++ b/src/PoseLibrary.cpp @@ -10,6 +10,8 @@ The MIT License #include "PoseLibrary.h" +#include "GamificationManager.h" + #include "SelectionSet.h" #include "SentryReporter.h" @@ -510,6 +512,7 @@ bool PoseLibrary::savePoseForSelection(const QString& name) if (!sel) return false; auto ents = sel->getResolvedEntities(); if (ents.isEmpty()) return false; + GamificationManager::noteFeature(QStringLiteral("pose_library")); return savePose(ents.first(), name); } diff --git a/src/QtMeshCloudClient.cpp b/src/QtMeshCloudClient.cpp index 056b629f9..a036424af 100644 --- a/src/QtMeshCloudClient.cpp +++ b/src/QtMeshCloudClient.cpp @@ -2011,3 +2011,252 @@ QtMeshCloudClient::FeedbackResult QtMeshCloudClient::submitFeedback(const QStrin QStringLiteral("warning")); return out; } + +// ---- Gamification (#796 / qtmesh-cloud#79) ---- + +namespace { + +/// Shared body for POST /v1/events/editor and /v1/events/operations — both +/// return `{accepted, newAchievements}` on 200 and the same error shapes. +QtMeshCloudClient::GamificationEventsResult postGamificationBatch( + const QString& bearerToken, + const QString& path, + const QString& arrayField, + const QJsonArray& items, + int timeoutMs) +{ + QtMeshCloudClient::GamificationEventsResult out; + if (bearerToken.isEmpty()) { + out.errorString = QStringLiteral("missing bearer token"); + return out; + } + if (items.isEmpty()) { + out.ok = true; + return out; + } + if (items.size() > QtMeshCloudClient::kGamificationMaxBatch) { + out.errorString = QStringLiteral("batch exceeds %1 events") + .arg(QtMeshCloudClient::kGamificationMaxBatch); + return out; + } + + const QUrl url(QtMeshCloudClient::apiBaseUrl() + path); + if (!url.isValid()) { + out.errorString = QStringLiteral("invalid API base URL"); + return out; + } + + QJsonObject body; + body.insert(arrayField, items); + const QByteArray payload = QJsonDocument(body).toJson(QJsonDocument::Compact); + + QNetworkAccessManager nam; + const QNetworkRequest req = authorizedJsonRequest(url, bearerToken, timeoutMs); + QNetworkReply* reply = nam.post(req, payload); + QEventLoop loop; + QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); + loop.exec(); + + out.httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + const QByteArray responseBody = reply->readAll(); + const auto nerr = reply->error(); + const QString transportErr = reply->errorString(); + reply->deleteLater(); + + out.responseBodySnippet = trimSnippet(responseBody); + + QJsonObject root; + QString parseError; + const bool parsed = parseJsonObjectBody(responseBody, root, parseError); + + if (nerr == QNetworkReply::NoError && out.httpStatus == 200 && parsed) { + out.ok = true; + out.accepted = root.value(QStringLiteral("accepted")).toInt(); + out.newAchievements = root.value(QStringLiteral("newAchievements")).toArray(); + return out; + } + + if (nerr != QNetworkReply::NoError) + out.errorString = transportErr; + else if (parsed && !jsonErrorCode(root).isEmpty()) + out.errorString = jsonErrorCode(root); + else + out.errorString = QStringLiteral("HTTP %1").arg(out.httpStatus); + return out; +} + +} // namespace + +QtMeshCloudClient::GamificationEventsResult QtMeshCloudClient::postEditorEvents( + const QString& bearerToken, const QJsonArray& events, int timeoutMs) +{ + return postGamificationBatch(bearerToken, QStringLiteral("/v1/events/editor"), + QStringLiteral("events"), events, timeoutMs); +} + +QtMeshCloudClient::GamificationEventsResult QtMeshCloudClient::postOperationEvents( + const QString& bearerToken, const QJsonArray& operations, int timeoutMs) +{ + return postGamificationBatch(bearerToken, QStringLiteral("/v1/events/operations"), + QStringLiteral("operations"), operations, timeoutMs); +} + +QtMeshCloudClient::GamificationStatsResult QtMeshCloudClient::fetchGamificationStats( + const QString& bearerToken, int timeoutMs) +{ + GamificationStatsResult out; + if (bearerToken.isEmpty()) { + out.errorString = QStringLiteral("missing bearer token"); + return out; + } + const QUrl url(apiBaseUrl() + QStringLiteral("/v1/me/stats")); + + QNetworkAccessManager nam; + const QNetworkRequest req = authorizedJsonRequest(url, bearerToken, timeoutMs); + QNetworkReply* reply = nam.get(req); + QEventLoop loop; + QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); + loop.exec(); + + out.httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + const QByteArray responseBody = reply->readAll(); + const auto nerr = reply->error(); + const QString transportErr = reply->errorString(); + reply->deleteLater(); + + out.responseBodySnippet = trimSnippet(responseBody); + + QJsonObject root; + QString parseError; + const bool parsed = parseJsonObjectBody(responseBody, root, parseError); + if (nerr == QNetworkReply::NoError && out.httpStatus == 200 && parsed) { + out.ok = true; + out.stats = root; + return out; + } + + if (nerr != QNetworkReply::NoError) + out.errorString = transportErr; + else if (parsed && !jsonErrorCode(root).isEmpty()) + out.errorString = jsonErrorCode(root); + else + out.errorString = QStringLiteral("HTTP %1").arg(out.httpStatus); + return out; +} + +namespace { + +QtMeshCloudClient::GamificationPrefsResult parseGamificationPrefsReply(QNetworkReply* reply) +{ + QtMeshCloudClient::GamificationPrefsResult out; + out.httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + const QByteArray responseBody = reply->readAll(); + const auto nerr = reply->error(); + const QString transportErr = reply->errorString(); + reply->deleteLater(); + + QJsonObject root; + QString parseError; + const bool parsed = parseJsonObjectBody(responseBody, root, parseError); + if (nerr == QNetworkReply::NoError && out.httpStatus == 200 && parsed) { + // Server returns the prefs either at the root or under `prefs`. + const QJsonObject prefs = root.contains(QStringLiteral("prefs")) + ? root.value(QStringLiteral("prefs")).toObject() + : root; + out.ok = true; + out.sync = prefs.value(QStringLiteral("sync")).toBool(true); + out.usage = prefs.value(QStringLiteral("usage")).toBool(true); + out.ops = prefs.value(QStringLiteral("ops")).toBool(true); + out.profilePublic = prefs.value(QStringLiteral("profilePublic")).toBool(false); + return out; + } + + if (nerr != QNetworkReply::NoError) + out.errorString = transportErr; + else if (parsed && !jsonErrorCode(root).isEmpty()) + out.errorString = jsonErrorCode(root); + else + out.errorString = QStringLiteral("HTTP %1").arg(out.httpStatus); + return out; +} + +} // namespace + +QtMeshCloudClient::GamificationPrefsResult QtMeshCloudClient::fetchGamificationPrefs( + const QString& bearerToken, int timeoutMs) +{ + GamificationPrefsResult out; + if (bearerToken.isEmpty()) { + out.errorString = QStringLiteral("missing bearer token"); + return out; + } + const QUrl url(apiBaseUrl() + QStringLiteral("/v1/me/gamification/prefs")); + + QNetworkAccessManager nam; + QNetworkReply* reply = nam.get(authorizedJsonRequest(url, bearerToken, timeoutMs)); + QEventLoop loop; + QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); + loop.exec(); + return parseGamificationPrefsReply(reply); +} + +QtMeshCloudClient::GamificationPrefsResult QtMeshCloudClient::setGamificationPrefs( + const QString& bearerToken, const QJsonObject& patch, int timeoutMs) +{ + GamificationPrefsResult out; + if (bearerToken.isEmpty()) { + out.errorString = QStringLiteral("missing bearer token"); + return out; + } + const QUrl url(apiBaseUrl() + QStringLiteral("/v1/me/gamification/prefs")); + const QByteArray payload = QJsonDocument(patch).toJson(QJsonDocument::Compact); + + QNetworkAccessManager nam; + QNetworkReply* reply = nam.put(authorizedJsonRequest(url, bearerToken, timeoutMs), payload); + QEventLoop loop; + QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); + loop.exec(); + return parseGamificationPrefsReply(reply); +} + +QtMeshCloudClient::UploadResult QtMeshCloudClient::deleteGamificationData( + const QString& bearerToken, int timeoutMs) +{ + UploadResult out; + if (bearerToken.isEmpty()) { + out.errorString = QStringLiteral("missing bearer token"); + return out; + } + const QUrl url(apiBaseUrl() + QStringLiteral("/v1/me/gamification")); + + QNetworkAccessManager nam; + QNetworkReply* reply = nam.deleteResource(authorizedJsonRequest(url, bearerToken, timeoutMs)); + QEventLoop loop; + QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); + loop.exec(); + + out.httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + const QByteArray responseBody = reply->readAll(); + const auto nerr = reply->error(); + const QString transportErr = reply->errorString(); + reply->deleteLater(); + + out.responseBodySnippet = trimSnippet(responseBody); + if (nerr == QNetworkReply::NoError && out.httpStatus == 200) { + out.ok = true; + return out; + } + out.errorString = nerr != QNetworkReply::NoError + ? transportErr + : QStringLiteral("HTTP %1").arg(out.httpStatus); + return out; +} + +QString QtMeshCloudClient::profileUrl(const QString& userSlug) +{ + const QString slug = userSlug.trimmed(); + if (slug.isEmpty()) + return {}; + return QStringLiteral("https://qtmesh.dev/u/%1") + .arg(QString::fromUtf8(QUrl::toPercentEncoding(slug))); +} diff --git a/src/QtMeshCloudClient.h b/src/QtMeshCloudClient.h index ff5a8683e..7e0de6857 100644 --- a/src/QtMeshCloudClient.h +++ b/src/QtMeshCloudClient.h @@ -3,6 +3,7 @@ #include #include +#include #include #include @@ -350,6 +351,75 @@ class QtMeshCloudClient { static FeedbackResult submitFeedback(const QString& bearerToken, const FeedbackSubmission& submission, int timeoutMs = 30000); + + // ---- Gamification (#796 / qtmesh-cloud#79) ---- + + struct GamificationEventsResult { + bool ok = false; + int httpStatus = 0; + QString errorString; + QString responseBodySnippet; + int accepted = 0; + /// Achievements newly earned by this batch (serialized cloud objects: + /// key/category/title/description/icon/tier/xp/hidden). + QJsonArray newAchievements; + }; + + /// Max events/operations per POST accepted by the cloud. + static constexpr int kGamificationMaxBatch = 200; + + /// POST /v1/events/editor — batched `feature.used` events. Each event: + /// {id, feature, at, surface}; `id` is the idempotency key. + static GamificationEventsResult postEditorEvents(const QString& bearerToken, + const QJsonArray& events, + int timeoutMs = 30000); + + /// POST /v1/events/operations — batched `operation.completed` events. + /// Each op: {id, op, at, surface, metrics, ownerSlug?, projectSlug?}; + /// `id` is REQUIRED (ops without one are dropped server-side). + static GamificationEventsResult postOperationEvents(const QString& bearerToken, + const QJsonArray& operations, + int timeoutMs = 30000); + + struct GamificationStatsResult { + bool ok = false; + int httpStatus = 0; + QString errorString; + QString responseBodySnippet; + QJsonObject stats; ///< full /v1/me/stats payload + }; + + /// GET /v1/me/stats — xp/level/streak/achievements/featureUsage/counters. + static GamificationStatsResult fetchGamificationStats(const QString& bearerToken, + int timeoutMs = 30000); + + struct GamificationPrefsResult { + bool ok = false; + int httpStatus = 0; + QString errorString; + bool sync = true; + bool usage = true; + bool ops = true; + bool profilePublic = false; + }; + + /// GET /v1/me/gamification/prefs. + static GamificationPrefsResult fetchGamificationPrefs(const QString& bearerToken, + int timeoutMs = 30000); + + /// PUT /v1/me/gamification/prefs — @p patch may carry any subset of + /// {sync, usage, ops, profilePublic} booleans. + static GamificationPrefsResult setGamificationPrefs(const QString& bearerToken, + const QJsonObject& patch, + int timeoutMs = 30000); + + /// DELETE /v1/me/gamification — purges server-side stats/achievements/ + /// operations for the user (E-P6 "delete my gamification data"). + static UploadResult deleteGamificationData(const QString& bearerToken, + int timeoutMs = 30000); + + /// Public achievement-wall URL for a user (https://qtmesh.dev/u/). + static QString profileUrl(const QString& userSlug); }; #endif diff --git a/src/QuadRetopoController.cpp b/src/QuadRetopoController.cpp index 389795c93..e37d8db5b 100644 --- a/src/QuadRetopoController.cpp +++ b/src/QuadRetopoController.cpp @@ -1,4 +1,5 @@ #include "QuadRetopoController.h" +#include "GamificationManager.h" #include "QuadRetopo.h" #include "SelectionSet.h" #include "SentryReporter.h" @@ -106,6 +107,13 @@ QVariantMap QuadRetopoController::retopologizeSelected(int targetFaces, result["quadDominance"] = report.quadDominance(); if (!report.error.isEmpty()) result["error"] = report.error; + if (report.applied) + GamificationManager::noteOperation( + QStringLiteral("retopo"), + {{QStringLiteral("tris_before"), report.totalTrianglesBefore}, + {QStringLiteral("tris_after"), report.totalTrianglesAfterRetopo}, + {QStringLiteral("quad_ratio_after"), report.quadDominance()}}); + if (report.applied) emit retopoApplied(result); else emit error(report.error.isEmpty() ? QStringLiteral("Quad retopology failed") diff --git a/src/SDManager.cpp b/src/SDManager.cpp index 54fbbf831..b7cba99fb 100644 --- a/src/SDManager.cpp +++ b/src/SDManager.cpp @@ -1,4 +1,5 @@ #include "SDManager.h" +#include "GamificationManager.h" #include #include #include @@ -399,6 +400,8 @@ void SDManager::generateTexture(const QString &prompt, int width, int height, co return; } + GamificationManager::noteFeature(QStringLiteral("stable_diffusion")); + // LCOV_EXCL_START — requires a loaded SD model // Override settings if width/height provided if (width > 0) { diff --git a/src/SkinWeightsController.cpp b/src/SkinWeightsController.cpp index be3028f53..b05c17488 100644 --- a/src/SkinWeightsController.cpp +++ b/src/SkinWeightsController.cpp @@ -1,4 +1,5 @@ #include "SkinWeightsController.h" +#include "GamificationManager.h" #include "SkinWeights.h" #include "SelectionSet.h" #include "SentryReporter.h" @@ -154,6 +155,10 @@ QVariantMap SkinWeightsController::computeWeightsForSelected(int maxInfluencesPe result["totalAssignmentsAfter"] = report.totalAssignmentsAfter; if (report.applied) { + GamificationManager::noteOperation( + QStringLiteral("skin_weights"), + {{QStringLiteral("verts_weighted"), report.totalVerticesProcessed}, + {QStringLiteral("max_influences"), maxInfluencesPerVertex}}); emit weightsApplied(result); } else { // Always populate `error` in the result map, not just when diff --git a/src/TexturePaintController.cpp b/src/TexturePaintController.cpp index cb4dcd3a9..b2f3cc412 100644 --- a/src/TexturePaintController.cpp +++ b/src/TexturePaintController.cpp @@ -1,6 +1,7 @@ #include "TexturePaintController.h" #include "EditModeController.h" +#include "GamificationManager.h" #include "EditableMesh.h" #include "Manager.h" #include "OgreWidget.h" @@ -1752,6 +1753,11 @@ int TexturePaintController::bakeVertexColorsToTexture(int resolution, QStringLiteral("Vertex→Texture bake: %1×%1 (%2 pixels, dilation=%3)") .arg(res).arg(painted).arg(opts.dilationPixels)); + GamificationManager::noteOperation( + QStringLiteral("vertex_color_bake"), + {{QStringLiteral("texture_size"), res}, + {QStringLiteral("pixels_painted"), painted}}); + refreshPreviewUri(); emit sessionChanged(); return painted; @@ -1869,6 +1875,7 @@ bool TexturePaintController::beginStrokeUV(double u, double v) if (!m_paintEnabled || m_strokeActive) return false; if (!hasActiveSession()) if (!ensurePaintableTexture(1024)) return false; + GamificationManager::noteFeature(QStringLiteral("texture_paint")); m_strokeActive = true; m_strokeJustBegan = true; m_smudgeHavePrev = false; diff --git a/src/UVEditorController.cpp b/src/UVEditorController.cpp index dead933c4..6db2a08d4 100644 --- a/src/UVEditorController.cpp +++ b/src/UVEditorController.cpp @@ -1,6 +1,7 @@ #include "UVEditorController.h" #include "EditableMesh.h" +#include "GamificationManager.h" #include "EditModeController.h" #include "HalfEdgeMesh.h" #include "EmbeddedTextureCache.h" @@ -2063,6 +2064,8 @@ void UVEditorController::unwrapSelectedFaces() if (!m_activeEntity || m_selectedUvFaces.isEmpty()) return; + GamificationManager::noteFeature(QStringLiteral("uv_unwrap")); + commitWorkingMeshUvs(); const auto before = m_workingMesh.subMeshes(); diff --git a/src/UvUnwrapController.cpp b/src/UvUnwrapController.cpp index bcb00d084..e88f67476 100644 --- a/src/UvUnwrapController.cpp +++ b/src/UvUnwrapController.cpp @@ -1,4 +1,5 @@ #include "UvUnwrapController.h" +#include "GamificationManager.h" #include "UvUnwrap.h" #include "SelectionSet.h" #include "SentryReporter.h" @@ -115,6 +116,13 @@ QVariantMap UvUnwrapController::unwrapSelectedToFile(const QString& outputPath, result["utilization"] = report.utilization; if (!report.error.isEmpty()) result["error"] = report.error; + if (report.applied) + GamificationManager::noteOperation( + QStringLiteral("uv_unwrap"), + {{QStringLiteral("uv_charts"), report.chartCount}, + {QStringLiteral("verts_before"), report.verticesBefore}, + {QStringLiteral("verts_after"), report.verticesAfter}}); + if (report.applied) emit unwrapApplied(result); else emit error(report.error.isEmpty() ? QStringLiteral("UV unwrap failed") : report.error); diff --git a/src/VATBakerController.cpp b/src/VATBakerController.cpp index 3d2622c0a..69abfe89d 100644 --- a/src/VATBakerController.cpp +++ b/src/VATBakerController.cpp @@ -10,6 +10,7 @@ The MIT License #include "VATBakerController.h" +#include "GamificationManager.h" #include "SelectionSet.h" #include "SentryReporter.h" #include "VATBaker.h" @@ -203,6 +204,12 @@ bool VATBakerController::bake(const QString& animationName, .arg(result.frameCount).arg(result.vertexCount).arg(result.posTexPath) : QStringLiteral("VAT bake failed: %1").arg(result.error)); + if (result.ok) + GamificationManager::noteOperation( + QStringLiteral("vat_bake"), + {{QStringLiteral("frames_baked"), result.frameCount}, + {QStringLiteral("verts_baked"), result.vertexCount}}); + emit bakeFinished(result.ok, result.posTexPath, result.error); return true; } diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 73e6cb792..c49dc951d 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -48,6 +48,8 @@ #include "AppConsoleLog.h" #include "AppSettingsKeys.h" #include "CloudAccountMenuButton.h" +#include "GamificationManager.h" +#include "GamificationToast.h" #include "CloudCredentialStore.h" #include "CloudDeepLink.h" #include "AppLaunchHandler.h" @@ -797,6 +799,14 @@ void MainWindow::initToolBar() [](QQmlEngine* engine, QJSEngine*) -> QObject* { return WelcomeScreenController::qmlInstance(engine, nullptr); }); + qmlRegisterSingletonType("WelcomeScreen", 1, 0, "GamificationManager", + [](QQmlEngine* engine, QJSEngine*) -> QObject* { + return GamificationManager::qmlInstance(engine, nullptr); + }); + qmlRegisterSingletonType("PropertiesPanel", 1, 0, "GamificationManager", + [](QQmlEngine* engine, QJSEngine*) -> QObject* { + return GamificationManager::qmlInstance(engine, nullptr); + }); qmlRegisterSingletonType("AssetBrowser", 1, 0, "AssetBrowserController", [](QQmlEngine* engine, QJSEngine*) -> QObject* { return AssetBrowserController::qmlInstance(engine, nullptr); @@ -2811,10 +2821,46 @@ void MainWindow::setupCloudAccountStatusControl() cloudAction->setObjectName(QStringLiteral("modeAnyCloudAccountAction")); updateCloudAuthActions(); updateCloudUploadActionState(); + + // Gamification (#796): one restrained toast on unlock, and the one-time + // non-blocking consent prompt the first time an event would be recorded. + auto* gamify = GamificationManager::instance(); + connect(gamify, &GamificationManager::achievementsUnlocked, this, + [this](const QVariantList& achievements) { + GamificationToast::showAchievements(this, achievements); + }); + connect(gamify, &GamificationManager::statsChanged, this, [this]() { + if (m_cloudAccountControl) + m_cloudAccountControl->refresh(); + }); + connect(gamify, &GamificationManager::consentPromptRequested, this, [this]() { + auto* prompt = new QMessageBox(this); + prompt->setAttribute(Qt::WA_DeleteOnClose); + prompt->setWindowModality(Qt::NonModal); + prompt->setIcon(QMessageBox::Question); + prompt->setWindowTitle(tr("Sync your QtMesh progress?")); + prompt->setText(tr("Track the tools you discover and your editing milestones " + "on your QtMesh Cloud profile?")); + prompt->setInformativeText(tr( + "Only feature names, counts and numeric before/after metrics are sent — " + "never your models, textures or file names. You can change this anytime " + "in Preferences.")); + QPushButton* enable = prompt->addButton(tr("Enable sync"), QMessageBox::AcceptRole); + prompt->addButton(tr("Not now"), QMessageBox::RejectRole); + connect(prompt, &QMessageBox::finished, this, [prompt, enable]() { + auto* gamify = GamificationManager::instance(); + if (prompt->clickedButton() == enable) + gamify->acceptConsent(); + else + gamify->declineConsent(); + }); + prompt->show(); + }); } void MainWindow::updateCloudAuthActions() { + GamificationManager::instance()->handleSessionChanged(); if (m_cloudAccountControl) m_cloudAccountControl->refresh(); updateCloudUploadActionState(); @@ -3300,6 +3346,7 @@ void MainWindow::startCloudPackageUpload(QtMeshCloudSession* session, m_cloudUploadProgress->finish(true, tr("Upload complete")); statusBar()->showMessage(tr("Uploaded to QtMesh Cloud."), 5000); + GamificationManager::noteFeature(QStringLiteral("cloud_upload")); QMessageBox done(this); if (!error.isEmpty()) { diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index e933245ab..6fc712e58 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -80,6 +80,10 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/../src/DependencyResolver.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/ProjectPackager.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/QtMeshCloudSession.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/GamificationTypes.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/GamificationEventQueue.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/GamificationManager.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/GamificationToast.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/CloudAccountMenuButton.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/FeedbackDiagnostics.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/FeedbackDialog.cpp @@ -317,6 +321,10 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/../src/CLIPipeline.h ${CMAKE_CURRENT_SOURCE_DIR}/../src/CloudDeepLink.h ${CMAKE_CURRENT_SOURCE_DIR}/../src/QtMeshCloudClient.h + ${CMAKE_CURRENT_SOURCE_DIR}/../src/GamificationTypes.h + ${CMAKE_CURRENT_SOURCE_DIR}/../src/GamificationEventQueue.h + ${CMAKE_CURRENT_SOURCE_DIR}/../src/GamificationManager.h + ${CMAKE_CURRENT_SOURCE_DIR}/../src/GamificationToast.h ${CMAKE_CURRENT_SOURCE_DIR}/../src/RTShaderHelper.h ${CMAKE_CURRENT_SOURCE_DIR}/../src/ViewCube/ViewCubeController.h ${CMAKE_CURRENT_SOURCE_DIR}/../src/UndoManager.h