# Deep analysis: `force-commit-recruits` (CFB27 CLI — force-commit recruits + Coach XP setter)

Source repo: `E:/Games/EA SPORTS College Football 27.SteamGG.NET/Mods/CoachCarouselRecruitTool/reference/force-commit-recruits`
First-party files analyzed (in full): `force-commit.js` (77 ln), `savePicker.js` (191 ln), `set-coach-fastest.js` (64 ln), `engine/rg/openSave.js` (52 ln), `engine/rg/applyClass.js` (554 ln, the core), `README.md`, `package.json`, `force-commit.bat`, `force-commit-dry-run.bat`, `set-coach-fastest.bat`.

Precision rule applied throughout: anything not literally in the code is prefixed **UNVERIFIED**.

---

## 0. Architecture at a glance

```
force-commit.bat ──> node force-commit.js [saveNameOrPath] [--dry-run] [--verbose]
                        └─ savePicker.resolveSavePath()   (auto-detect Documents/…/saves, FBCHUNKS magic check)
                        └─ engine/rg/applyClass.forceCommitClass(savePath, {dryRun})
                              └─ engine/rg/openSave.openSave()  (madden-franchise, schema 468_2 override)

set-coach-fastest.bat ──> node set-coach-fastest.js  ──> applyClass.setCoachXP(savePath, {dryRun})
```

- `package.json` deps: `"@toondepauw/node-zstd": "^2.0.0"`, `"madden-franchise": "^4.2.2"`. Ships its own `node.exe` so end-users need no Node install.
- The entry scripts set `process.env.RG_SCHEMA_DIR = path.resolve(__dirname, 'engine-data')` **before** requiring the engine modules — `openSave.js` reads that env var at module load.
- `.bat` files are one-liners: `"%~dp0node.exe" "%~dp0force-commit.js" %*` + `pause`. The dry-run bat hardcodes `--dry-run --verbose`.

---

## 1. Save opening pattern (`engine/rg/openSave.js`, complete)

```js
const SCHEMA_DIR = process.env.RG_SCHEMA_DIR || path.resolve(__dirname, '..', 'engine-data');
const SCHEMA_OVERRIDE = () => ({ major: 468, minor: 2, gameYear: 27, path: path.join(SCHEMA_DIR, 'C27_468_2.gz') });

function openSave(savePath) {
  return new Promise((resolve, reject) => {
    const f = new FranchiseFile(savePath, {
      autoParse: true,
      schemaDirectory: SCHEMA_DIR,
      schemaOverride: SCHEMA_OVERRIDE(),
    });
    f.on('ready', () => resolve(f));
    f.on('error', reject);
  });
}
```

Helpers (all of them — these are the exact primitives our tool should copy):

```js
/** Resolve a table id by name — largest recordCapacity wins (several helper tables share names). */
function tableByName(file, name) {
  const hits = file.tables.filter((t) => t.name === name);
  if (!hits.length) throw new Error(`table not found: ${name}`);
  return hits.sort((a, b) => b.header.recordCapacity - a.header.recordCapacity)[0];
}

async function readTable(file, name) { const t = tableByName(file, name); await t.readRecords(); return t; }

/** Decode a 32-char binary-string reference into {tableId, row}, or null if empty/invalid. */
function parseRef(bin) {
  if (typeof bin !== 'string' || bin.length < 32 || !/[1-9]/.test(bin)) return null;
  return { tableId: parseInt(bin.slice(0, 15), 2), row: parseInt(bin.slice(15), 2) };
}

/** Encode {tableId,row} back into a 32-char reference. */
function makeRef(tableId, row) {
  return tableId.toString(2).padStart(15, '0') + row.toString(2).padStart(17, '0');
}

/** Safe field read (schema mismatches / free rows throw otherwise). */
const sf = (rec, field) => { try { return rec[field]; } catch { return undefined; } };
```

`applyClass.js` adds the write twin:

```js
const W = (rec, field, val) => { try { rec[field] = val; return true; } catch { return false; } };
```

Also used everywhere: `file.getTableById(id)` + `await t.readRecords()`, memoized in a per-run cache:

```js
const cache = {};
const getT = async (id) => { if (!cache[id]) { const t = file.getTableById(id); await t.readRecords(); cache[id] = t; } return cache[id]; };
```

---

## 2. Tables opened by name in `forceCommitClass`

```js
const seasonInfoT         = await readTable(file, 'SeasonInfo');
const recruitTargetArrayT = await readTable(file, 'RecruitTarget[]');
const recruitTargetT      = await readTable(file, 'RecruitTarget');
const recruitT            = await readTable(file, 'Recruit');
const playerT             = tableByName(file, 'Player'); await playerT.readRecords();
const teamT               = await readTable(file, 'Team');
const rosterT             = await readTable(file, 'Player[]');
```

Plus tables reached only by following refs (never by name):
- the **TopSchoolsList array table** (via `Recruit.TopSchoolsList` ref) and the **top-school element table** its entries point to (records carry `TeamId`, `TeamInfluence`) — a code comment calls this element record `ProspectTargetSchool` (UNVERIFIED as the literal save-table name; strongly implied by the comment "TeamIndex is what the game uses as the identifier in ProspectTargetSchool.TeamId");
- the **school tracking table** (via `Team.MySchoolTrackingTable` ref) with the ten `*Grade` letter-grade columns;
- the **team roster `Player[]` row** is followed via `Team.Roster` ref (`const rosterT = await getT(rosterRef.tableId)` — the code deliberately follows the ref instead of comparing IDs to the by-name `Player[]` table).

`setCoachXP` opens one more: `LeagueSetting`.

---

## 3. Field-by-field inventory (exact names as read/written in code)

### 3.1 `SeasonInfo` (record 0 only)
| Field | R/W | Notes |
|---|---|---|
| `CurrentWeek` | R | `+sf(seasonInfoT.records[0], 'CurrentWeek') || 1`. Written into `RecruitTarget.CommittedWeekNumber`. No in-code stage gating — README instructs "ONLY RUN … ON A SAVE FILE THAT IS ON WEEK 4 OF THE TRANSFER PORTAL PERIOD". |

### 3.2 `Recruit`
| Field | R/W | Type/values seen in code |
|---|---|---|
| `RecruitStage` | R+W | Enum string. Values referenced: `'SoftCommitted'`, `'HardCommitted'`, `'Signed'`. Written: `'HardCommitted'`. (Uncommitted stage value(s) never named in code — the tool only tests `!==` those three. UNVERIFIED what the uncommitted enum literal is, e.g. `'None'`/`'Open'`.) |
| `RecruitStageAdvance` | W | Written `'InstantCommit'` (enum string). Never read. |
| `TotalScholarshipOffers` | R | Number; `> 0` disqualifies a recruit from being force-committed (this is the "protect your board with a 0-NIL offer" mechanism from the README). |
| `Player` | R | 32-bit ref → `Player` row. Validated: `playerRef.tableId === playerT.header.tableId`. |
| `NationalRank` | R | Number; sort key ascending (best first). Also `nationalRank % 100 < 20` selects "charity" recruits. |
| `CommitScore` | R | Number, fallback `900`. Written into the top school's `TeamInfluence`. |
| `TopSchoolsList` | R | 32-bit ref → an array table row (the recruit's ranked school list). |

### 3.3 `RecruitTarget` (one row = one entry on some school's recruiting board)
| Field | R/W | Notes |
|---|---|---|
| `Recruit` | R+W | Ref → `Recruit` row. Empty/dangling ref ⇒ entry is "hijackable". Written via `makeRef(recruitTableId, recruitRow)`. |
| `ScholarshipStatus` | R+W | Enum string; only literal seen: `'Offered'`. `sendFreeCommits` resets any entry whose status ≠ `'Offered'` back to `'Offered'` (other enum values UNVERIFIED — clearly exist since the code tests for ≠). |
| `CommittedWeekNumber` | W | Set to `SeasonInfo.CurrentWeek`. |
| `OriginalNILExpectation` | W | Set to `0`. |
| `CurrentNILOffer` | W | Set to `0`. |
| `NILExpectation` | W | Set to `0`. |

### 3.4 `RecruitTarget[]` (array table; **row index == TeamIndex == "boardRow"**)
- Each row is a school's recruiting board; each column (from `offsetTable.map(o => o.name)`) holds a ref to a `RecruitTarget` row.
- Iterated `0 .. header.recordCapacity`, skipping `!rec || rec.isEmpty`.
- **User-school detection**: if ANY element ref in the row points to a tableId other than `recruitTargetT.header.tableId`, the whole board is flagged `isUserSchool = true` and skipped. (UNVERIFIED why: presumably the human player's board entries live in a different/derived table.)

### 3.5 `Player` (shared by roster players AND recruit prospects)
| Field | R/W | Values seen in code |
|---|---|---|
| `Position` | R | `CB FS SS ROLB LOLB MLB DT LE RE TE RT LT C RG LG QB WR HB FB K P` (keys of `POSITION_TO_RATING_GROUP`). |
| `ProspectStarRating` | R | Enum: `ONE_STAR TWO_STAR THREE_STAR FOUR_STAR FIVE_STAR` (fallback `'TWO_STAR'`). |
| `SchoolYear` | R | Enum; literals used: `'Freshman'`, `'Sophomore'`, fallback `'Senior'`. |
| `RecruitingDealbreaker` | W | Set to the school's best dealbreaker (values = the `RecruitingDealbreaker` enum below). |
| `IdealRecruitingPitch` | W | Set to `DEALBREAKER_TO_PITCH[dealbreaker]` (pitch enum below). |

### 3.6 `Team`
| Field | R/W | Notes |
|---|---|---|
| `TeamIndex` | R | The game-facing team id; equals the `RecruitTarget[]` board row and the value stored in top-school `TeamId`. **Not** the same as Team table row index — `buildTeamIndexToRow` builds the reverse map. |
| `PrestigeRank` | R | Number; needy schools sorted ascending ("lowest value = highest prestige = first pick"). |
| `TeamPrestige` | R | Number 0–10 (debug log prints `teamPrestige/2` as "stars", and `STAR_TO_PRESTIGE_RANGE` tops out at 10). Matched against recruit star tier. |
| `Roster` | R | Ref → a `Player[]` row (array of refs to `Player`). |
| `MySchoolTrackingTable` | R | Ref → school-tracking record holding the letter-grade columns. |
| `DisplayName` | R | String, logging only. |
| `TEAM_RATINGDB/LB/DL/TE/OL/QB/WR/RB/ST` | — | Named in `RATING_GROUPS`/`POSITION_TO_RATING_GROUP` as Team table rating fields, but **never actually read** in the current code (needs are computed from roster ages instead; the group names are only used for the results breakdown). |

### 3.7 `Player[]` (roster array rows, via `Team.Roster`)
- Columns = refs to `Player` rows; only refs whose `tableId === playerT.header.tableId` are honored.
- Used by `calculateTeamNeeds`: counts Freshman/Sophomore players per position; a position is "needed" when that count ≤ 1 (`YOUNG_NEEDED_THRESHOLD = 1`, same value for K/P via `YOUNG_NEEDED_THRESHOLD_ST = 1`).

### 3.8 TopSchoolsList array table + element records (via `Recruit.TopSchoolsList`)
- The array row's **first column** (`topSchoolsListT.offsetTable.map(o => o.name)[0]`) is the recruit's **#1 school**. Ref → an element record with:

| Field | R/W | Notes |
|---|---|---|
| `TeamId` | R+W | == `Team.TeamIndex` == board row. Written to the committing school's board row on instant-commit. |
| `TeamInfluence` | W | Written = recruit's `CommitScore`. (UNVERIFIED semantics: presumably the interest/points value that makes the #1 slot stick.) |

- UNVERIFIED: element table is named `ProspectTargetSchool` (from code comment only).

### 3.9 School tracking table (via `Team.MySchoolTrackingTable`)
Read-only letter-grade columns (exact names, from `GRADE_TO_DEALBREAKER` keys):
`AcademicPrestigeGrade, CoachStabilityGrade, CoachPrestigeGrade, ChampionshipContenderGrade, CampusLifestyleGrade, BrandExposureGrade, AthleticFacilitiesGrade, ProgramTraditionGrade, StadiumAtmosphereGrade, ConferencePrestigeGrade`
Values are letter-grade enum strings: `Aplus A Aminus Bplus B Bminus Cplus C Cminus Dplus D Dminus F` (see `GRADE_VALUE_MAP`).

### 3.10 `LeagueSetting` (setCoachXP)
| Field | R/W | Notes |
|---|---|---|
| `CoachXPSpeedSetting` | R+W | Record 0 only. Written `'Fastest'` (enum string). Previous value may be unset. |

---

## 4. THE MUTATION RECIPE — force-committing one recruit (verbatim core)

`instantCommit(targetEntry, recruitRec, recruitRow, recruitTableId, boardRow, commitScore, currentWeek, getT, playerEntry, schoolDealbreaker)`:

```js
W(targetEntry, 'Recruit', makeRef(recruitTableId, recruitRow));
W(targetEntry, 'CommittedWeekNumber', currentWeek);
W(targetEntry, 'OriginalNILExpectation', 0);
W(targetEntry, 'CurrentNILOffer', 0);
W(targetEntry, 'NILExpectation', 0);
W(targetEntry, 'ScholarshipStatus', 'Offered');

W(recruitRec, 'RecruitStage', 'HardCommitted');
W(recruitRec, 'RecruitStageAdvance', 'InstantCommit');

W(playerEntry, 'RecruitingDealbreaker', schoolDealbreaker);
W(playerEntry, 'IdealRecruitingPitch', DEALBREAKER_TO_PITCH[schoolDealbreaker]);

const topSchoolsRef = parseRef(sf(recruitRec, 'TopSchoolsList'));
if (!topSchoolsRef) return false;
const topSchoolsListT = await getT(topSchoolsRef.tableId);
const topSchoolsListRec = topSchoolsListT.records[topSchoolsRef.row];
if (!topSchoolsListRec) return false;
const firstCol = topSchoolsListT.offsetTable.map((o) => o.name)[0];
const topSchoolRef = parseRef(sf(topSchoolsListRec, firstCol));
if (!topSchoolRef) return false;
const topSchoolT = await getT(topSchoolRef.tableId);
const topSchoolRec = topSchoolT.records[topSchoolRef.row];
if (!topSchoolRec) return false;

try { topSchoolRec.TeamId = boardRow; } catch {}
try { topSchoolRec.TeamInfluence = commitScore; } catch {}
return true;
```

Recipe restated (the 3-table consistency contract):
1. **Board side** (`RecruitTarget` entry that already exists on the school's `RecruitTarget[]` board — the tool never allocates new rows, it *hijacks* an existing uncommitted/dangling entry): point `Recruit` at the recruit, stamp `CommittedWeekNumber = CurrentWeek`, zero all three NIL fields, `ScholarshipStatus = 'Offered'`.
2. **Recruit side**: `RecruitStage = 'HardCommitted'`, `RecruitStageAdvance = 'InstantCommit'`.
3. **Player side** (compatibility sweetener, keeps the AI from decommitting? UNVERIFIED intent): overwrite `RecruitingDealbreaker` with the school's highest-graded attribute and `IdealRecruitingPitch` with the mapped pitch.
4. **Top-school side**: rewrite slot #1 of the recruit's `TopSchoolsList` so `TeamId = boardRow` (the school's TeamIndex) and `TeamInfluence = CommitScore`. **Commit identity is derived from this**: elsewhere the tool counts a recruit as committed to a board only when `RecruitStage ∈ {HardCommitted, Signed}` **AND** `#1 TopSchool.TeamId == boardRow`. So stage alone does not bind a recruit to a school — the #1 top-school entry is the binding.
5. If any step of the top-school walk fails the function returns `false` and the caller logs `[commit-fail]` — note the earlier `W()` writes are **not rolled back** (gotcha: a failed commit still leaves the target entry + recruit stage mutated).

Post-pass (`sendFreeCommits`) — makes every *existing* non-'Offered' board offer free, league-wide:

```js
function sendFreeCommits(recruitTargetT) {
  const limit = recruitTargetT.header.nextRecordToUse;
  let freeCommits = 0;
  for (let i = 0; i < limit; i++) {
    const targetEntry = recruitTargetT.records[i];
    if (!targetEntry || targetEntry.isEmpty) continue;
    const recruitRef = sf(targetEntry, 'ScholarshipStatus') || 'Offered';  // (misnamed var — it's the status string)
    if (!recruitRef || recruitRef === 'Offered') continue;
    W(targetEntry, 'OriginalNILExpectation', 0);
    W(targetEntry, 'CurrentNILOffer', 0);
    W(targetEntry, 'NILExpectation', 0);
    W(targetEntry, 'ScholarshipStatus', 'Offered');
    freeCommits++;
  }
  return freeCommits;
}
```

---

## 5. Recruit-vs-roster-player distinction & safe row iteration

**Distinction — table membership, not a flag:**
- Recruits are rows of the `Recruit` table; each carries a `Player` ref into the shared `Player` table (so recruit prospects and roster players coexist in `Player`).
- Roster players are only ever reached the other way: `Team.Roster` → `Player[]` row → per-column refs → `Player` rows.
- Both walks defensively check ref tableId: `if (!playerRef || playerRef.tableId !== playerT.header.tableId) continue;`

**Safe iteration idioms (free-list / empty-row handling):**
- Data tables with a live free-list (`Recruit`, `RecruitTarget`): iterate `for (let i = 0; i < table.header.nextRecordToUse; i++)` — rows at/after `nextRecordToUse` are unused pool.
- Fixed/positional tables (`Team`, `RecruitTarget[]` where row index == TeamIndex): iterate `for (let row = 0; row < table.header.recordCapacity; row++)` because index is meaningful.
- Every row: `if (!rec || rec.isEmpty) continue;`
- Every field access through `sf()` (try/catch → `undefined`) and every write through `W()` (try/catch → false) — free rows and schema mismatches throw in madden-franchise.
- Dangling refs are treated as null: `parseRef` returns null for all-zero / non-binary strings, and a resolved-but-empty target record is treated the same as no record.
- Re-check live state before mutating (records are shared objects): the commit loop re-reads `RecruitStage` (`liveStage`) before acting on the pre-built candidate list.

---

## 6. Matching logic (for context — how it decides who commits where)

- **Needy school** = non-user board with `committedCount < FINAL_THRESHOLD (35)`. `committedCount` counts board entries whose recruit is `HardCommitted|Signed` **and** whose #1 top-school `TeamId` equals the board row. Everything else on the board (empty `Recruit` ref, dangling recruit, uncommitted stage, or committed-elsewhere) becomes a **hijackable target entry**.
- **Available recruit** = `Recruit` row with `TotalScholarshipOffers == 0` and `RecruitStage ∉ {SoftCommitted, HardCommitted, Signed}`, with a valid `Player` ref and `Position`.
- Recruits processed best-first (`NationalRank` asc). Candidate schools filtered by `prestigeSet.has(s.teamPrestige)` using `STAR_TO_PRESTIGE_RANGE` (or `CHARITY_STAR_TO_PRESTIGE_RANGE` when `nationalRank % 100 < CHARITY_RECRUIT_CHANCE (20)` — a deterministic pseudo-20% "charity" downgrade). Prefer schools needing the recruit's position (young-player scarcity), tie-break by fewest commits then random.
- Each match consumes one hijackable entry (`targetIdx++`); a school that runs out of entries while still under threshold is reported in `skippedSchools`.
- `LOWER_STAR_LOOKUP` is defined but **never used** (dead code).
- `calculateTeamNeeds`'s `rosterT/playerT/teamIndex` params: `teamIndex` unused inside; needs = positions with ≤1 Freshman/Sophomore.

---

## 7. `setCoachXP` — table, field, meaning

```js
async function setCoachXP(savePath, options = {}) {
  const file = await openSave(savePath);
  const coachT = await readTable(file, 'LeagueSetting');
  const coachRec = coachT.records[0];
  if (!coachRec || coachRec.isEmpty) throw new Error('LeagueSetting record not found');
  const previousSpeed = sf(coachRec, 'CoachXPSpeedSetting');
  W(coachRec, 'CoachXPSpeedSetting', 'Fastest');
  let backup = null;
  if (!options.dryRun) {
    backup = `${savePath}.backup-${Date.now()}`;
    fs.copyFileSync(savePath, backup);
    await file.save();
  }
  return { previousSpeed, newSpeed: 'Fastest', backup, dryRun: !!options.dryRun };
}
```

- Table: **`LeagueSetting`** (record 0). Field: **`CoachXPSpeedSetting`**, enum string, written `'Fastest'`.
- Meaning: it is the **league-wide Coach XP speed slider** (a League Settings menu option), NOT recruiting points. README: "In my testing it just about doubles XP gain" and warns EA re-overwrites the value the moment you enter the in-game League Settings menu (so re-run after visiting that menu).
- Other enum values UNVERIFIED (only `'Fastest'` appears; presumably `Normal/Fast/...` tiers exist).

---

## 8. Save flow / backup / dry-run

Identical block in both `forceCommitClass` and `setCoachXP`:

```js
let backup = null;
if (!options.dryRun) {
  backup = `${savePath}.backup-${Date.now()}`;
  fs.copyFileSync(savePath, backup);
  await file.save();
}
```

- **Backup naming**: `<savePath>.backup-<epoch-ms>` (e.g. `DYNASTY-WII.backup-1752522000000`), created in the same folder as the save.
- **Ordering**: mutations happen only in memory; the pristine on-disk file is copied to the backup **before** `file.save()` overwrites it in place. `file.save()` with no args writes back to the opened path (madden-franchise re-compresses with zstd).
- **Dry-run**: the entire mutation pass still runs against the in-memory model (so counts/debug are real), but backup+save are skipped and `result.dryRun` is echoed by the CLI. There is no field-level revert — dry-run safety relies purely on never calling `file.save()`.
- No file locking / game-running check; README says run from main menu or with the game closed, and only on a full save (never an `-autosave`).

---

## 9. `savePicker.js` — save discovery (reusable verbatim for our GUI)

- Candidate docs dirs: `%USERPROFILE%\Documents` plus every `%USERPROFILE%\OneDrive*\Documents`.
- Game folder match: entries `/^EA SPORTS College Football/i`, scored to prefer names containing `beta` and/or `27` (`score = (/beta/i ? 2 : 0) + (/\b27\b|Football 27/i ? 0 : 1)` — lower is better... note: the beta bonus of +2 actually *raises* the score, and sort is ascending, so despite the comment intent, non-beta folders sort first; UNVERIFIED whether that is intended); must contain a `saves` subfolder.
- A file is offered as a save iff: name starts with `dynasty` (case-insensitive), has **no dot** in the name, does not end `-autosave`, is a regular file, and its **first 8 bytes equal the magic `FBCHUNKS`** (latin1). That magic check (`isFBCHUNKS`) is the cheap "is this a CFB27 save" validator we should reuse.
- Fallbacks: explicit CLI path arg → interactive numbered picker → manual path prompt (3 attempts), with a soft warning if the magic is missing.

---

## 10. Embedded constants / enum maps (verbatim from `applyClass.js`)

```js
const POSITION_TO_RATING_GROUP = {
  CB: 'TEAM_RATINGDB', FS: 'TEAM_RATINGDB', SS: 'TEAM_RATINGDB',
  ROLB: 'TEAM_RATINGLB', LOLB: 'TEAM_RATINGLB', MLB: 'TEAM_RATINGLB',
  DT: 'TEAM_RATINGDL', LE: 'TEAM_RATINGDL', RE: 'TEAM_RATINGDL',
  TE: 'TEAM_RATINGTE',
  RT: 'TEAM_RATINGOL', LT: 'TEAM_RATINGOL', C: 'TEAM_RATINGOL', RG: 'TEAM_RATINGOL', LG: 'TEAM_RATINGOL',
  QB: 'TEAM_RATINGQB',
  WR: 'TEAM_RATINGWR',
  HB: 'TEAM_RATINGRB', FB: 'TEAM_RATINGRB',
  K: 'TEAM_RATINGST', P: 'TEAM_RATINGST',
};

const DEALBREAKER_TO_PITCH = {
  AthleticFacilities: 'CoachsFavorite',
  AcademicPrestige: 'CollegeExperience',
  BrandExposure: 'ItsGameTime',
  ChampionshipContender: 'TVTime',
  CoachPrestige: 'ItsGameTime',
  CoachStability: 'TheClutch',
  PlayingStyle: 'TimeToGetToWork',
  PlayingTime: 'FootballInfluencer',
  ProPotential: 'SundayBound',
  ProgramTradition: 'Grassroots',
  ProximityToHome: 'ConferenceSpotlight',
  StadiumAtmosphere: 'CollegeExperience',
  ConferencePrestige: 'ConferenceSpotlight',
  CampusLifestyle: 'CollegeExperience',
};
```
→ Full `RecruitingDealbreaker` enum domain (14 values) and the recruiting-pitch enum values (`CoachsFavorite, CollegeExperience, ItsGameTime, TVTime, TheClutch, TimeToGetToWork, FootballInfluencer, SundayBound, Grassroots, ConferenceSpotlight`).

```js
const GRADE_TO_DEALBREAKER = {
  AcademicPrestigeGrade: 'AcademicPrestige',
  CoachStabilityGrade: 'CoachStability',
  CoachPrestigeGrade: 'CoachPrestige',
  ChampionshipContenderGrade: 'ChampionshipContender',
  CampusLifestyleGrade: 'CampusLifestyle',
  BrandExposureGrade: 'BrandExposure',
  AthleticFacilitiesGrade: 'AthleticFacilities',
  ProgramTraditionGrade: 'ProgramTradition',
  StadiumAtmosphereGrade: 'StadiumAtmosphere',
  ConferencePrestigeGrade: 'ConferencePrestige',
};

const LOWER_STAR_LOOKUP = {   // defined but UNUSED (dead code)
  FIVE_STAR: 'FOUR_STAR', FOUR_STAR: 'THREE_STAR', THREE_STAR: 'TWO_STAR',
  TWO_STAR: 'ONE_STAR', ONE_STAR: 'ONE_STAR',
}

const GRADE_VALUE_MAP = {
  Aplus: 10, A: 9, Aminus: 8, Bplus: 7, B: 6, Bminus: 5,
  Cplus: 4, C: 3, Cminus: 2, Dplus: 1, D: 0, Dminus: -1, F: -2,
}

const RATING_GROUPS = ['TEAM_RATINGDB', 'TEAM_RATINGLB', 'TEAM_RATINGDL', 'TEAM_RATINGTE', 'TEAM_RATINGOL', 'TEAM_RATINGQB', 'TEAM_RATINGWR', 'TEAM_RATINGRB', 'TEAM_RATINGST'];
const FINAL_THRESHOLD = 35;               // commits per school target
const YOUNG_NEEDED_THRESHOLD = 1;         // <=1 young player at position => needed
const YOUNG_NEEDED_THRESHOLD_ST = 1;      // same, for K/P
const CHARITY_RECRUIT_CHANCE = 20;        // nationalRank%100 < 20 => charity tier

const STAR_TO_PRESTIGE_RANGE = {
  ONE_STAR:   [0, 1, 2, 3, 4, 5, 6],
  TWO_STAR:   [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
  THREE_STAR: [3, 4, 5, 6, 7, 8, 9, 10],
  FOUR_STAR:  [6, 7, 8, 9, 10],
  FIVE_STAR:  [8, 9, 10],
};

const CHARITY_STAR_TO_PRESTIGE_RANGE = {
  ONE_STAR:   [0, 1, 2, 3],
  TWO_STAR:   [0, 1, 2, 3],
  THREE_STAR: [2, 3, 4, 5],
  FOUR_STAR:  [5, 6, 7],
  FIVE_STAR:  [7, 8, 9],
};
```

---

## 11. Gotchas & takeaways for CoachCarouselRecruitTool

1. **Commit binding is two-sided**: `Recruit.RecruitStage` + the recruit's `TopSchoolsList[0].TeamId`. Our decommit logic should do the inverse of `instantCommit`: flip `RecruitStage` back to the uncommitted value (literal UNVERIFIED — dump the enum from the schema), and decide what to do with slot 0 of `TopSchoolsList`. Keeping the old school somewhere in `TopSchoolsList` (per our spec) is directly supported: the list is an ordinary ref-array; slot 0 = current #1.
2. **`TeamId`/`TeamIndex` vs table row**: never confuse `Team` table row with `TeamIndex`; `RecruitTarget[]` row index IS `TeamIndex`; build the `TeamIndex → Team row` map exactly like `buildTeamIndexToRow`.
3. **`TeamInfluence` on the top-school element** appears to be the recruit-interest score for that school (written = `CommitScore`); prime candidate for our "small re-recruit edge" knob. UNVERIFIED semantics — needs live-save experimentation.
4. **RecruitTarget rows are a scarce resource**: the game pre-allocates a fixed board per school; the tool never creates rows, it re-uses ("hijacks") existing entries. Any writer we build must do the same or learn madden-franchise row allocation.
5. **User boards are detectable** by element refs pointing outside the AI `RecruitTarget` table — useful if we want to exempt (or include) the human team.
6. **No transactional safety**: partial writes stick even on `return false`; and dry-run mutates memory. Fine for a one-shot CLI, but a GUI should clone state or re-open the file after a dry run.
7. **README behavior note**: after force-committing, the game AI may still decommit some recruits or drop them from boards during week advance — stage+top-school edits are honored but not immune to downstream AI passes. Expect the same for our decommit/follow logic: verify results after one in-game advance.
8. `RecruitStageAdvance = 'InstantCommit'` looks like the mechanism telling the game engine to process the commit on next advance — we may want its other enum values (UNVERIFIED — dump from schema) for staged decommits.
9. The FBCHUNKS 8-byte magic + `dynasty*` no-dot filename filter is the proven save-discovery recipe (OneDrive-aware).
10. `LeagueSetting.CoachXPSpeedSetting` gets clobbered by the game whenever the League Settings menu is opened — any setting we write there needs the same caveat surfaced in UI.
