Design Document: Valorant Career Mode

Overview

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.

Design goals

Architecture

High-level layering

[Diagram]

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:

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.

Client-side state management for a large simulated world

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:

Data flow: simulating a Match

[Diagram]

Module boundaries

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

Components and Interfaces

Screens (App Router routes under app/career/)

Key engine interfaces

// 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.

Data Models

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
}

Reference_Dataset contract

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):

Save_System: local storage schema and migration

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:

Match_Simulation_Engine algorithm

Round win probability model

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.

Map aggregation

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.

Match aggregation

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.

Map_Pool selection

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).

Per-Map / per-Match statistics

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.

High_Stakes_Match and Clutch_Decision_Point

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:

League_Tier / Promotion / Relegation / Scouting progression logic

Correctness Properties

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).

Character Creation

Property 1: Game Changers starting conditions are fixed

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

Property 2: Region derivation is a deterministic function of Country

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

Property 3: Valid creation produces a correctly-placed Free_Agent with in-range Attributes

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

Property 4: Invalid creation input is always rejected without side effects

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

Attributes, Overall Rating, and Roles

Property 5: Attributes are always integers clamped to 0-99

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

Property 6: Overall_Rating is a pure, bounded function of Attributes and Role

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

Property 7: Overall_Rating never goes stale

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

Property 8: Training_Focus increases are bounded, and absence of a selection yields no increase

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

Property 9: Match participation adjustments are small and always smaller than that Season's training increase

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

Property 10: Role-associated Attributes are weighted at least as heavily as non-associated ones

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

Property 11: Role reassignment persists until explicitly changed

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

League World Generation

Property 12: The League_World always fully covers every Region x Tier x Circuit combination

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

Property 13: Fictionally-populated slots have team counts within the defined range

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

Property 14: Fictional Rosters always have 5 role-complete NPCs, and average NPC quality increases with Tier

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

Property 15: NPC attribute mechanics mirror Player_Character mechanics minus Training_Focus

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

Property 16: Every Team has exactly 5 active Roster members once a Transfer_Window has closed

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

Property 17: Complete Reference_Dataset slots are sourced verbatim

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

Property 18: Incomplete or missing dataset slots fall back to valid fictional generation

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

Property 19: Academy, Premier, and Game Changers Challengers are never dataset-sourced

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

League Tier Structure, Promotion, Relegation, and Scouting

Property 20: Kickoff qualification always selects exactly the top-N Region finishers

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

Property 21: Circuit_Points awarded always match the defined table for the finishing position

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

Property 22: Champions seeding always orders Teams by accumulated Circuit_Points

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

Property 23: Premier movement is always bounded to one adjacent Division and never changes Tier

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

Property 24: Academy Teams are never subject to tier-level promotion or relegation

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

Property 25: Challengers promotion and Invite/Championship relegation match their defined criteria exactly

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

Property 26: Tier/Division movement is always cascaded consistently to every Roster member

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

Property 27: Scouting eligibility exactly tracks the defined threshold

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

Property 28: Circuit-change attempts outside the defined crossover are always rejected

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

Property 29: Offers and Tournament participation are always confined to a player's own Circuit, except valid crossovers

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

Property 30: Crossover offer probability is a monotonic step from 0 to certainty between the floor and ceiling

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

Property 31: Accepting a crossover offer permanently updates Circuit and Tier together

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

Roster and Contract Management

Property 32: A Free_Agent's presented offers always exactly match their Circuit and Region, with an explicit empty state

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

Property 33: Signing onto a full Roster always swaps out exactly the lowest-rated existing member

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

Property 34: Signing onto a non-full Roster always adds without removing

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

Property 35: Roster queries always expose all required per-member fields

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

Property 36: Contract offers are always within the defined Salary and duration bounds

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

Property 37: Counter-offer negotiation always yields exactly one defined outcome

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

Property 38: Contract expiry transitions to Free_Agent only at the next Transfer_Window

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

Property 39: Active Contracts always block off-window signings, unchanged

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

Property 40: Counter-offer rounds are always capped at 3

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

Property 41: Exactly one Transfer_Window opens per Season, with at most one active offer per eligible player from their own Circuit

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

Property 42: Team assignment is always locked between the close of one window and the open of the next

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

Property 43: Closing the window with no active Contract always yields Free_Agent status for the rest of the Season

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

Match Simulation

Property 44: Match and Map outcomes always satisfy their win-count rules

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

Property 45: Round-win probability is monotonic in rating difference and always strictly positive for both sides

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

Property 46: Every Map participant always has complete, non-negative statistics

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

Property 47: Match statistics are always the exact sum of per-Map statistics

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

Property 48: Recorded career history always matches the engine's computed Match result

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

Property 49: High_Stakes_Match classification is a deterministic function of Tournament context

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

Property 50: Clutch_Decision_Points always pause with at least 2 choices when the Player_Character is active

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

Property 51: Selecting a Clutch choice always applies exactly its defined probability shift and resumes

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

Property 52: Clutch points auto-resolve with the ordinary probability model when the Player_Character is not an active participant

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

Property 53: Every Clutch_Decision_Point resolution is always recorded

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

Property 54: Selected Maps within a Match are always unique and drawn from the active Map_Pool

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

Tournament Structure and Season Calendar

Property 55: The Tournament calendar's shape is always identical across Seasons for the same Tier/Region/Circuit

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

Property 56: Region-specific and cross-Region qualification always match their defined criteria

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

Property 57: Non-qualifying Teams are always excluded and explicitly recorded, never silently dropped

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

Property 58: Group ranking always produces a total order via the defined tie-break, and exactly the top-N advance

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

Property 59: Recorded bracket placement is always consistent with elimination order

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

Property 60: Tournament prize payments always match the defined table and never improve for a worse placement

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

Economy: Random Events, Sponsorships, and Shop

Property 61: The number of Random_Events presented per Season is always within the defined bounds

For any simulated Season, the count of Random_Events presented to the user is always between 1 and 2 inclusive. Validates: Requirements 15.2

Property 62: Simulation never advances past an unresolved Random_Event

For any presented Random_Event, career simulation never proceeds until exactly one of its defined options has been selected. Validates: Requirements 15.3

Property 63: Selecting a Random_Event option always applies exactly its defined effect

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

Property 64: Every resolved Random_Event is always recorded with its chosen option

For any resolved Random_Event, career history always contains exactly one matching record of that event and the option selected. Validates: Requirements 15.5

Property 65: A Sponsorship offer exists if and only if its threshold is met and it is not already active

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

Property 66: Accepting a Sponsorship always activates it for exactly its defined duration

For any accepted Sponsorship offer, the resulting active Sponsorship's remaining duration always equals that Sponsorship's catalog-defined duration. Validates: Requirements 16.3

Property 67: Active Sponsorships always pay exactly their combined recurring income at each Season end, and always expire on schedule

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

Property 68: Declining a Sponsorship offer is always a no-op

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

Property 69: Money is always non-negative and every earning operation increases it by exactly its defined amount

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

Property 70: A sufficient-funds purchase always deducts exactly the cost and applies exactly the defined effect

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

Property 71: An insufficient-funds purchase is always rejected, unchanged, and flagged

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

Aging, Retirement, and Legend Status

Property 72: Age always advances by exactly 1 at Season end, before any other Age-dependent processing

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

Property 73: Age-based decline always applies to Aim and Clutch once Age reaches 26, alongside any Training_Focus increase

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

Property 74: Age 35 always triggers mandatory Retirement, and Ages 28-34 always offer (never force) voluntary Retirement

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

Property 75: Retirement always finalizes stats, blocks future Match participation, and cleans up Contract/Roster state

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

Property 76: Legend_Status is awarded if and only if every defined threshold is met or exceeded

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

Property 77: A Legend_Status award always produces a complete, accurate Hall_of_Fame record

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

Property 78: The Hall_of_Fame is always displayed most-recent-first, with an explicit empty state

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

Property 79: The first matching career event always records exactly one Achievement, and repeats never duplicate it

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

Property 80: The Achievements view always returns exactly the recorded set, including empty

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

Save System

Property 81: Saving always persists the current state and reports success

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

Property 82: Save then load always round-trips to an equivalent state

For any valid CareerState, calling deserializeCareer(serializeCareer(state)) always yields a state deeply equal to the original state. Validates: Requirements 22.2

Property 83: Loading with missing or corrupt data always leaves in-memory state untouched

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

Property 84: Every Season-end transition always triggers an automatic save

For any Season-end transition, an automatic save always occurs without requiring any explicit user save request. Validates: Requirements 22.4

Property 85: A failed save never corrupts previously persisted data

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

Dashboard and History Display

Property 86: The dashboard view-model always contains all required fields, with Free_Agent substitution

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

Property 87: The history view is always ordered chronologically with all required per-Season fields

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

Property 88: The Legend indicator is shown if and only if Legend_Status has been attained

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

Error Handling

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.

Testing Strategy

Tooling

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.

Dual testing approach

Why PBT applies here

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.