A House Divided A House DividedDocumentation
Changelog
Game Design/Platform

Achievements Service

Last updated 2026-08-20
Source files

Overview#

The Achievements Service provides a comprehensive system for awarding, revoking, and tracking player achievements. Achievements are account-bound (keyed by userId) with optional character context for historical tracking.

Location: src/lib/achievements/

Key files:

Core Functions#

getAchievementBySlug(slug)#

Purpose: Fetch achievement definition by slug.

Returns: Achievement | null

Caching: Uses cached definitions when available.

export async function getAchievementBySlug(slug: string): Promise<Achievement | null> {
  const cached = getCachedDefinitions();
  if (cached) {
    const found = cached.find((a) => a.slug === slug);
    if (found) return found;
  }

  const db = await getDb();
  return await db.collection<Achievement>("achievements").findOne({ slug });
}

getAllAchievements()#

Purpose: Fetch all achievement definitions.

Returns: Achievement[] sorted by order field.

Caching: Caches full definition list after first fetch.

awardAchievement(userId, slug, characterId?, grantedBy?)#

Purpose: Award single achievement to account.

Returns: true if newly awarded, false if already had it or error.

Idempotent: Calling multiple times only awards once.

export async function awardAchievement(
  userId: ObjectId,
  achievementSlug: string,
  characterId?: ObjectId,
  grantedBy?: ObjectId
): Promise<boolean> {
  const achievement = await getAchievementBySlug(achievementSlug);
  if (!achievement) return false;

  const db = await getDb();

  // Check if already has it
  const existing = await db
    .collection<CharacterAchievement>("characterAchievements")
    .findOne({ userId, achievementId: achievement._id });
  if (existing) return false;

  // Award it
  await db.collection<CharacterAchievement>("characterAchievements").insertOne({
    _id: new ObjectId(),
    userId,
    characterId,
    achievementId: achievement._id,
    earnedAt: new Date(),
    grantedBy,
  });

  invalidateRarityCache();
  return true;
}

awardAchievements(userId, slugs, characterId?, grantedBy?)#

Purpose: Award multiple achievements in batch.

Returns: Number of newly awarded achievements.

Efficiency: Reduces N+1 queries:

export async function awardAchievements(
  userId: ObjectId,
  achievementSlugs: string[],
  characterId?: ObjectId,
  grantedBy?: ObjectId
): Promise<number> {
  if (achievementSlugs.length === 0) return 0;
  if (achievementSlugs.length === 1) {
    return (await awardAchievement(userId, achievementSlugs[0], characterId, grantedBy)) ? 1 : 0;
  }

  const db = await getDb();

  // 1. Resolve all definitions
  const cached = getCachedDefinitions();
  let achievements: Achievement[];
  if (cached) {
    achievements = cached.filter((a) => achievementSlugs.includes(a.slug));
  } else {
    achievements = await db
      .collection<Achievement>("achievements")
      .find({ slug: { $in: achievementSlugs } })
      .toArray();
  }
  if (achievements.length === 0) return 0;

  // 2. Check which ones account already has
  const achievementIds = achievements.map((a) => a._id);
  const existing = await db
    .collection<CharacterAchievement>("characterAchievements")
    .find({ userId, achievementId: { $in: achievementIds } }, { projection: { achievementId: 1 } })
    .toArray();
  const existingIds = new Set(existing.map((e) => e.achievementId.toString()));

  // 3. Insert only new ones
  const toInsert: CharacterAchievement[] = achievements
    .filter((a) => !existingIds.has(a._id.toString()))
    .map((a) => ({
      _id: new ObjectId(),
      userId,
      characterId,
      achievementId: a._id,
      earnedAt: new Date(),
      grantedBy,
    }));
  if (toInsert.length === 0) return 0;

  await db.collection<CharacterAchievement>("characterAchievements").insertMany(toInsert);
  invalidateRarityCache();
  return toInsert.length;
}

revokeAchievement(userId, slug)#

Purpose: Revoke achievement from account.

Returns: true if revoked, false if didn't have it or error.

export async function revokeAchievement(
  userId: ObjectId,
  achievementSlug: string
): Promise<boolean> {
  const achievement = await getAchievementBySlug(achievementSlug);
  if (!achievement) return false;

  const db = await getDb();
  const result = await db
    .collection<CharacterAchievement>("characterAchievements")
    .deleteOne({ userId, achievementId: achievement._id });

  if (result.deletedCount > 0) {
    invalidateRarityCache();
    return true;
  }
  return false;
}

getAccountAchievements(userId)#

Purpose: Get all achievements earned by account with details.

Returns: Array<{ achievement, earnedAt }> sorted by achievement order.

export async function getAccountAchievements(
  userId: ObjectId
): Promise<Array<{ achievement: Achievement; earnedAt: Date }>> {
  const db = await getDb();
  const cas = await db
    .collection<CharacterAchievement>("characterAchievements")
    .find({ userId })
    .toArray();

  const achievementIds = cas.map((ca) => ca.achievementId);
  if (achievementIds.length === 0) return [];

  const achievements = await db
    .collection<Achievement>("achievements")
    .find({ _id: { $in: achievementIds } })
    .toArray();

  const aMap = new Map(achievements.map((a) => [a._id.toString(), a]));
  return cas
    .map((ca) => {
      const a = aMap.get(ca.achievementId.toString());
      if (!a) return null;
      return { achievement: a, earnedAt: ca.earnedAt };
    })
    .filter((x): x is { achievement: Achievement; earnedAt: Date } => x !== null)
    .sort((x, y) => x.achievement.order - y.achievement.order);
}

getAchievementRarityMap()#

Purpose: Get rarity map: achievementId → % of accounts with it.

Denominator: Total accounts with completed setup (have characters).

Returns: Map<string, number> (percentage per achievement)

Caching: Caches rarity map; invalidated on award/revoke.

export async function getAchievementRarityMap(): Promise<Map<string, number>> {
  const cached = getCachedRarity();
  if (cached) return cached;

  const db = await getDb();
  const totalAccounts = await db.collection("users").countDocuments({ hasCompletedSetup: true });
  if (totalAccounts === 0) {
    const empty = new Map<string, number>();
    setCachedRarity(empty);
    return empty;
  }

  const counts = await db
    .collection<CharacterAchievement>("characterAchievements")
    .aggregate<{ _id: ObjectId; count: number }>([
      { $group: { _id: "$achievementId", count: { $sum: 1 } } },
    ])
    .toArray();

  const map = new Map<string, number>();
  for (const c of counts) {
    map.set(c._id.toString(), (c.count / totalAccounts) * 100);
  }
  setCachedRarity(map);
  return map;
}

resolveUserIdFromCharacter(characterId)#

Purpose: Resolve character ID to owning user ID.

Use case: Turn processing has characterId, needs userId for achievement award.

export async function resolveUserIdFromCharacter(characterId: ObjectId): Promise<ObjectId | null> {
  const db = await getDb();
  const character = await db
    .collection<{ _id: ObjectId; userId: ObjectId }>("characters")
    .findOne({ _id: characterId }, { projection: { userId: 1 } });
  return character?.userId ?? null;
}

Trigger System#

The trigger system automatically checks for achievement conditions when game events occur. All triggers are non-blocking - errors are logged and swallowed.

Action Achievements#

Trigger: checkActionAchievements(userId, characterId, actionType, currentTurn?)

Called from: Action handlers (fundraise, campaign, poll, etc.)

Achievements checked (per-actionType thresholds):

Action Achievement Threshold
fundraise first_fundraise 1
fundraise fundraiser 10
fundraise big_fundraiser 50
campaign campaigner 10
buildDonorBase grassroots 5
advertise advertiser 3
rest rested 1
poll/pollLarge pollster 5 total polls

Plus two checks that run on every call regardless of actionType:

Condition Achievement Threshold
Total actionLogs count for the character century_club 100
currentTurn argument passed and <= 1 turn_one 1 (first turn)
Server local hour is 0 (new Date().getHours() === 0) night_shift 1 (any action taken at midnight server time)

Passive Profile Achievements#

Trigger: checkPassiveProfileAchievements(userId, characterId, facts)

Called from: and , passing a facts object computed from current character/account state (not from action logs).

Achievements checked, one per fact:

Fact Achievement
iteration.type === "Iteration" and iteration.number === 1 iteration4_founder
hasCeoCorp corner_office
hasCabinetSeat cabinet_seat
hasCentralBankChair central_banker
bondIncomePerTurn > 0 bondholder
dividendIncomePerTurn > 0 dividend_day
characterCreatedAt is 30+ days old elder_statesman
statsAllocated built_different
onboardingComplete onboarded
hallOfFameTop10 hall_of_famer
hasElectedOffice && hasCabinetSeat && isPartyChair (all three) iron_triangle

Election Entry Achievements#

Trigger: checkElectionEntryAchievements(userId, characterId, election)

Called from: Election entry handlers

Achievements:

Election Win Achievements#

Trigger: checkElectionWinAchievements(userId, characterId, electionType)

Called from: Election resolution

Achievements:

Office Held Achievements#

Trigger: checkOfficeHeldAchievements(userId, characterId, officeType)

Called from: Office assumption

Achievements:

Influence Achievements#

Trigger: checkInfluenceAchievements(userId, characterId)

Called from: Influence action handlers

Achievements:

Subscriber Achievements#

Trigger: checkSubscriberAchievements(userId, characterId)

Called from: Subscription handlers

Achievements:

Funds Achievements#

Trigger: checkFundsAchievements(userId, characterId, funds)

Called from: Fund update handlers

Achievements:

Bill Achievements#

Trigger: checkBillSponsoredAchievements(userId, characterId)

Called from: Bill sponsorship

Achievements:

News Achievements#

Trigger: checkNewsPostAchievements(userId, characterId) and checkNewsReplyAchievements(userId, characterId)

Called from: News post creation

Achievements:

Progress Calculation#

Function: getAchievementProgress(db, character, achievement) in

Purpose: Compute { current, target } progress for an achievement the character hasn't earned yet, driven by the achievement definition's triggerType and triggerConfig fields rather than by re-running the trigger checks above. Returns null for unrecognized triggerType values or on error.

Supported triggerType values:

triggerType Source of current target from triggerConfig
action_count actionLogs count for the character, filtered by actionType or actionTypes count
election_won electedOfficials count for the character count
influence_count actionLogs count where actionType in ["supportPlayer", "attackPlayer", "barnstorm"] count
subscriber_count userSubscriptions count for the character count
funds_threshold character.funds amount
news_reply newsPosts count with non-null parentId for the character count (defaults to 5)

Caching System#

Definition Cache#

let cachedDefinitions: Achievement[] | null = null;

export function getCachedDefinitions(): Achievement[] | null {
  return cachedDefinitions;
}

export function setCachedDefinitions(definitions: Achievement[]): void {
  cachedDefinitions = definitions;
}

export function invalidateRarityCache(): void {
  cachedRarity = null; // Also invalidates rarity on definition changes
}

Rarity Cache#

let cachedRarity: Map<string, number> | null = null;

export function getCachedRarity(): Map<string, number> | null {
  return cachedRarity;
}

export function setCachedRarity(rarity: Map<string, number>): void {
  cachedRarity = rarity;
}

export function invalidateRarityCache(): void {
  cachedRarity = null;
}

Invalidation: Rarity cache is invalidated on every award/revoke to ensure accurate percentages.

Data Model#

Collections#

Collection Purpose
achievements Achievement definitions (slug, name, description, order)
characterAchievements Account-achievement links (userId, achievementId, characterId, earnedAt, grantedBy)

Document Types#

interface Achievement {
  _id: ObjectId;
  slug: string;
  name: string;
  description: string;
  icon?: string;
  order: number;
  createdAt: Date;
  updatedAt: Date;
}

interface CharacterAchievement {
  _id: ObjectId;
  userId: ObjectId; // Primary FK (account-bound)
  characterId?: ObjectId; // Optional: which character earned it
  achievementId: ObjectId;
  earnedAt: Date;
  grantedBy?: ObjectId; // Optional: admin who granted
  createdAt: Date;
  updatedAt: Date;
}

Usage Patterns#

In API Routes#

export async function POST(request: Request) {
  const auth = await requireAuth();
  if (!auth.ok) return auth.response;

  // ... perform action ...

  // Award achievement
  await awardAchievement(auth.user.userId, "first_action", auth.user.character?._id);

  return NextResponse.json({ success: true });
}

In Turn Processing#

// Turn processing has characterId, needs userId
const { resolveUserIdFromCharacter } = await import("@/lib/achievements");
const userId = await resolveUserIdFromCharacter(characterId);
if (userId) {
  await awardAchievement(userId, "election_winner", characterId);
}

Batch Awards#

// Efficient batch award (single lookup, single insert)
await awardAchievements(userId, ["first_win", "third_term", "millionaire"], characterId);

Error Handling#

All achievement functions are non-throwing:

try {
  await awardAchievement(userId, slug, characterId);
} catch (error) {
  console.error("[achievements] awardAchievement error:", error);
  return false;
}