docs/grouped-achievement-souvenirs.md
When several achievements are unlocked during the same gameplay moment, Hydra should create one souvenir using one captured frame and associate every achievement from that unlock batch with it.
Example card title:
Giant Slayer + 4 others
Hiding, deleting, or liking this card applies to the complete captured moment rather than to one achievement inside it.
This design is based on the launcher worktree and the API develop branch inspected on 2026-08-19.
The API does not currently have a separate souvenir entity. A souvenir is implicitly a GameAchievement with a non-null imageKey:
GameAchievement owns imageKey, souvenirVisibility, and likeCount.GameAchievementLike references one GameAchievement.GET /users/:id/souvenirs queries achievements with images and returns one feed item per achievement.gameId + achievementName.PUT /profile/games/achievements accepts an imageKey on each achievement.The launcher similarly identifies souvenirs using gameId + achievementName. Its regular achievement pipeline receives all newly detected achievements as an array, but captures and uploads one image for each achievement sequentially.
Assigning one imageKey to multiple existing GameAchievement rows would still create multiple cards, like counters, visibility settings, and like collections. Deleting any one of those achievements would queue the shared object for storage deletion and break the remaining cards.
A shared captured moment therefore needs to become a first-class entity.
An achievement can only be unlocked once and currently supports no more than one souvenir, so a nullable foreign key on GameAchievement is sufficient. A many-to-many join table is unnecessary unless Hydra later allows multiple souvenirs for the same achievement.
model Souvenir {
id Int @id @default(autoincrement())
gameId Int
clientId String
imageKey String @unique
capturedAt DateTime
visibility ProfileVisibility @default(PRIVATE)
likeCount Int @default(0)
rarestUnlockPercentage Float?
achievementOrder String[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
game Game @relation(fields: [gameId], references: [id], onDelete: Cascade)
achievements GameAchievement[]
likes SouvenirLike[]
@@unique([gameId, clientId])
@@index([gameId, capturedAt, id])
@@index([gameId, rarestUnlockPercentage, capturedAt, id])
}
model SouvenirLike {
id Int @id @default(autoincrement())
souvenirId Int
userId Int
createdAt DateTime @default(now())
souvenir Souvenir @relation(fields: [souvenirId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([souvenirId, userId])
@@index([userId])
}
/// No relations: cleanup metadata must survive hard game/account deletion until
/// an unclaimed object has been queued for deletion.
model SouvenirUpload {
id String @id @default(uuid())
userId Int
gameId Int
clientId String
imageKey String @unique
contentLength Int?
expiresAt DateTime
claimedAt DateTime?
deletedAt DateTime?
createdAt DateTime @default(now())
@@unique([gameId, clientId])
@@index([claimedAt, expiresAt])
@@index([userId])
}
model GameAchievement {
// Existing achievement fields...
souvenirId Int?
souvenir Souvenir? @relation(fields: [souvenirId], references: [id], onDelete: SetNull)
}
model Game {
// Existing game fields...
souvenirs Souvenir[]
}
model User {
// Existing user fields...
souvenirLikes SouvenirLike[]
}
clientId is generated by the launcher and provides idempotency. Retrying the same batch after a timeout must return or preserve the same souvenir rather than create a duplicate.
imageKey is also unique. Prefix ownership validation alone is insufficient because the same user could otherwise submit one owned key with two different client IDs. Deleting either souvenir would then delete the object used by the other. Before enabling the unique constraint, run a production preflight that reports duplicate and foreign legacy image keys and block the rollout until every conflict is repaired.
The association is immutable after creation:
souvenirId can be attached to a new souvenir.achievementOrder stores the associated achievements' canonical database names in the normalized input order. It provides a stable fallback when several achievements have identical unlock timestamps without adding ordered metadata to the child relation. Before directly deleting a souvenir, the service clears souvenirId on every associated achievement in the same transaction, then deletes the souvenir so the achievement rows remain intact.
Extend the existing achievement synchronization request with an optional souvenirs array:
{
"id": "encoded-game-id",
"achievements": [
{
"name": "ACH_FIRST",
"unlockTime": 1787054400000
},
{
"name": "ACH_SECOND",
"unlockTime": 1787054400000
}
],
"souvenirs": [
{
"clientId": "launcher-generated-uuid",
"imageKey": "achievements/42/image.jpeg",
"capturedAt": 1787054400000,
"achievementNames": ["ACH_FIRST", "ACH_SECOND"]
}
]
}
Using an array allows the launcher to retry several pending captured moments in one synchronization request.
The validation schema must reject empty souvenir batches and define bounded lengths for the array, clientId, imageKey, and achievementNames. Within one request, client IDs must be unique, achievement names must be unique case-insensitively within each souvenir, and one achievement cannot appear in two submitted souvenirs. New-launcher clientId values are UUIDs; deterministic legacy IDs used by the API migration are not accepted from clients. Invalid timestamps, duplicate names, souvenir-referenced names missing from the catalogue, and image keys outside the authenticated user's prefix are request errors rather than silently ignored input. Unreferenced legacy achievement entries retain the current catalogue-filtering behavior.
Before opening the database transaction, validate catalogue membership and user-key prefixes, load the upload reservations, and issue parallel R2 HeadObject checks for every unclaimed key. The object must exist and its content length must match the reservation before it can become a souvenir. A missing object returns a retryable upload-incomplete error so the launcher repeats the PUT; do not keep an external storage call open inside the database transaction.
Within the existing database transaction, the API should:
SouvenirUpload reservation against the user, game, client ID, image key, expiry, claim, and deletion state. It must be unclaimed and unexpired for a new souvenir; an idempotent retry may use the already-claimed matching reservation for the existing souvenir.(gameId, clientId) without overwriting stored fields during the create race.GameAchievement rows and persist their canonical names in achievementOrder.rarestUnlockPercentage to the lowest known percentage among the associated achievements, or null when all values are unknown.Souvenir synchronization is atomic with achievement synchronization. If one submitted souvenir conflicts or fails validation, the transaction rejects the entire request and acknowledges none of them. Partial success is not supported in the first implementation. Item-specific validation/conflict errors include the offending clientId; the launcher marks only that record terminal and retries the remaining unacknowledged records. For a request-level error without a client ID, retry records individually before classifying any of them as terminal.
When a request includes souvenirs, a successful response is always 200, including a retry where every achievement and souvenir was already stored:
{
"objectId": "620",
"shop": "steam",
"achievements": [
{
"name": "ACH_FIRST",
"unlockTime": 1787054400000,
"hardcoreUnlockTime": null
},
{
"name": "ACH_SECOND",
"unlockTime": 1787054400000,
"hardcoreUnlockTime": null
}
],
"souvenirs": [
{
"clientId": "launcher-generated-uuid",
"id": "encoded-souvenir-id"
}
]
}
achievements contains the canonical server achievement collection, matching the payload returned by the existing reconciliation 200 response. Requests without the new souvenirs field retain the existing 200/204 behavior for old launcher versions. The new launcher replaces its local canonical collection from achievements and clears only the client IDs present in the acknowledgement.
New clients should use the souvenirs collection. The existing per-achievement imageKey input can temporarily remain supported for older launchers through a compatibility adapter:
legacy:<gameAchievementId>, capturedAt equal to the achievement's unlock time, and achievementOrder containing its canonical stored name. Also create a matching claimed SouvenirUpload idempotency record for the legacy key.likeCount, and update both the souvenir and claimed upload-record key.Monitor use of the legacy adapter and do not remove it until launcher-version telemetry shows that unsupported clients are no longer active.
GET /users/:id/souvenirs should paginate Souvenir rows rather than GameAchievement rows. total must count captured moments.
{
"items": [
{
"id": "encoded-souvenir-id",
"imageUrl": "https://example.com/image.jpg",
"capturedAt": 1787054400000,
"visibility": "PUBLIC",
"primaryAchievementName": "ACH_SECOND",
"gameId": "b2c3d4e5",
"objectId": "620",
"shop": "steam",
"gameTitle": "Portal 2",
"gameIconUrl": "https://example.com/game.jpg",
"achievements": [
{
"name": "ACH_FIRST",
"displayName": "Giant Slayer",
"description": "Defeat the first boss.",
"achievementIcon": "https://example.com/achievement.jpg",
"unlockTime": 1787054400000,
"points": 45,
"isRare": false,
"isPlatinum": false
},
{
"name": "ACH_SECOND",
"displayName": "Speed Runner",
"description": "Complete the level quickly.",
"achievementIcon": "https://example.com/achievement-2.jpg",
"unlockTime": 1787054400000,
"points": 25,
"isRare": true,
"isPlatinum": false
}
],
"likeCount": 3,
"likedByMe": false
}
],
"total": 1,
"hiddenReason": null
}
primaryAchievementName is required and identifies one entry in achievements selected by the server rules below. visibility remains owner-only, matching the current response. For the transition period, the API also returns that primary achievement in the existing top-level achievement fields so older launcher versions continue displaying grouped souvenirs as a single-achievement card.
The first-class entity also changes two existing achievement reads that are separate from the souvenir feed:
UsersService.getUserAchievements should return at most one compatibility item per souvenir, using the primary achievement and the souvenir image.UsersService.getUnlockedAchievements should join through GameAchievement.souvenir. Each associated achievement may return the shared image URL when the souvenir and section are visible to the requester.These reads must be migrated before the legacy GameAchievement.imageKey and souvenirVisibility columns are removed.
recent uses Souvenir.capturedAt DESC, Souvenir.id DESC.oldest uses Souvenir.capturedAt ASC, Souvenir.id ASC.rare uses rarestUnlockPercentage ASC NULLS LAST, capturedAt DESC, id DESC so pagination remains database-driven and deterministic.The primary achievement displayed on the card should be selected consistently:
achievementOrder.The ordered achievements response follows achievementOrder, with achievement ID as a defensive fallback for inconsistent legacy data. The API must not depend on the implicit order of a Prisma relation.
When the game's unlocked count reaches the current catalogue total, determine the completing achievement by latest unlock time, then latest entry in achievementOrder within the same captured batch, then highest achievement ID. Only that achievement receives isPlatinum: true; a souvenir containing it selects it as primary. This makes a grouped final-unlock batch deterministic.
rarestUnlockPercentage is maintained data, not a write-once snapshot. Recompute it whenever associations are created and whenever an associated GameAchievement.globalUnlockPercentage changes. Update the existing rarity backfill script to refresh affected souvenirs as well. The value is the minimum known percentage and remains null only when every associated percentage is null; rare sorting places nulls last.
New actions should identify the captured moment directly:
POST /users/:ownerId/souvenirs/:souvenirId/like
PATCH /profile/souvenirs/:souvenirId/visibility
DELETE /profile/souvenirs/:souvenirId
Required related changes:
gameAchievementId to souvenirId.gameId:achievementName metadata key to souvenirId.achievementCount and optional primary achievement metadata in like notification variables.UsersService.During compatibility rollout, the old gameId + achievementName endpoints can resolve the achievement's souvenirId and mutate the entire souvenir.
Notification variables use the encoded souvenir ID. Idempotently migrate existing non-deleted souvenir-like notifications by resolving their legacy encoded gameId:achievementName key through the backfilled achievement association. Retain legacy fields for display compatibility, but aggregation lookup uses the new ID so the next like does not create a duplicate notification.
Every ID-based mutation must load the souvenir through its game and verify that game.userId is the authenticated owner. Preserve the current subscription/session rule: when the owner has no active cloud subscription, every associated achievement must belong to the current authenticated session before visibility or deletion can be changed. The public like route must retain the current active-subscription, profile-section visibility, per-souvenir visibility, blocked-user, and owner checks.
After the read cutover, visibility updates, like toggles, and deletion use the new souvenir tables as their source of truth. Compatibility routes delegate to the same service methods so behavior cannot diverge; only the temporary expand-phase deployment mirrors changes into legacy columns/tables. Souvenir deletion queues its unique image key, clears souvenirId on its achievements, marks the upload reservation deleted, and deletes the database row in one transaction; best-effort object processing runs only after commit.
Replace the current per-achievement capture map with a pending captured-moment record:
interface PendingSouvenir {
clientId: string;
ownerId: string;
remoteGameId: string;
gameKey: string;
screenshotPath: string;
imageKey?: string;
uploadedAt?: number;
capturedAt: number;
achievements: Array<{
name: string;
unlockTime: number;
hardcore?: boolean;
}>;
status: "pending" | "terminal";
attemptCount: number;
lastAttemptAt?: number;
lastErrorCode?: string;
}
interface LocalSouvenirAsset {
souvenirId: string;
clientId: string;
ownerId: string;
gameKey: string;
screenshotPath: string;
}
Store pending moments as a collection keyed by clientId, not as one value per game. Multiple watcher passes for the same game can be pending simultaneously without overwriting one another. The record contains the complete achievement synchronization data so it remains retryable after process restart without depending on an in-memory achievement cache. Derive the API's achievementNames from this stored array. ownerId is the encoded authenticated user ID and scopes retries to the account that captured and authorized the upload; never retry a record while a different user is authenticated.
The new upload authorization is idempotent as well. Extend the achievement-image presigned URL contract with remoteGameId, launcher clientId, and the JPEG byte length; verify that the authenticated user owns the game, and create a server-generated owned JPEG key such as achievements/<userId>/<gameId>/<uploadUuid>.jpeg. Creating a new reservation requires an active subscription, but refreshing the same authenticated user's existing reservation remains allowed if the subscription expires while a captured moment is pending. Repeating authorization for an active unclaimed reservation returns the same image key with a fresh presigned URL only when the content length matches; a mismatch is an idempotency conflict. A claimed reservation returns its existing key and claimed status without authorizing an overwrite. Rate-limit new reservations with a named RedisExpirationTime entry and cap outstanding unclaimed reservations per user; retries of an existing client ID do not consume another slot. The reservation expiry uses a named non-Redis constant exported from src/constants. The legacy random-key authorization remains available to old launchers.
The synchronization transaction claims the reservation when it creates the souvenir. A cleanup job locks expired reservations and, only while they are still unclaimed, queues their keys through the existing durable object-deletion queue and removes the reservation metadata in the same transaction. A concurrent claim or refresh therefore wins cleanly rather than deleting an active upload. A later authorization for the same client ID creates a fresh reservation with a different image key, so delayed deletion of the expired key cannot remove the new upload. Claimed reservations remain as idempotency records. Deleting their souvenir sets deletedAt instead of removing the reservation, and authorization/synchronization reject a deleted reservation so a delayed retry cannot resurrect a user-deleted card.
For regular games:
clientId.remoteGameId and clientId.imageKey before uploading bytes. If it differs from the stored key because an expired reservation was rotated, clear uploadedAt first.uploadedAt is absent, upload the image to that key and persist uploadedAt after the PUT succeeds. A repeated PUT during the same reservation may resend bytes but targets the same logical object.clientId, atomically move its local path into a LocalSouvenirAsset record keyed by encoded souvenir ID and remove the pending record.Capture or local persistence failure logs the souvenir error but does not block achievement notifications, local achievement state, or achievement-only synchronization. Once a pending record is durable, upload/API failures affect only its retry lifecycle and do not replay notifications.
The pending worker retries records for the current authenticated user with bounded exponential backoff:
Each retry refreshes or resolves the reservation before deciding whether to upload or synchronize. If the returned key matches and uploadedAt is set, retry only synchronization. If upload completion is unknown, repeat the PUT to the active key. If expiry cleanup rotated the reservation, persist the new key, clear uploadedAt, and upload again. Validation and idempotency-conflict responses set status to terminal: retain lastErrorCode, stop automatic retries, and log the client ID without deleting or reassigning server data. Network failures, timeouts, upload-incomplete responses, rate/reservation-capacity limits, rollout-disabled responses, and server errors remain retryable. Keep terminal records and their screenshots for a named diagnostic-retention period; afterward delete the local file and record. The API's expired-reservation cleanup independently handles any unclaimed remote object.
Screenshot cleanup must receive the set of pending screenshotPath values and exclude them from the 50-file retention limit. Acknowledged files become eligible for normal cleanup; when cleanup removes one, it also removes the corresponding LocalSouvenirAsset mapping. Deleting a souvenir locally looks up its encoded souvenir ID in that mapping and removes the recorded path when present. A souvenir captured on another device simply has no local asset. Local deletion never derives a path from an individual achievement name.
The first implementation defines one per-game watcher polling pass as one souvenir batch. The current Windows and Wine loops can call mergeAchievements separately for each changed achievement file, so split change detection/parsing from merging: collect and case-insensitively deduplicate every newly parsed achievement for a game, then invoke mergeAchievements once after all changed files in that pass are read. Guard the complete per-game pass so another poll cannot enter while capture, persistence, or merging is in flight. A short per-game coalescing window can be added later if testing finds games that split one logical burst across adjacent polling passes.
For emulator integrations:
Introduce a souvenir-specific type instead of representing feed entries as ProfileAchievement:
interface ProfileSouvenir {
id: string;
imageUrl: string | null;
capturedAt: number;
primaryAchievementName: string;
achievements: ProfileSouvenirAchievement[];
visibility?: ProfileVisibility;
gameId: string;
objectId: string;
shop: GameShop;
gameTitle: string | null;
gameIconUrl: string | null;
likeCount: number;
likedByMe: boolean;
}
Required UI changes:
souvenir.id for list keys, optimistic updates, carousel selection, loading states, likes, visibility, and deletion.+ {{count}} other/others label.Because one image represents the complete group, resetting any associated achievement deletes the complete souvenir before deleting the achievement. This preserves the rule that a souvenir is an indivisible captured moment and avoids leaving a grouped card whose captured context no longer matches its contents.
Resetting all achievements for a game must delete all of that game's souvenirs, queue each unique image key once, and remove associated likes through cascading relations.
Local screenshot deletion should use the souvenir clientId or stored local screenshot path rather than an achievement name.
Database cascades do not delete R2 objects. Every path that deletes games or achievements must first select the affected Souvenir.imageKey values and queue them in the same transaction. This includes:
Game, integration, and account deletion paths must also collect unclaimed SouvenirUpload.imageKey values. Once their object or user-prefix deletion is durably queued and the owning game/account is being removed, delete all corresponding reservation metadata because ownership validation prevents replay. Direct souvenir deletion keeps its claimed reservation and sets deletedAt because the game remains valid and a delayed retry must not recreate the card.
The launcher must cancel local pending batches before issuing a reset. Resetting one achievement cancels the complete pending batch containing it; resetting all achievements cancels every pending batch for that game. Remove the protected pending record before making its screenshot eligible for local cleanup so a retry cannot recreate progress after the user resets it. Any already-uploaded unclaimed object is removed later by reservation expiry cleanup.
Use the existing deletion helper's deduplication so a key is queued once even when several achievements or games are removed together. Add regression tests proving that a failed database transaction does not process an object deletion and a committed transaction remains recoverable through the durable queue.
Souvenir, SouvenirLike, SouvenirUpload, and nullable GameAchievement.souvenirId without removing legacy columns. Include the explicit game/user relations and cascade behavior from the proposed schema.GameAchievement with a non-null imageKey using:
clientId = legacy:<gameAchievementId>;capturedAt = GameAchievement.unlockTime;visibility = GameAchievement.souvenirVisibility;rarestUnlockPercentage = GameAchievement.globalUnlockPercentage; andachievementOrder = [GameAchievement.name].SouvenirUpload idempotency record with the same user, game, client ID, and image key, setting both claimedAt and expiresAt to the migration time and leaving the legacy-unknown contentLength null; expiry and length are ignored after claim.GameAchievementLike rows with conflict-safe inserts keyed by (souvenirId, userId).Souvenir.likeCount from migrated SouvenirLike rows. Report any difference from the legacy counter rather than carrying inconsistent denormalized state forward silently.gameId:achievementName key to the encoded backfilled souvenir ID, retaining old display variables during compatibility.The backfill and compatibility behavior must be idempotent and safe to rerun. Use deterministic client IDs plus conflict-safe inserts, and paginate by a stable achievement ID cursor rather than offset. A dry run reports intended inserts, associations, migrated likes, mismatches, and invalid rows; write mode must stop on an invariant violation rather than partially guessing a repair.
Keep the legacy columns during the complete compatibility window. The new tables are the source of truth after step 9; rolling the API back to code that only understands legacy rows is not safe once grouped souvenirs are accepted. If rollback is required before step 10, disable grouped writes and return a retryable service-unavailable response so new launchers retain pending batches. Disable the flag before any API rollback.
clientId does not duplicate the souvenir or likes.clientId with conflicting data is rejected.imageKey with a different clientId is rejected, including concurrent requests.200 and acknowledges every submitted clientId.achievementOrder order.rarestUnlockPercentage and rare sorting; all-null groups sort last.other/others labels correctly.primaryAchievementName, including platinum and multiple-rare-achievement cases.New souvenir rows and newly created users default to PRIVATE. Update the Prisma schema and database defaults for Souvenir.visibility, User.souvenirsVisibility, and the legacy GameAchievement.souvenirVisibility column retained during compatibility. The backfill preserves stored visibility values for existing souvenirs, and the migration does not silently rewrite an existing user's profile-wide choice. Any retroactive privacy change for existing users requires a separate, explicit product migration.