Valorant Career Mode is a single-player, client-only career simulation layered on top of the existing Next.js (./game) application. The player creates a Player_Character and steers a multi-season career through the VCT competitive ecosystem (Premier -> Academy -> Challengers -> Invite, or the Game Changers Challengers -> Championship path) while a simulated League_World of NPC teams and players evolves around them.
The feature is a management/statistical simulation: there is no real-time rendering of gameplay. A Season advances through a fixed calendar of Tournaments; Matches are resolved by a deterministic-given-a-seed, probability-weighted Match_Simulation_Engine that runs Round-by-Round as pure computation, not an animated loop. The engine occasionally pauses at Clutch_Decision_Points in High_Stakes_Matches to let the user make a tactical choice.
Because there is no backend, the entire Career_System — the League_World, the Player_Character, career history, and all economy state — lives in browser memory during a session and is checkpointed to localStorage by the Save_System. The top competitive tier (Invite and Game Changers Championship, plus optionally specific Challengers teams) can be seeded from a Reference_Dataset of real-world team/player data prepared by a separate Go tooling pipeline; this design only specifies the contract that dataset must satisfy, not how it is produced.
Career_System state is a plain JSON-serializable object graph (no class instances, no Map/Set, no functions), so Save_System round-tripping is structurally trivial and safe.Reference_Dataset is defined as a versioned TypeScript type with a matching JSON Schema, so the Go pipeline and the Next.js app can evolve independently as long as both honor the schema.The engine layer contains only pure, framework-agnostic TypeScript modules (lib/engine/*). Every acceptance criterion that is phrased as "the system SHALL compute/derive/apply X" is implemented here as a pure function (state, input) => state' or (input) => output. This is the layer property-based tests target directly, without touching React.
The state layer is a single Zustand store (useCareerStore) holding one CareerState object (see Data Models). Zustand is chosen over React Context/useReducer because:
LeagueWorld (hundreds of NPCs) doesn't cause unrelated components to re-render;All store mutations go through named actions (startCareer, simulateNextMatch, resolveClutchChoice, advanceSeason, acceptContractOffer, purchaseShopItem, saveCareer, loadCareer, ...) that internally call the pure engine functions and replace the relevant slice of state immutably. This keeps every mutation attributable to one engine function, which is what the correctness properties assert about.
The LeagueWorld can contain on the order of 4 regions x (3 Premier divisions + Academy + 2 Challengers-tier circuits + Invite/Championship) x 8-12 teams x 5-8 roster slots, i.e. roughly 1,000-1,500 NPC_Player records. To keep this manageable in browser memory and in localStorage:
Record<PlayerId, NPCPlayer>, Record<TeamId, Team>), not nested. Teams reference players by id; Rosters are arrays of ids. This avoids duplicate/divergent copies of the same player and keeps save payloads smaller.SeasonHistoryEntry summaries (final standings, achievements, stat lines) rather than keeping every simulated Round.MapSimulationResult with a rounds: RoundResult[] array), but the store only needs to retain the aggregated Map/Match result and per-player stat totals for history; the UI can replay the round list for the just-finished Match from a transient (non-persisted) activeMatchTranscript slice that's cleared once the result is saved into history.| Module | Responsibility | Requirements |
|---|---|---|
lib/engine/characterCreation.ts |
Validate creation input, derive Region, generate initial Attributes | 1 |
lib/engine/attributes.ts |
Attribute clamping, Overall_Rating formula, Training_Focus/aging/match-day adjustments | 2, 3, 18 |
lib/engine/leagueWorld.ts |
Generate fictional Teams/NPCs, merge in Reference_Dataset, enforce roster invariants | 4, 5 |
lib/engine/tiers.ts |
Tier/Division/Stage structure, promotion/relegation, scouting, circuit crossover | 6, 7 |
lib/engine/roster.ts |
Contract offers, signing, release-on-full-roster, roster queries | 8, 13, 14 |
lib/engine/matchSimulation.ts |
Round/Map/Match simulation, stats aggregation, High_Stakes_Match + Clutch_Decision_Point | 9, 10, 11 |
lib/engine/tournaments.ts |
Season calendar generation, qualification, group ranking, bracket resolution, prizes | 12 |
lib/engine/economy.ts |
Random_Events, Sponsorships, Shop, Money ledger | 15, 16, 17 |
lib/engine/careerLifecycle.ts |
Aging, retirement, Legend_Status, Hall_of_Fame, Achievements | 18, 19, 20, 21 |
lib/engine/saveSystem.ts |
Serialize/deserialize, versioning, migration, localStorage IO | 22 |
lib/referenceDataset.ts |
Types + validator for the external Reference_Dataset contract | 5 |
store/careerStore.ts |
Zustand store wiring UI to the engine | all |
app/career/* |
Screens (dashboard, roster, transfer market, match center, hall of fame, shop, creation) | 23 |
app/career/)app/career/new/page.tsx — Character Creation wizard (name, country+flag picker, role, circuit, conditionally starting tier, age).app/career/dashboard/page.tsx — Career Dashboard: age/role/OVR/team/tier/contract-or-FA status, Legend indicator, quick links.app/career/roster/page.tsx — Roster view for the player's current team (active + substitutes, role/OVR/salary/duration/status).app/career/transfers/page.tsx — Transfer Market: incoming Contract offers (or explicit "no offers"), counter-offer negotiation UI, Free_Agent status.app/career/match/[matchId]/page.tsx — Match Center: pre-match info (teams, map pool/pick-ban), live round-by-round result feed (rendered as a replay of the already-simulated transcript, or paused at a Clutch_Decision_Point awaiting a choice), post-match stat line.app/career/season/page.tsx — Season Calendar / Tournament view: current Season's Tournament sequence, standings, Circuit_Points, bracket.app/career/history/page.tsx — Career History: chronological Season list with team/tier/standings/Achievements.app/career/hall-of-fame/page.tsx — Hall of Fame: cross-career Legend_Status records, most-recent-first, empty-state message.app/career/shop/page.tsx — Shop: Shop_Item catalog, Money balance, purchase flow with insufficient-funds handling.pendingRandomEvent, pendingSponsorshipOffer) and renderable from any screen.// lib/engine/attributes.ts
export function clampAttribute(value: number): number; // 0-99 integer clamp
export function computeOverallRating(attrs: Attributes, role: Role): number; // 0-99
export function applyTrainingFocus(attrs: Attributes, focus: TrainingFocus, age: number, rng: Rng): Attributes;
export function applyMatchDayAdjustment(attrs: Attributes, participation: MatchParticipation, rng: Rng): Attributes;
export function applyAgingDecline(attrs: Attributes, age: number, rng: Rng): Attributes;
// lib/engine/matchSimulation.ts
export function roundWinProbability(teamAOverall: number, teamBOverall: number): number; // (0,1)
export function simulateRound(ctx: RoundContext, rng: Rng): RoundResult;
export function simulateMap(ctx: MapContext, rng: Rng): MapResult;
export function simulateMatch(ctx: MatchContext, rng: Rng): MatchResult;
export function isHighStakesMatch(ctx: TournamentContext): boolean;
export function isClutchDecisionPoint(map: MapProgress, ctx: TournamentContext): boolean;
export function applyClutchChoice(map: MapProgress, choice: ClutchChoice): MapProgress;
export function selectMapPool(activePool: MapName[], format: MatchFormat, rng: Rng): MapName[];
// lib/engine/saveSystem.ts
export function serializeCareer(state: CareerState): SaveFile;
export function deserializeCareer(raw: unknown): Result<CareerState, SaveError>;
export function migrateSaveFile(raw: SaveFileAnyVersion): Result<SaveFile, SaveError>;
Rng is a small seeded PRNG wrapper ({ next(): number }, e.g. a mulberry32/xorshift implementation) rather than Math.random(), so every simulation function is pure and reproducible from (input, seed) — a prerequisite for property-based testing and for save/replay consistency.
All models are plain, JSON-serializable TypeScript types (no classes, no methods). Enums are modeled as string-literal unions for readability and easy JSON Schema mirroring.
// --- Primitives -------------------------------------------------------
type PlayerId = string;
type TeamId = string;
type Season = number; // sequential season index, 1-based
type Role = "Duelist" | "Initiator" | "Controller" | "Sentinel";
interface Attributes {
aim: number; // 0-99
gameSense: number; // 0-99
communication: number;// 0-99
clutch: number; // 0-99
consistency: number; // 0-99
leadership: number; // 0-99
}
type Region = "Americas" | "EMEA" | "Pacific" | "China";
type CompetitiveCircuit = "OpenCircuit" | "GameChangersCircuit";
type OpenCircuitTier = "Premier" | "Academy" | "Challengers" | "Invite";
type GameChangersTier = "Challengers" | "Championship";
type LeagueTier = OpenCircuitTier | GameChangersTier;
type PremierDivision = 1 | 2 | 3; // 1 = lowest, 3 = highest
type InviteSeasonStage =
| "Kickoff" | "Masters1" | "Stage1" | "Masters2" | "Stage2" | "Champions";
// --- People -------------------------------------------------------
interface PlayerBase {
id: PlayerId;
name: string;
age: number;
role: Role;
attributes: Attributes;
overallRating: number; // 0-99, always == computeOverallRating(attributes, role)
region: Region;
competitiveCircuit: CompetitiveCircuit;
leagueTier: LeagueTier;
premierDivision?: PremierDivision; // present iff leagueTier === "Premier"
teamId: TeamId | null; // null => Free_Agent
rosterStatus: "active" | "substitute" | null; // null when teamId is null
contract: Contract | null;
careerStats: CareerStats;
achievements: AchievementRecord[];
}
interface PlayerCharacter extends PlayerBase {
kind: "PlayerCharacter";
country: CountryCode;
money: number; // >= 0
popularity: number; // 0-99
activeSponsorships: ActiveSponsorship[];
legendStatus: boolean;
retired: boolean;
retirementRecord?: RetirementRecord;
}
interface NpcPlayer extends PlayerBase {
kind: "NpcPlayer";
sourcedFrom: "fictional" | "referenceDataset";
}
interface Contract {
teamId: TeamId;
role: Role;
salaryPerSeason: number; // >= 0
seasonsRemaining: number; // 1-5 initially, counts down
}
interface CareerStats {
matchesPlayed: number;
matchesWon: number;
tournamentTitles: number;
inviteOrChampionshipAppearances: number;
// ... additional aggregate stat totals (kills, deaths, assists, ACS, etc.)
}
// --- Teams / League World -------------------------------------------------------
interface Team {
id: TeamId;
name: string;
region: Region;
competitiveCircuit: CompetitiveCircuit;
leagueTier: LeagueTier;
premierDivision?: PremierDivision;
activeRosterIds: PlayerId[]; // exactly 5 once a Transfer_Window has closed
substituteIds: PlayerId[];
sourcedFrom: "fictional" | "referenceDataset";
circuitPointsThisSeason?: number; // Invite-tier only
}
interface LeagueWorld {
teams: Record<TeamId, Team>;
npcPlayers: Record<PlayerId, NpcPlayer>;
mapPool: MapName[]; // active Season Map_Pool
}
// --- Season / Tournament / Match -------------------------------------------------------
interface SeasonState {
seasonNumber: Season;
calendar: TournamentDef[]; // fixed per Tier/Region/Circuit, generated at season start
completedTournaments: TournamentResult[];
transferWindow: TransferWindowState;
pendingRandomEvents: RandomEventInstance[];
circuitPointsByTeam: Record<TeamId, number>; // Invite tier, reset each Season
}
interface TournamentDef {
id: string;
leagueTier: LeagueTier;
region: Region | "Cross-Region";
stage?: InviteSeasonStage;
format: MatchFormat;
prizeTable: PrizeTableEntry[];
}
type MatchFormat = "Bo1" | "Bo3" | "Bo5";
type MapName = "Ascent" | "Breeze" | "Haven" | "Lotus" | "Split" | "Summit" | "Sunset" | string;
interface MatchContext {
matchId: string;
tournamentId: string;
format: MatchFormat;
teamA: TeamId;
teamB: TeamId;
isHighStakes: boolean;
}
interface RoundResult {
roundNumber: number;
winner: TeamId;
clutchDecision?: ClutchDecisionRecord;
}
interface MapResult {
map: MapName;
roundsWonByTeam: Record<TeamId, number>;
rounds: RoundResult[];
winner: TeamId;
playerStats: Record<PlayerId, MapPlayerStats>;
}
interface MapPlayerStats {
kills: number; deaths: number; assists: number; combatScore: number;
}
interface MatchResult {
matchId: string;
maps: MapResult[];
winner: TeamId;
playerStats: Record<PlayerId, MapPlayerStats>; // aggregated across maps
}
interface ClutchDecisionRecord {
matchId: string;
roundNumber: number;
presentedChoices: ClutchChoice[] | null; // null when auto-resolved
selectedChoice: ClutchChoice | null;
outcome: "won" | "lost";
}
type ClutchChoice = "aggressive_execute" | "slow_default" | "full_save" | string;
// --- Economy -------------------------------------------------------
interface ActiveSponsorship {
sponsorshipId: string;
seasonsRemaining: number;
incomePerSeason: number;
}
interface RandomEventInstance {
eventId: string;
resolved: boolean;
selectedOptionId?: string;
}
interface AchievementRecord {
achievementId: string;
season: Season;
age: number;
}
interface RetirementRecord {
age: number;
finalOverallRating: number;
tournamentTitles: number;
highestLeagueTierReached: LeagueTier;
}
interface HallOfFameRecord {
playerName: string;
ageAtRetirement: number;
finalOverallRating: number;
tournamentTitles: number;
highestLeagueTierReached: LeagueTier;
retiredAtSeason: Season;
}
// --- Root state -------------------------------------------------------
interface CareerState {
saveVersion: number;
playerCharacter: PlayerCharacter;
leagueWorld: LeagueWorld;
season: SeasonState;
seasonHistory: SeasonHistoryEntry[];
hallOfFame: HallOfFameRecord[]; // cross-career, persists even after starting a new career
}
The Go tooling pipeline and the Next.js app are separate codebases, so the Reference_Dataset is defined as a versioned, explicit interchange format: a JSON file (or one per Region/Tier, the app doesn't care) validated against a JSON Schema, mirrored by a TypeScript type used at load time.
// lib/referenceDataset.ts
/** Top-level file the Go tooling must produce. One or more files are merged at load time. */
export interface ReferenceDataset {
schemaVersion: 1;
generatedAt: string; // ISO 8601, informational only
teams: ReferenceTeam[];
}
export interface ReferenceTeam {
externalId: string; // stable id from the source data, used for de-dup across files
name: string;
region: Region;
competitiveCircuit: CompetitiveCircuit;
leagueTier: "Invite" | "Championship" | "Challengers"; // only these tiers may be dataset-sourced
players: ReferencePlayer[]; // ideally 5-8; the app will pad/trim to roster rules if not
}
export interface ReferencePlayer {
externalId: string;
name: string;
role: Role;
age: number;
countryCode: CountryCode;
// Reference players carry pre-computed attributes so the Go tooling (which has access to
// real-world stats) controls the initial skill estimate; the app never invents attributes
// for a dataset-sourced player.
attributes: Attributes;
}
JSON Schema mirror (authoritative for cross-language validation; kept in sync with the TypeScript type above):
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "ReferenceDataset",
"type": "object",
"required": ["schemaVersion", "generatedAt", "teams"],
"properties": {
"schemaVersion": { "const": 1 },
"generatedAt": { "type": "string", "format": "date-time" },
"teams": {
"type": "array",
"items": {
"type": "object",
"required": ["externalId", "name", "region", "competitiveCircuit", "leagueTier", "players"],
"properties": {
"externalId": { "type": "string", "minLength": 1 },
"name": { "type": "string", "minLength": 1 },
"region": { "enum": ["Americas", "EMEA", "Pacific", "China"] },
"competitiveCircuit": { "enum": ["OpenCircuit", "GameChangersCircuit"] },
"leagueTier": { "enum": ["Invite", "Championship", "Challengers"] },
"players": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["externalId", "name", "role", "age", "countryCode", "attributes"],
"properties": {
"externalId": { "type": "string", "minLength": 1 },
"name": { "type": "string", "minLength": 1 },
"role": { "enum": ["Duelist", "Initiator", "Controller", "Sentinel"] },
"age": { "type": "integer", "minimum": 14, "maximum": 40 },
"countryCode": { "type": "string" },
"attributes": {
"type": "object",
"required": ["aim", "gameSense", "communication", "clutch", "consistency", "leadership"],
"properties": {
"aim": { "type": "integer", "minimum": 0, "maximum": 99 },
"gameSense": { "type": "integer", "minimum": 0, "maximum": 99 },
"communication": { "type": "integer", "minimum": 0, "maximum": 99 },
"clutch": { "type": "integer", "minimum": 0, "maximum": 99 },
"consistency": { "type": "integer", "minimum": 0, "maximum": 99 },
"leadership": { "type": "integer", "minimum": 0, "maximum": 99 }
}
}
}
}
}
}
}
}
}
}
Loading rules (implemented by leagueWorld.ts, tested by Properties 16-18 below):
(region, leagueTier) combination is treated as complete from the dataset only if the dataset provides a team for that slot with a valid Roster (per validation above); otherwise it falls back to fictional generation for that slot (Requirement 5.2).Invite, Championship, and Challengers tiers may ever be dataset-sourced (Requirement 5.3); Academy and Premier entries in a dataset file are ignored with a logged warning.Challengers, dataset-sourced teams and fictionally-generated teams coexist per-Region to fill the region's Team slot quota (Requirement 5.4/5.5); the loader de-dupes by externalId and never generates a fictional team that collides with a reserved dataset slot.Storage key: vct-career:v1 for the active save; vct-career:hall-of-fame is stored separately so Hall_of_Fame records survive starting a new career (Requirement 20.4 says "cross-career record").
Envelope (SaveFile):
interface SaveFile {
saveVersion: number; // schema version of the payload below, independent of app version
savedAt: string; // ISO 8601
checksum: string; // simple hash (e.g. FNV-1a) of the serialized payload, detects truncation
payload: CareerState; // the full, JSON-serializable CareerState (minus hallOfFame; see below)
}
hallOfFame is intentionally not nested inside the per-career payload; it is its own top-level record at vct-career:hall-of-fame with its own tiny envelope ({ saveVersion, records: HallOfFameRecord[] }), so retiring/starting new careers never risks clobbering past Legend_Status records.
Versioning & migration strategy:
saveVersion is a simple incrementing integer, bumped whenever a shape-breaking change is made to CareerState.saveSystem.ts keeps an ordered array of migration functions, migrations: Array<(old: unknown) => unknown>, indexed by "from version". migrateSaveFile walks forward one version at a time (v1 -> v2 -> v3 ...) until reaching the current CURRENT_SAVE_VERSION, then runs schema validation.raw.saveVersion is newer than CURRENT_SAVE_VERSION (e.g. save from a future app build), load fails with a typed SaveError.UnsupportedVersion rather than guessing.deserializeCareer returns Result.err(SaveError.Corrupt) and the caller (per Requirement 22.3) leaves the in-memory state untouched.localStorage.setItem on a temp key (vct-career:v1:__pending), read it back and compare checksums, and only then move it to the real key. If any step throws (quota exceeded, storage disabled in private mode, etc.), the temp key is cleaned up and the real save key is left exactly as it was (Requirement 22.5) — this is what makes the "failed save doesn't corrupt prior save" property testable.Each Round is resolved as a weighted coin flip. Let A and B be the two teams' combined Overall_Rating (sum of the five active Roster members' overallRating participating in that Round, Requirement 9.5). The win probability for Team A is a logistic function of the rating difference, so it is smooth, symmetric, and never touches 0 or 1:
p(A wins) = 1 / (1 + 10 ^ (-(A - B) / K))
K is a tunable scale constant (e.g. K = 400, borrowed from the well-understood Elo shape) chosen so that a realistic Overall_Rating gap (roughly 0-50 points combined-team-of-5, i.e. 0-10 points per player) maps to a meaningful but not deterministic edge (e.g. a 50-point combined edge is roughly a 70-75% Round win chance, never below an explicit floor). To satisfy Requirement 9.4/9.5's "retains a win probability greater than 0%" as a hard guarantee rather than an asymptotic one, the engine clamps the raw logistic output to [VARIANCE_FLOOR, 1 - VARIANCE_FLOOR] (e.g. VARIANCE_FLOOR = 0.05), which is also the "randomized variance factor" referenced in 9.5. A single Round outcome is then drawn as rng.next() < p(A wins).
Per-Attribute contributions (Aim, Game_Sense, etc., not just the scalar Overall_Rating) refine which player gets credit for a kill/death/assist within a won/lost Round (see stat derivation below) but do not change the Round win probability formula itself, keeping the win-probability model simple, monotonic, and easy to state as a property.
A Map is simulated as a loop of simulateRound calls:
roundsWon = { A: 0, B: 0 }
while true:
result = simulateRound(...)
roundsWon[result.winner] += 1
if roundsWon.A >= 13 && roundsWon.A - roundsWon.B >= 2: winner = A; break
if roundsWon.B >= 13 && roundsWon.B - roundsWon.A >= 2: winner = B; break
// 12-12 -> continues until a 2-round lead (overtime), per Requirement 9.3
This directly encodes Requirement 9.2/9.3: first to 13 with the game continuing past 12-12 until a 2-round margin.
Given the Tournament's MatchFormat, Maps are simulated one at a time and the loop stops as soon as a team has clinched the format's required Map-win count (1 for Bo1, 2 for Bo3, 3 for Bo5) — later Maps in a Bo3/Bo5 are never simulated once the outcome is decided, matching Requirement 9.1 exactly and keeping simulation cost bounded.
Before Map 1, the engine runs a simplified pick/ban: alternately removing one Map from the Season's active mapPool (starting team determined by seed/context) until exactly mapsNeeded remain, then order those by pick order. This guarantees no Map repeats within a Match and every selected Map is a pool member (Requirement 11.2), and is a pure function of (pool, format, rng).
For each Round won or lost, the engine attributes a probabilistic kill/death/assist/combat-score delta to the five participating players on each side, weighted by each player's Attributes (e.g. aim and consistency weight kill likelihood; gameSense/communication weight assists) — always non-negative integers. Per-Map totals are simply the sum of these per-Round deltas; per-Match totals are the sum of a player's stats across every Map they participated in (Requirement 9.6/9.7). This aggregation-by-summation structure is exactly what Property 39 (below) checks.
isHighStakesMatch(tournamentContext) is a pure, total function over the fixed case list from Requirement 10.1 (stage Grand Finals for Masters/Champions, GC Championship Grand Final, Kickoff-to-Masters_1 decider, Premier promotion/relegation decider) — implemented as a lookup/predicate over TournamentContext, not a stateful flag, so it is trivially property-testable as "returns true iff context matches one of the fixed cases."
Within a High_Stakes_Match's Map simulation loop, before drawing each Round, the engine checks isClutchDecisionPoint(mapProgress, context) (true at a defined match-point Round, e.g. roundsWon[either] === 12, or the 12-12 overtime Round). If true and the Player_Character is an active Roster participant in that Match:
ClutchPrompt with >= 2 ClutchChoice options instead of a RoundResult;resolveClutchChoice(choice) re-enters the engine with applyClutchChoice, which applies that choice's fixed probability-shift (e.g. aggressive_execute: +0.08 to the Player_Character's Team's Round-win probability for that Round only, full_save: -0.03 this Round but +0.05 to the following Round, etc. — exact magnitudes are configuration data per the requirements' open question) and resumes normal simulateRound for that (now-shifted) Round.isClutchDecisionPoint check still fires, but the engine calls simulateRound directly with no prompt (Requirement 10.4) — i.e., automatic resolution is implemented by skipping the pause, not by a separate code path, which keeps the "same probability model" guarantee structural rather than something that can drift.ClutchDecisionRecord to the Match/career history (Requirement 10.5).OpenCircuit = [Premier, Academy, Challengers, Invite], GameChangersCircuit = [Challengers, Championship]. Premier additionally has 3 ordered PremierDivisions.InviteSeasonStages run in fixed order each Season. Region-scoped stages (Kickoff, Stage1, Stage2) produce a per-Region standings table; cross-Region stages (Masters1, Masters2, Champions) pool qualifiers from all 4 Regions into one bracket.computePromotionRelegation(standings, tierRules) -> TeamMovement[], where TeamMovement = { teamId, fromTier, toTier, fromDivision?, toDivision? }. The function enforces structurally that:premierDivision by exactly 1, never leagueTier (Requirement 6.9);fromTier/toTier in any movement (Requirement 6.10);TeamMovement is a single applyTeamMovement(leagueWorld, movement) function that updates the Team.leagueTier/premierDivision and cascades the same values onto every PlayerId in activeRosterIds + substituteIds (Requirement 6.13), so tier-consistency can never drift between a Team and its Roster by construction.getScoutingEligibility(player, targetTier) compares player.overallRating/achievements against a fixed per-tier threshold table (Academy<-Premier, Challengers<-Academy, Invite<-Challengers) and is what gates whether roster.ts allows a higher-tier Team to generate an offer for that player during a Transfer_Window (Requirement 6.14/6.15).PlayerCharacter.competitiveCircuit is otherwise never reassigned by any engine function except one: accepting a GC->Open crossover offer. getCrossoverOfferProbability(overallRating) is a pure function returning 0 below CROSSOVER_FLOOR (65), linearly interpolating to 1 at CROSSOVER_CEILING (85), and 1 above it — used once per Transfer_Window per eligible GC player to decide whether an Open_Circuit Team extends an offer.A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.
This feature's core logic (attribute math, match simulation, tournament resolution, economy, save/load) is implemented as pure functions over plain data, which makes it a strong fit for property-based testing (PBT). UI-only concerns (layout, "provide visual feedback", static catalog contents) are intentionally excluded here and covered by unit/example tests instead (see Testing Strategy).
For any valid character-creation input where the selected Competitive_Circuit is GameChangersCircuit, the resulting Player_Character's starting leagueTier is always the Game Changers Challengers tier and the accepted age is always between 16 and 19 inclusive.
Validates: Requirements 1.3
For any selectable Country, the derived Region always equals that Country's fixed one-to-one mapping entry, regardless of any other creation input.
Validates: Requirements 1.5
For any character-creation input that passes validation, the resulting Player_Character is a Free_Agent (teamId === null) whose region, leagueTier, and competitiveCircuit match the derived/selected values, and whose six generated Attributes each fall within both the selected starting tier's defined range and the overall 0-99 scale.
Validates: Requirements 1.7, 1.9
For any character-creation input containing an invalid name, Country, Age (outside the selected tier's range), Role, starting League_Tier, or Competitive_Circuit, character creation always returns a rejection identifying an invalid field and never produces a Player_Character.
Validates: Requirements 1.8
For any starting Attributes and any sequence of Training_Focus, match-day, aging, Shop_Item, or Random_Event adjustments applied to them, every resulting Attribute value is always an integer within [0, 99], regardless of how far the raw (pre-clamp) computation would have pushed it.
Validates: Requirements 2.1, 2.5
For any Attributes and Role, computeOverallRating always returns an integer within [0, 99], and is consistent: given identical Attributes and Role, it always returns the same value.
Validates: Requirements 2.2
For any sequence of Attribute-mutating operations applied to a player, the player's stored overallRating is always exactly computeOverallRating(currentAttributes, currentRole) immediately after each operation.
Validates: Requirements 2.7
For any player and any selected Training_Focus, the resulting (pre-clamp) increase is always an integer between 1 and 5; for any Season in which no Training_Focus is selected, no training-based increase is applied to any Attribute.
Validates: Requirements 2.3, 2.6
For any Match participation and any Attribute, the applied match-day adjustment's magnitude is always at most 1, and whenever that Season's Training_Focus increase is also defined, the match-day adjustment magnitude is always strictly smaller than it. Validates: Requirements 2.4
For any base Attributes/Role pair and any positive delta applied equally to one Role-associated Attribute versus one non-associated Attribute, the resulting increase in Overall_Rating from the Role-associated change is always greater than or equal to the increase from the non-associated change.
Validates: Requirements 3.1, 3.2
For any player accepting a Contract offering a different Role, the player's active Role immediately becomes that offered Role; for any subsequent Contract expiration with no new Role-changing Contract, the Role remains unchanged.
Validates: Requirements 3.3, 3.4
For any newly-generated LeagueWorld, every valid (Region, LeagueTier, CompetitiveCircuit) combination defined by the tier structure has at least one Team.
Validates: Requirements 4.1
For any Region/Tier (or Region/Tier/Division for Premier) populated via fictional generation, the number of generated Teams is always between 8 and 12 inclusive. Validates: Requirements 4.2
For any fictionally-generated Team, its Roster always contains exactly 5 NPC_Players collectively covering all four Roles; for any two fictionally-populated Tiers within the same Region and Competitive_Circuit, the higher Tier's average NPC overallRating is always strictly greater than the lower Tier's.
Validates: Requirements 4.3
For any NPC_Player, applying the match-day and aging adjustment functions produces the same clamped results as for an equivalent Player_Character in the same state, and no Training_Focus-based increase is ever applied to an NPC_Player. Validates: Requirements 4.4
For any LeagueWorld state immediately after any Transfer_Window closes, every Team's activeRosterIds array always has a length of exactly 5.
Validates: Requirements 4.6
For any (Region, LeagueTier) slot where the Reference_Dataset provides a complete, valid Team entry, the generated Team and its Players for that slot are always exactly the dataset's entries (identity mapping on externalId, name, role, age, and attributes).
Validates: Requirements 5.1, 5.4
For any (Region, LeagueTier) slot where the Reference_Dataset is absent or incomplete, the fallback-generated Team(s) for that slot always satisfy the same team-count and Roster-composition invariants as Property 13/14, and never duplicate an externalId reserved by a partially-configured dataset.
Validates: Requirements 5.2, 5.5
For any Reference_Dataset content, including one that contains entries tagged for Academy or Premier, the League_World generator never sources Academy, Premier, or Game Changers Challengers Teams/Players from that dataset.
Validates: Requirements 5.3
For any Kickoff standings table (any permutation of Team records), the set of Teams qualifying for Masters_1 always equals exactly the top N Teams by the defined ranking rule, for the fixed N. Validates: Requirements 6.4
For any Team's finishing position in Stage_1 or Stage_2, the Circuit_Points awarded always equal that Stage's table lookup for that position. Validates: Requirements 6.5
For any set of Champions-qualifying Teams with arbitrary accumulated Circuit_Points (Kickoff + Stage_1 + Stage_2), the resulting seed order is always a descending sort by total Circuit_Points with the defined tie-break applied. Validates: Requirements 6.6
For any Premier Division playoff result, every computed TeamMovement always changes premierDivision by exactly 1 (or not at all) and never sets leagueTier to anything other than Premier.
Validates: Requirements 6.9
For any Season's results for an Academy Team, the computed TeamMovements never include that Team as a fromTier/toTier participant.
Validates: Requirements 6.10
For any final Challengers standing, that Team is promoted to Invite/Championship if and only if it meets the defined promotion criteria; symmetrically, for any final Invite/Championship standing, that Team is relegated to Challengers if and only if it meets the defined relegation criteria. Validates: Requirements 6.11, 6.12
For any applied TeamMovement, every current Roster member of that Team (active and substitute) always has a leagueTier/premierDivision matching the Team's new value once the movement has been applied, and this holds before the next Season begins.
Validates: Requirements 6.13
For any player's overallRating/Achievements relative to the next-higher Open_Circuit Tier's scouting threshold, that player is eligible to receive an offer from that Tier if and only if the threshold is met or exceeded.
Validates: Requirements 6.14
For any attempted Competitive_Circuit change that is not a valid Game_Changers-to-Open crossover acceptance, the request is always rejected and the player's competitiveCircuit is always left unchanged; an Open_Circuit player's competitiveCircuit never becomes GameChangersCircuit under any sequence of operations.
Validates: Requirements 7.3, 7.4, 7.7
For any generated set of Contract offers or Tournament participants, every one presented to or including a given player always belongs to that player's own competitiveCircuit, unless it is a valid Game_Changers-to-Open crossover offer under Property 30.
Validates: Requirements 7.1, 7.2
For any overallRating value, the crossover-offer probability is always exactly 0 below the floor (65), monotonically non-decreasing as overallRating increases from the floor to the ceiling (85), and always effectively 1 at or above the ceiling.
Validates: Requirements 7.5
For any Game_Changers-Circuit player accepting a crossover offer from an Open_Circuit Team, the resulting competitiveCircuit is always OpenCircuit and the resulting leagueTier always equals that Team's leagueTier, and this change is never reverted by any later operation.
Validates: Requirements 7.6
For any set of generated Contract offers and any Free_Agent, the offers presented to that player always equal exactly the subset matching that player's competitiveCircuit and region, and an explicit "no offers" indicator is always shown when that subset is empty.
Validates: Requirements 8.1
For any Team whose active Roster already has 5 members and any Contract acceptance by a new player, the resulting active Roster always has exactly 5 members, always includes the newly-signed player, and the removed member is always exactly the pre-existing member with the minimum overallRating (ties broken deterministically), who always becomes a Free_Agent.
Validates: Requirements 8.2
For any Team whose active Roster has fewer than 5 members, accepting a Contract offer always increases that Roster's size by exactly 1 and never removes an existing member. Validates: Requirements 8.3, 8.4
For any Team's Roster at any point, the Roster view always includes, for every member, their Role, Overall_Rating, Salary, remaining Contract duration, and active/substitute status. Validates: Requirements 8.5
For any generated Contract offer, salaryPerSeason is always non-negative and seasonsRemaining is always an integer between 1 and 5 inclusive.
Validates: Requirements 13.1
For any counter-offer and Team evaluation state, negotiation always resolves to exactly one of {accept at the counter-offered Salary, reject and withdraw, revise with a new Salary}; whenever the outcome is "accept," the finalized Contract's Salary always equals the counter-offered value. Validates: Requirements 13.2
For any Contract whose seasonsRemaining reaches 0, the player is never classified as a Free_Agent before the next Transfer_Window opens, and is always classified as a Free_Agent once it does.
Validates: Requirements 13.3
For any player with an active Contract, any attempted signing with a different Team outside a Transfer_Window is always rejected and the existing Contract is always left unchanged. Validates: Requirements 13.4
For any negotiation sequence, once 3 counter-offer rounds have occurred, every subsequent counter-offer is always rejected and the offering Team's most recent offer is always treated as final. Validates: Requirements 13.5
For any Season's conclusion, exactly one Transfer_Window is opened; while it is open, any Free_Agent or zero-duration-Contract player always has at most one active Contract offer at a time, and that offer's Team is always within the player's own Competitive_Circuit. Validates: Requirements 14.1, 14.2
For any player, their teamId never changes at any point after a Transfer_Window closes until the next Transfer_Window opens.
Validates: Requirements 14.4
For any player who has no active Contract at the moment their Transfer_Window closes, that player's status is always Free_Agent for the remainder of that Season. Validates: Requirements 14.5
For any simulated Match of a given MatchFormat, the declared winner is always the Team that reaches the format's required Map-win count (1/2/3 for Bo1/Bo3/Bo5) first; for any simulated Map, the declared winner and final Round score always satisfy either (winner's Rounds >= 13 and margin >= 2) or (both Teams' Rounds >= 12 and the winner leads by exactly 2).
Validates: Requirements 9.1, 9.2, 9.3
For any two combined-team Overall_Ratings A and B, roundWinProbability(A, B) is always strictly between 0 and 1; whenever A > B, roundWinProbability(A, B) is always strictly greater than roundWinProbability(B, A).
Validates: Requirements 9.4, 9.5
For any completed Map simulation, every Roster member who participated always has a MapPlayerStats record where kills, deaths, assists, and combat score are all present and non-negative.
Validates: Requirements 9.6
For any completed Match, each participating player's Match-level stat totals always equal the sum of that player's per-Map stats across every Map they played in that Match. Validates: Requirements 9.7
For any completed Match, the Match result, Map-by-Map score, and Player_Character statistics recorded into career history are always exactly the values produced by the Match_Simulation_Engine for that Match. Validates: Requirements 9.8
For any Tournament context, isHighStakesMatch always returns true for exactly the fixed set of defined cases (Masters/Champions Grand Final, GC Championship Grand Final, Kickoff-to-Masters_1 decider, Premier promotion/relegation decider) and false for every other context.
Validates: Requirements 10.1
For any High_Stakes_Match where the Player_Character is an active Roster participant, reaching a defined Clutch_Decision_Point Round always pauses simulation and always presents a choice set of size 2 or more. Validates: Requirements 10.2
For any Clutch_Decision_Point and any of its defined choices, selecting that choice always applies exactly that choice's win-probability delta to the Player_Character's Team for the affected Round(s), and simulation always resumes afterward. Validates: Requirements 10.3
For any High_Stakes_Match where the Player_Character's Team is involved but the Player_Character is not an active Roster participant, every Clutch_Decision_Point in that Match is always resolved via the same simulateRound function used for ordinary Rounds, and no choice prompt is ever created.
Validates: Requirements 10.4
For any resolved Clutch_Decision_Point, whether manually or automatically resolved, career history always contains a matching ClutchDecisionRecord capturing the presented choices (if any), the selection (if any), and the outcome.
Validates: Requirements 10.5
For any Match requiring N Maps, the selected Map sequence always has no duplicate entries and every selected Map is always a member of the Season's active Map_Pool. Validates: Requirements 11.2
For any two Seasons sharing the same League_Tier, Region, and Competitive_Circuit inputs, the generated Tournament sequence always has identical count and order. Validates: Requirements 12.1
For any Team's finishing position in a preceding Region-specific Tournament, that Team's qualification for the next Region-specific Tournament always equals the defined criteria evaluated on that position; for any set of the four Regions' preceding-stage results, a cross-Region Tournament's qualifying set always equals exactly the Teams meeting each Region's qualification rule. Validates: Requirements 12.2, 12.3
For any Team failing a Tournament's qualification criteria, that Team is always excluded from that Tournament and a "non-qualified" record for that Team always appears in the Season's results. Validates: Requirements 12.4
For any group of Match results, including ties in win count, the ranking function always produces a total order using head-to-head as the tie-break, and the Teams advancing to the bracket/playoff stage are always exactly the fixed top-N for that League_Tier. Validates: Requirements 12.5
For any completed bracket or playoff stage, the recorded placement list never ranks a Team eliminated in an earlier round above a Team eliminated in a later round (or the eventual winner). Validates: Requirements 12.6
For any Team's final placement in a Tournament, the Money paid to that Team's Player_Character (if any) always equals that Tournament's prize-table lookup for that placement, and payments are always monotonically non-increasing as placement worsens. Validates: Requirements 12.7
For any simulated Season, the count of Random_Events presented to the user is always between 1 and 2 inclusive. Validates: Requirements 15.2
For any presented Random_Event, career simulation never proceeds until exactly one of its defined options has been selected. Validates: Requirements 15.3
For any Random_Event and any of its defined options, selecting that option always changes Money, Popularity, and/or Attributes by exactly that option's defined deltas, subject to the existing clamping rules (Property 5). Validates: Requirements 15.4
For any resolved Random_Event, career history always contains exactly one matching record of that event and the option selected. Validates: Requirements 15.5
For any Player_Character's Popularity/Overall_Rating and any Sponsorship's defined threshold, that Sponsorship is offered if and only if the threshold is met or exceeded and that Sponsorship is not already active for that player. Validates: Requirements 16.2
For any accepted Sponsorship offer, the resulting active Sponsorship's remaining duration always equals that Sponsorship's catalog-defined duration. Validates: Requirements 16.3
For any Season end, Money always increases by exactly the sum of all currently-active Sponsorships' recurring incomes; whenever an active Sponsorship's remaining duration reaches 0, it is always deactivated and never contributes to any later Season's income. Validates: Requirements 16.4, 16.5
For any declined Sponsorship offer, the player's Money and set of active Sponsorships are always identical before and after the decline. Validates: Requirements 16.6
For any sequence of Salary payments, Sponsorship income, Tournament prizes, and Shop purchases applied to a Player_Character, Money is always non-negative after every operation, and each earning operation always increases Money by exactly its defined amount. Validates: Requirements 17.1
For any Shop_Item and any Money balance greater than or equal to that item's cost, purchasing it always deducts exactly that cost from Money and always applies exactly that item's defined effect. Validates: Requirements 17.3
For any Shop_Item and any Money balance less than that item's cost, the purchase attempt always leaves Money and the Player_Character's other state unchanged and always indicates that funds are insufficient. Validates: Requirements 17.4
For any Season end, the Player_Character's Age always increases by exactly 1, and this update is always applied before any retirement, aging-decline, or Legend_Status evaluation that depends on the new Age for that same Season-end transition. Validates: Requirements 18.1
For any Player_Character with Age >= 26 at Season end, Aim and Clutch each always change by an integer decrease between 1 and 5 (subject to the clamp in Property 5), applied in addition to any Training_Focus increase for that Season. Validates: Requirements 18.2
For any Player_Character whose Age reaches 35 at a Season end, Retirement always executes automatically with no confirmation required; for any Player_Character with Age between 28 and 34 inclusive at a Season end, a voluntary-Retirement option is always offered and never forced. Validates: Requirements 19.1, 19.2
For any Retirement event, the Player_Character's career statistics are never mutated by any later operation, the Player_Character is never included as a participant in any subsequent Match simulation, and any active Contract is always terminated with the player always removed from every Team's Roster. Validates: Requirements 19.3, 19.4, 19.5
For any combination of career totals (Tournament titles, Invite/Championship appearances, Match win rate) relative to the fixed Legend_Status thresholds, Legend_Status is awarded at Retirement if and only if every one of those totals meets or exceeds its corresponding threshold. Validates: Requirements 20.1, 20.2
For any Legend_Status award, exactly one Hall_of_Fame record is always created, and it always contains the player's name, Age at Retirement, final career Overall_Rating, Tournament titles won, and highest League_Tier reached, each matching the player's final career state. Validates: Requirements 20.3
For any set of Hall_of_Fame records with arbitrary Retirement order, the displayed list is always ordered from most-recent Retirement to earliest; when the set is empty, an explicit "no career has attained Legend_Status" indication is always shown instead. Validates: Requirements 20.4, 20.5
For any career event matching an Achievement's trigger condition, the first occurrence in a career always creates exactly one Achievement record (capturing the current Season and Age), and every later recurrence of that same trigger in the same career never creates an additional record. Validates: Requirements 21.2, 21.3
For any career state, the Achievements view always returns exactly the set of Achievement records currently stored for that career, returning an empty list when none have been earned. Validates: Requirements 21.4
For any current CareerState, requesting a save always writes a serialized representation to local storage and always reports success to the user.
Validates: Requirements 22.1
For any valid CareerState, calling deserializeCareer(serializeCareer(state)) always yields a state deeply equal to the original state.
Validates: Requirements 22.2
For any missing save or any payload that fails validation, requesting a load always returns a "no save found / invalid" description and always leaves the current in-memory CareerState byte-for-byte unchanged.
Validates: Requirements 22.3
For any Season-end transition, an automatic save always occurs without requiring any explicit user save request. Validates: Requirements 22.4
For any save attempt (explicit or automatic) that fails because storage is unavailable or full, the previously persisted save data is always left byte-for-byte unchanged, and the user is always informed that the save did not complete. Validates: Requirements 22.5
For any career state, the dashboard view-model always includes Age, Role, Overall_Rating, and always includes either (Team and Contract status) or, when the Player_Character is a Free_Agent, a Free_Agent indicator in their place. Validates: Requirements 23.1, 23.2
For any career history containing an arbitrary number of completed Seasons, the displayed list is always ordered ascending by Season number and always includes, for each Season, the Team, League_Tier, per-Tournament final standings, and Achievements earned that Season. Validates: Requirements 23.3
For any career state, the dashboard always shows the distinct Legend_Status indicator exactly when the Player_Character has attained Legend_Status, and never otherwise. Validates: Requirements 23.4
The engine layer never throws for expected domain outcomes (invalid creation input, insufficient funds, rejected counter-offer, no save found, etc.) — those are represented as a Result<T, E> return type ({ ok: true, value: T } | { ok: false, error: E }), following the pattern already implied by deserializeCareer. This keeps error handling testable as ordinary data rather than relying on try/catch control flow inside property tests.
| Error category | Representative cases | Handling |
|---|---|---|
Validation errors (CreationError, NegotiationError) |
Invalid name/country/age/role/tier (Req 1.8); rejected counter-offer path (Req 13.2) | Returned as a typed Result.err, carrying a field/reason code; the UI renders inline field errors, never a generic crash boundary. |
Economy errors (InsufficientFundsError) |
Shop purchase with Money < cost (Req 17.4) | Result.err; UI shows an "insufficient funds" toast, state is provably unchanged by Property 71. |
State-machine violations (InvalidTransitionError) |
Signing outside a Transfer_Window while under Contract (Req 13.4); attempting a non-crossover Circuit change (Req 7.3/7.4) | Rejected at the store-action boundary before any engine mutation runs; the action is a no-op other than returning the error. |
Save/Load errors (SaveError: StorageUnavailable, QuotaExceeded, Corrupt, UnsupportedVersion) |
Req 22.3, 22.5 | saveSystem.ts never lets a failed write touch the previously-committed key (write-then-verify-then-swap, see Save_System design); deserializeCareer never partially mutates the in-memory store — the store only commits a load result after full validation succeeds. |
| Reference_Dataset validation errors | Malformed JSON, schema violations, out-of-range Attributes, unsupported leagueTier tag |
Validated once at load time (Ajv or a hand-rolled schema check against the JSON Schema above) before any team is merged into the LeagueWorld; an invalid dataset team is treated as "incomplete for that slot" and triggers the Property 18 fallback path rather than crashing career generation. |
| Programmer errors (impossible states, e.g. a Round simulated for a retired player) | N/A — should never occur given Property 75 | Guarded with invariant()-style assertions in development builds only (stripped in production) so regressions surface immediately during testing rather than being silently possible. |
React Error Boundaries are used only as a last-resort UI safety net (one per top-level app/career/* route) to avoid a single rendering bug blanking the whole app; they are not part of the domain error-handling contract above.
The project currently has no test runner configured. This design adds:
Install (bun, dev dependencies): bun add -d vitest fast-check @testing-library/react @testing-library/jest-dom jsdom.
*.property.test.ts, colocated with each lib/engine/*.ts module) implement every property from the Correctness Properties section, one test per property, each run for a minimum of 100 iterations (fc.assert(fc.property(...), { numRuns: 100 })). Each test is tagged in a comment immediately above it:// Feature: valorant-career-mode, Property 45: Round-win probability is monotonic in rating difference and always strictly positive for both sides
it("round win probability is monotonic and strictly positive", () => {
fc.assert(
fc.property(fc.integer({ min: 0, max: 500 }), fc.integer({ min: 0, max: 500 }), (a, b) => {
const p = roundWinProbability(a, b);
expect(p).toBeGreaterThan(0);
expect(p).toBeLessThan(1);
if (a > b) expect(p).toBeGreaterThan(roundWinProbability(b, a));
}),
{ numRuns: 100 }
);
});
Custom fast-check arbitraries are defined once in lib/engine/__testing__/arbitraries.ts (e.g. arbAttributes(), arbPlayerCharacter(), arbTeamWithRoster(), arbCareerState()) and reused across property tests to keep generated domain objects always structurally valid (e.g. Attributes always integers 0-99, Rosters always non-empty) so tests focus on the property, not on re-deriving valid fixtures.*.test.ts) cover: fixed catalog contents (Random_Event/Sponsorship/Shop_Item catalogs each have >= 2 options / defined thresholds — Requirements 15.1, 16.1, 17.2), the static tier/stage/division structure tables (Requirements 6.1, 6.2, 6.7, 6.8), stage-scoping branching (Requirement 6.3), default Map_Pool contents (Requirement 11.1), and the handful of criteria classified as pure UI/UX guidance that aren't computable properties (Requirements 1.1, 1.2, 1.4, 1.6, 3.1's static table, 4.5/14.3's reuse of already-covered logic, 5.6, 6.15's reuse of Properties 26/27, 8.1's UI framing, 18.3/18.4/18.5's reuse of Properties 23/25/26/55, 19.6's reuse of Property 76).localStorage read/write through the real browser API (via jsdom's localStorage shim) rather than mocked, to catch quota/availability edge cases end-to-end (1-3 examples, not PBT, per Requirement 22's Property 81/83/85 already covering the logic layer with a mocked storage backend); and Reference_Dataset loading against 1-2 realistic fixture JSON files to confirm the schema validator + merge logic work together (not PBT, since this is a fixed external contract, not a space of arbitrary internal inputs).app/career/dashboard, .../roster, .../hall-of-fame verifying the Free_Agent substitution (Property 86), Legend indicator (Property 88), and Hall_of_Fame empty-state (Property 78) render correctly — thin wrappers around the already-property-tested view-model functions, so these stay as a small number of example-based snapshots rather than duplicating PBT at the component layer.Nearly every subsystem in this feature (attribute math, match simulation, tournament/bracket resolution, promotion/relegation, contracts, economy, save/load) is implemented as a pure function over a large, structured input space (arbitrary Attribute combinations, arbitrary standings tables, arbitrary Roster compositions, arbitrary save payloads). That is exactly the profile PBT is designed for, and it's why the Correctness Properties section above is large relative to a typical CRUD or IaC feature. The exceptions — static catalogs, fixed structural tables, and pure visual/UX guidance — are called out explicitly above and intentionally left to unit tests or excluded from automated testing entirely, per the property-vs-example decision guide.