From 9b38f28c6fe98a4983f9265dfcfb37456de48843 Mon Sep 17 00:00:00 2001 From: Eric Date: Tue, 14 Jul 2026 16:01:00 -0500 Subject: [PATCH 1/5] docs: design Brooks live recruiting import --- ...14-brooks-live-recruiting-import-design.md | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-14-brooks-live-recruiting-import-design.md diff --git a/docs/superpowers/specs/2026-07-14-brooks-live-recruiting-import-design.md b/docs/superpowers/specs/2026-07-14-brooks-live-recruiting-import-design.md new file mode 100644 index 0000000..609037e --- /dev/null +++ b/docs/superpowers/specs/2026-07-14-brooks-live-recruiting-import-design.md @@ -0,0 +1,68 @@ +# Brooks Live Recruiting Import Design + +## Goal + +Bring Brooks's game-verified live recruiting mappings into `cfb27-lua-hook` as a small experimental SDK service for reading and safely rewriting already-allocated recruiting state. Do not require another CFB27/MMC test session and do not claim support for unproven row creation or runtime-cache materialization. + +## Evidence boundary + +The implementation is based on Brooks's July 14, 2026 evidence through upstream commit `b2b5a7ce4216c5838f1dbd2fb5a76dba6d67e7fe`, especially: + +- `recruiting-app/src/live-action-layout.json` version 1.2.0; +- `recruiting-app/src/live-action-write.js` guarded composers; +- the sanitized behavioral facts from the live action-map and pitch-reversal reports. + +Committed hook artifacts must not contain Brooks's PID, absolute addresses, save paths, player names, or raw memory dumps. Test fixtures will retain only minimal synthetic bytes and expected decoded values. + +## Architecture + +Keep the native host and protocol unchanged. Add a focused CommonJS SDK module that composes the hook's existing `scanMemory`, `readMemory`, and `writeTransaction` calls behind typed recruiting operations. This is faster and lower risk than expanding the native FrTk protocol or pretending a generic record-allocation API already exists. + +The module owns session calibration, codecs, validation, and expected-byte transactions. Consumers receive decoded recruiting values and stable sanitized errors, never addresses, masks, or byte buffers. + +## Supported surface + +The first delivery supports only operations backed by Brooks's completed evidence: + +- locate and read a `UserRecruitTarget` record for a selected recruit; +- read RecruitingBoard total, processed, and assigned hours from table 4251; +- enable or disable DM the Player, Browse Social Media, and Contact Friends & Family while adjusting assigned hours in the same guarded transaction; +- change `CurrentNILOffer` in place; +- rewrite the content of an existing ActiveRecruitingPitch row without allocating or freeing a row; +- rewrite the content of an existing ActiveVisitInfo row without allocating or freeing a row. + +`SendTheHouse` remains excluded until Brooks resolves the observed-bit versus schema-mask conflict. Scholarship offers, scouting, board membership changes, new pitch/visit creation, pitch/visit removal, and any operation that changes a FrTk freelist remain excluded. + +## Session and transaction flow + +1. Discover the current game and negotiate the existing memory and guarded-transaction capabilities. +2. Calibrate the authoritative live mirrors using the masked reference signatures from the upstream layout. +3. Reject zero or multiple authoritative candidates. +4. Bind calibration to the current PID and discard it when the game, host session, or selected save changes. +5. Read the current record and board-hour bytes immediately before composing a mutation. +6. Validate field ranges, action cost, available board hours, and existing pitch/visit references. +7. Submit one expected-byte `writeTransaction` containing every affected field and hours cell. +8. Return only decoded state or a stable sanitized error. + +The service must use the table 4251 RecruitingBoard mirror for hours. The earlier board-array hours candidate was a stale copy and is prohibited. + +## Failure behavior + +Every uncertainty fails closed. Calibration ambiguity, PID drift, missing references, unsupported actions, insufficient hours, unexpected bytes, transaction rollback, or malformed host responses produce a stable error and no partial success result. Dry-run composition remains available for tests and diagnostics, but no raw operations are returned to consumer applications. + +## Testing + +Testing is fully automated and offline: + +- pure codec tests for references, UserRecruitTarget fields, pitch values, visit values, and packed hours; +- calibration tests using a fake client and synthetic unique/ambiguous/no-match memory pages; +- composer tests derived from Brooks's verified before/after values; +- transaction tests proving contact bits and board hours are changed together; +- failure tests for stale PID, missing pitch/visit rows, insufficient hours, expected-byte mismatch, and rollback errors; +- boundary tests proving the public API returns no addresses, patterns, masks, or bytes. + +Run the existing repository checks and test suite. No CFB27, MMC, installed-host, weekly-advance, or autosave gate is required for this delivery. + +## Deferred work + +Brooks's freelist header discovery and `composeAlloc`/`composeFree` algorithm are retained as research input and synthetic fixtures only. A public allocation capability may be designed later if his staged advance test proves synthetic rows are processed correctly and the derived runtime-cache behavior is understood. That work is not part of this implementation. From d19ae73d3cb0a2d9436be578c6306450d65dc92d Mon Sep 17 00:00:00 2001 From: Eric Date: Tue, 14 Jul 2026 16:22:44 -0500 Subject: [PATCH 2/5] docs: plan Brooks live recruiting import --- ...026-07-14-brooks-live-recruiting-import.md | 459 ++++++++++++++++++ ...14-brooks-live-recruiting-import-design.md | 37 +- 2 files changed, 478 insertions(+), 18 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-14-brooks-live-recruiting-import.md diff --git a/docs/superpowers/plans/2026-07-14-brooks-live-recruiting-import.md b/docs/superpowers/plans/2026-07-14-brooks-live-recruiting-import.md new file mode 100644 index 0000000..3dc0003 --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-brooks-live-recruiting-import.md @@ -0,0 +1,459 @@ +# Brooks Live Recruiting Import Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a small typed SDK service for Brooks-verified, existing-row live recruiting reads and writes without allocation or another in-game test session. + +**Architecture:** Reuse the existing FrTk catalog, typed record reader, and guarded field transaction API. Promote only the four verified recruiting table mirrors at runtime, then expose decoded contact, NIL, pitch, visit, and board-hour operations through one CommonJS service; the caller continues resolving row numbers from its loaded save. + +**Tech Stack:** Node.js 20 CommonJS, `node:test`, C++20, CMake/Visual Studio 2022, existing FrTk protocol v1. + +## Global Constraints + +- Pin imported evidence to Brooks commit `b2b5a7ce4216c5838f1dbd2fb5a76dba6d67e7fe` and layout version `1.2.0`. +- Do not commit a PID, process address, save path, player name, raw memory dump, fingerprint pattern, or mask from Brooks's repository. +- Keep profile files `discovery_only`; runtime discovery alone may promote exact verified table identities to `direct_verified`. +- Support existing rows only. Do not allocate, free, add, remove, or modify any FrTk freelist. +- Exclude `SendTheHouse`, scholarships, scouting, board membership, pitch-intensity changes, and pitch/visit creation or removal. +- Use RecruitingBoard Unique ID `220276943` for hours; never use the stale board-array mirror. +- A contact toggle and its assigned-hours delta must be one `transactFrtkFields` call. +- Run automated offline tests only. Do not launch CFB27, MMC, an installed host, an advance, or an autosave test. + +--- + +### Task 1: Promote only the verified live recruiting mirrors + +**Files:** + +- Modify: `native/host/frtk_discovery.cpp` +- Modify: `native/smoke/frtk_discovery_smoke.cpp` + +**Interfaces:** + +- Consumes: resolved `TableProfile { table_id, unique_id, record_size }` from existing descriptor discovery. +- Produces: `TableDescriptor::direct_write_verified = true` only for the exact table triples listed below. + +- [ ] **Step 1: Add a failing authority smoke test** + +Add a fixture helper and test that run each table through the existing word-swapped descriptor discovery path: + +```cpp +TableProfile EvidenceTable(std::string name, std::uint16_t table_id, + std::uint32_t unique_id, + std::uint32_t record_size) { + auto table = Table(std::move(name), table_id, 10); + table.unique_id = unique_id; + table.record_size = record_size; + for (auto& row : table.rows) { + row.pattern.resize(record_size, static_cast(row.row_index)); + row.mask.assign(record_size, 0xFF); + } + return table; +} + +void TestEvidenceBackedDirectWriteAuthority() { + const std::array verified{ + std::tuple{"Recruit", 4269u, 1873209313u, 24u}, + std::tuple{"ProspectTargetSchool", 5840u, 3789266353u, 4u}, + std::tuple{"UserRecruitTarget", 4168u, 3987156317u, 36u}, + std::tuple{"ActiveVisitInfo", 4176u, 3093586546u, 4u}, + std::tuple{"ActiveRecruitingPitch", 4190u, 1559900276u, 4u}, + std::tuple{"RecruitingBoard", 4251u, 220276943u, 12u}, + }; + for (const auto& [name, table_id, unique_id, record_size] : verified) { + ProfileBundle bundle; + bundle.tables = {EvidenceTable(name, table_id, unique_id, record_size)}; + LoadSchema(bundle); + FakeBackend backend; + InstallLiveDescriptorTable(bundle.tables[0], backend, 0x500000, 0x600000, + 48, 16); + const auto result = DiscoverTables(bundle, backend); + Require(State(result, unique_id).descriptor->direct_write_verified, + "verified table did not receive direct authority"); + } + + ProfileBundle mismatch; + mismatch.tables = {EvidenceTable("UserRecruitTarget", 4168, 3987156317, 35)}; + LoadSchema(mismatch); + FakeBackend backend; + InstallLiveDescriptorTable(mismatch.tables[0], backend, 0x700000, 0x800000, + 48, 16); + const auto result = DiscoverTables(mismatch, backend); + Require(!State(result, 3987156317).descriptor->direct_write_verified, + "wrong record size received direct authority"); +} +``` + +Call `TestEvidenceBackedDirectWriteAuthority()` from `main()`. + +- [ ] **Step 2: Build and run the test to verify it fails** + +Run: + +```powershell +cmake --build native/build-frtk --config Release --target cfb27_frtk_discovery_smoke +native/build-frtk/Release/cfb27_frtk_discovery_smoke.exe +``` + +Expected: build succeeds and the executable fails with `verified table did not receive direct authority` for the first new recruiting table. + +- [ ] **Step 3: Replace the two-ID check with an exact evidence allowlist** + +In `frtk_discovery.cpp`, replace the standalone Recruit and ProspectTargetSchool constants with: + +```cpp +struct DirectWriteEvidence { + std::uint16_t table_id; + std::uint32_t unique_id; + std::uint32_t record_size; +}; + +constexpr std::array kDirectWriteEvidence{ + DirectWriteEvidence{4269, 1873209313u, 24}, + DirectWriteEvidence{5840, 3789266353u, 4}, + DirectWriteEvidence{4168, 3987156317u, 36}, + DirectWriteEvidence{4176, 3093586546u, 4}, + DirectWriteEvidence{4190, 1559900276u, 4}, + DirectWriteEvidence{4251, 220276943u, 12}, +}; + +bool HasDirectWriteEvidence(const TableProfile& table) { + return std::ranges::any_of(kDirectWriteEvidence, [&](const auto& evidence) { + return table.table_id == evidence.table_id && + table.unique_id == evidence.unique_id && + table.record_size == evidence.record_size; + }); +} +``` + +Set the descriptor member with `HasDirectWriteEvidence(table)`. Do not change file-profile authority parsing or the protocol. + +- [ ] **Step 4: Re-run the focused native smoke** + +Run the two commands from Step 2. + +Expected: `frtk discovery smoke passed`. + +- [ ] **Step 5: Commit the authority change** + +```powershell +git add -- native/host/frtk_discovery.cpp native/smoke/frtk_discovery_smoke.cpp +git commit -m "feat: verify live recruiting table authority" +``` + +--- + +### Task 2: Add the sanitized layout and typed recruiting service + +**Files:** + +- Create: `packages/sdk/src/live-recruiting-layout.cjs` +- Create: `packages/sdk/src/live-recruiting.cjs` +- Create: `packages/sdk/test/live-recruiting.test.cjs` +- Modify: `packages/sdk/src/errors.cjs` +- Modify: `packages/sdk/index.cjs` +- Modify: `package.json` + +**Interfaces:** + +- Consumes: `client.inspectFrtkCatalog`, `client.readFrtkRecords`, `client.transactFrtkFields`, and a current positive catalog generation. +- Produces: `LIVE_RECRUITING_EVIDENCE`, `LIVE_RECRUITING_TABLES`, `CONTACT_ACTIONS`, and async `createLiveRecruitingService({ client, generation })`. +- The resolved service produces `readState`, `setContactAction`, `setNilOffer`, `rewritePitch`, and `rewriteVisit` methods. + +- [ ] **Step 1: Write failing layout and service tests** + +Build a fake client that records reads and transactions. Cover these exact cases: + +```js +test('exports only sanitized verified table metadata', () => { + assert.equal(LIVE_RECRUITING_EVIDENCE.upstreamCommit, + 'b2b5a7ce4216c5838f1dbd2fb5a76dba6d67e7fe'); + assert.deepEqual(Object.values(LIVE_RECRUITING_TABLES).map((t) => t.uniqueId), + [3987156317, 3093586546, 1559900276, 220276943]); + assert.doesNotMatch(JSON.stringify(LIVE_RECRUITING_TABLES), + /address|patternHex|maskHex|recordHex|pid|savePath/i); +}); + +test('requires all four tables to have direct_verified authority', async () => { + const client = fakeTypedClient({ authorityOverride: 'discovery_only' }); + await assert.rejects(createLiveRecruitingService({ client, generation: 7 }), + (error) => error.code === 'FRTK_AUTHORITY_UNPROVEN'); +}); + +test('reads decoded target, board, pitch, and visit state', async () => { + const client = fakeTypedClient(); + const service = await createLiveRecruitingService({ client, generation: 7 }); + const state = await service.readState({ targetRow: 12, boardRow: 33, + pitchRow: 4, visitRow: 2 }); + assert.deepEqual(state.board, { + row: 33, total: 550, processed: 15, assigned: 85, available: 465, + }); + assert.deepEqual(state.contacts, { + 'dm-player': true, 'browse-social-media': false, 'friends-family': true, + }); + assert.deepEqual(state.pitch, { row: 4, pitch: 3, intensity: 0 }); + assert.deepEqual(state.visit, + { row: 2, weekNumber: 1, weekType: 1, activity: 3 }); +}); + +test('toggles a contact and assigned hours in one transaction', async () => { + const client = fakeTypedClient(); + const service = await createLiveRecruitingService({ client, generation: 7 }); + await service.setContactAction({ transactionId: 'recruiting.dm.1', + targetRow: 12, boardRow: 33, action: 'browse-social-media', enabled: true }); + assert.deepEqual(client.transactions[0].changes, [ + { uniqueId: 3987156317, row: 12, field: 'SearchSocialMedia', value: 1 }, + { uniqueId: 220276943, row: 33, field: 'RecruitingHoursAssigned', value: 90 }, + ]); +}); + +test('fails closed on insufficient hours and does not transact', async () => { + const client = fakeTypedClient({ board: { total: 100, processed: 10, assigned: 95 } }); + const service = await createLiveRecruitingService({ client, generation: 7 }); + await assert.rejects(service.setContactAction({ transactionId: 'recruiting.dm.2', + targetRow: 12, boardRow: 33, action: 'dm-player', enabled: true }), + (error) => error.code === 'RECRUITING_HOURS_INSUFFICIENT'); + assert.equal(client.transactions.length, 0); +}); + +test('dry runs and no-op writes never send a transaction', async () => { + const client = fakeTypedClient(); + const service = await createLiveRecruitingService({ client, generation: 7 }); + const preview = await service.setNilOffer({ transactionId: 'recruiting.nil.1', + targetRow: 12, amount: 200, dryRun: true }); + assert.equal(preview.status, 'dry_run'); + await service.setContactAction({ transactionId: 'recruiting.dm.3', + targetRow: 12, boardRow: 33, action: 'dm-player', enabled: true }); + assert.equal(client.transactions.length, 0); +}); + +test('rewrites only an existing pitch enum and an existing visit row', async () => { + const client = fakeTypedClient(); + const service = await createLiveRecruitingService({ client, generation: 7 }); + await service.rewritePitch({ transactionId: 'recruiting.pitch.1', pitchRow: 4, pitch: 4 }); + await service.rewriteVisit({ transactionId: 'recruiting.visit.1', visitRow: 2, + weekNumber: 2, weekType: 1, activity: 6 }); + assert.deepEqual(client.transactions[0].changes, + [{ uniqueId: 1559900276, row: 4, field: 'Pitch', value: 4 }]); + assert.deepEqual(client.transactions[1].changes, [ + { uniqueId: 3093586546, row: 2, field: 'WeekNumber', value: 2 }, + { uniqueId: 3093586546, row: 2, field: 'Activity', value: 6 }, + ]); +}); +``` + +The fake responses must use the real typed shape: `{ generation, records: [{ uniqueId, row, values: [{ field, value }] }] }`. Also assert NIL accepts `0..1023`, pitch accepts `0..19`, week accepts `0..31`, week type accepts `0..6`, activity accepts `0..13`, and unsupported actions return `RECRUITING_ACTION_UNSUPPORTED`. + +- [ ] **Step 2: Run the SDK test to verify it fails** + +```powershell +node --test packages/sdk/test/live-recruiting.test.cjs +``` + +Expected: FAIL because `../src/live-recruiting.cjs` does not exist. + +- [ ] **Step 3: Add the sanitized layout constants** + +`live-recruiting-layout.cjs` must define the four exact identities and only the fields needed by this slice. Use the existing FrTk field-definition shape: + +```js +'use strict'; + +const LIVE_RECRUITING_EVIDENCE = Object.freeze({ + upstreamCommit: 'b2b5a7ce4216c5838f1dbd2fb5a76dba6d67e7fe', + layoutVersion: '1.2.0', +}); + +const LIVE_RECRUITING_TABLES = Object.freeze({ + userTarget: Object.freeze({ logicalName: 'UserRecruitTarget', tableId: 4168, + uniqueId: 3987156317, capacity: 1120, recordSize: 36, + fields: Object.freeze([ + { name: 'CurrentNILOffer', encoding: 'bitfield', byteOffset: 30, + storageBytes: 2, bitOffset: 6, bitWidth: 10, minimum: 0, maximum: 1023, + referenceTableId: null }, + { name: 'ContactFriendsAndFamily', encoding: 'bitfield', byteOffset: 34, + storageBytes: 1, bitOffset: 4, bitWidth: 1, minimum: 0, maximum: 1, + referenceTableId: null }, + { name: 'ContactHighSchoolCoaches', encoding: 'bitfield', byteOffset: 34, + storageBytes: 1, bitOffset: 5, bitWidth: 1, minimum: 0, maximum: 1, + referenceTableId: null }, + { name: 'SearchSocialMedia', encoding: 'bitfield', byteOffset: 34, + storageBytes: 1, bitOffset: 6, bitWidth: 1, minimum: 0, maximum: 1, + referenceTableId: null }, + ]) }), + visit: Object.freeze({ logicalName: 'ActiveVisitInfo', tableId: 4176, + uniqueId: 3093586546, capacity: 4830, recordSize: 4, + fields: Object.freeze([ + { name: 'Activity', encoding: 'bitfield', byteOffset: 0, storageBytes: 3, + bitOffset: 0, bitWidth: 23, minimum: 0, maximum: 15, referenceTableId: null }, + { name: 'WeekType', encoding: 'bitfield', byteOffset: 2, storageBytes: 2, + bitOffset: 7, bitWidth: 4, minimum: 0, maximum: 8, referenceTableId: null }, + { name: 'WeekNumber', encoding: 'bitfield', byteOffset: 3, storageBytes: 1, + bitOffset: 3, bitWidth: 5, minimum: 0, maximum: 31, referenceTableId: null }, + ]) }), + pitch: Object.freeze({ logicalName: 'ActiveRecruitingPitch', tableId: 4190, + uniqueId: 1559900276, capacity: 9380, recordSize: 4, + fields: Object.freeze([ + { name: 'Intensity', encoding: 'bitfield', byteOffset: 0, storageBytes: 4, + bitOffset: 0, bitWidth: 27, minimum: 0, maximum: 4, referenceTableId: null }, + { name: 'Pitch', encoding: 'bitfield', byteOffset: 3, storageBytes: 1, + bitOffset: 3, bitWidth: 5, minimum: 0, maximum: 22, referenceTableId: null }, + ]) }), + board: Object.freeze({ logicalName: 'RecruitingBoard', tableId: 4251, + uniqueId: 220276943, capacity: 138, recordSize: 12, + fields: Object.freeze([ + { name: 'RecruitingHoursProcessed', encoding: 'bitfield', byteOffset: 4, + storageBytes: 3, bitOffset: 0, bitWidth: 20, minimum: 0, maximum: 4095, + referenceTableId: null }, + { name: 'RecruitingHoursTotal', encoding: 'bitfield', byteOffset: 6, + storageBytes: 2, bitOffset: 4, bitWidth: 12, minimum: 0, maximum: 4095, + referenceTableId: null }, + { name: 'RecruitingHoursAssigned', encoding: 'unsigned', byteOffset: 8, + storageBytes: 4, bitOffset: 0, bitWidth: 32, minimum: 0, maximum: 4095, + referenceTableId: null }, + ]) }), +}); + +module.exports = { LIVE_RECRUITING_EVIDENCE, LIVE_RECRUITING_TABLES }; +``` + +- [ ] **Step 4: Implement the service with strict decoded validation** + +Use these public action definitions and exact method signatures: + +```js +const CONTACT_ACTIONS = Object.freeze({ + 'dm-player': Object.freeze({ field: 'ContactHighSchoolCoaches', hours: 10 }), + 'browse-social-media': Object.freeze({ field: 'SearchSocialMedia', hours: 5 }), + 'friends-family': Object.freeze({ field: 'ContactFriendsAndFamily', hours: 25 }), +}); + +async function createLiveRecruitingService({ client, generation }) { + const catalog = await client.inspectFrtkCatalog({ generation }); + for (const table of Object.values(LIVE_RECRUITING_TABLES)) { + const found = catalog.tables.find((candidate) => candidate.uniqueId === table.uniqueId); + if (!found || found.authorityStatus !== 'direct_verified') { + throw new Cfb27HookError('FRTK_AUTHORITY_UNPROVEN', + 'Live recruiting table authority is unproven'); + } + } + return Object.freeze({ + readState, + setContactAction, + setNilOffer, + rewritePitch, + rewriteVisit, + }); +} +``` + +Implementation rules: + +- Convert ordered typed values with `Object.fromEntries(record.values.map(({ field, value }) => [field, value]))` and reject duplicate/missing/unexpected fields as `INVALID_RESPONSE`. +- `readState({ targetRow, boardRow, pitchRow, visitRow })` performs one read request. `pitchRow` and `visitRow` are optional; the other rows are required. +- Return `available = total - assigned`. `processed` is informational because immediate actions are already included in assigned hours. +- `setContactAction(...)` rereads target plus board, returns `unchanged` without a transaction when the bool already matches, and otherwise writes the bool plus the new assigned total in one transaction. +- On enable, reject `assigned + hours > total` with `RECRUITING_HOURS_INSUFFICIENT`. On disable, reject a negative assigned total as `INVALID_RESPONSE`. +- `setNilOffer(...)` rereads `CurrentNILOffer` and changes only that field. +- `rewritePitch(...)` rereads `Pitch` and `Intensity`, changes only `Pitch`, and returns the preserved intensity. It never accepts a new intensity. +- `rewriteVisit(...)` rereads all three visit fields, puts every differing value in one transaction, and returns `unchanged` when none differ. +- Every mutation accepts optional `dryRun: true`. A dry run returns `{ status: 'dry_run', changedFields, ...decodedSummary }` and never returns the internal `changes` array. +- Applied results return only `{ transactionId, status, changedFields, ...decodedSummary }`. +- Add `RECRUITING_ACTION_UNSUPPORTED` and `RECRUITING_HOURS_INSUFFICIENT` to `ERROR_CODES`; all other invalid public arguments use `INVALID_REQUEST` or `FRTK_FIELD_INVALID`. + +Export the constants and factory from `packages/sdk/index.cjs`, and add both new source files to the root `npm run check` command. + +- [ ] **Step 5: Run focused and full SDK verification** + +```powershell +node --test packages/sdk/test/live-recruiting.test.cjs +npm run check +npm test +``` + +Expected: the focused file passes, syntax checks exit 0, and the full Node test suite reports zero failures. + +- [ ] **Step 6: Commit the SDK service** + +```powershell +git add -- package.json packages/sdk/index.cjs packages/sdk/src/errors.cjs packages/sdk/src/live-recruiting-layout.cjs packages/sdk/src/live-recruiting.cjs packages/sdk/test/live-recruiting.test.cjs +git commit -m "feat: add typed live recruiting service" +``` + +--- + +### Task 3: Document the app wiring and run the offline release gate + +**Files:** + +- Modify: `docs/frtk-table-api.md` +- Modify: `docs/research/runtime-verification.md` + +**Interfaces:** + +- Consumes: Task 2 exports from `@cfb27/lua-hook`. +- Produces: a copyable app integration example and an evidence/scope record. + +- [ ] **Step 1: Add the minimal consumer example** + +Add a `Live recruiting service` section to `docs/frtk-table-api.md` containing this flow: + +```js +const { createClient, createLiveRecruitingService } = require('@cfb27/lua-hook'); + +const client = createClient({ pid }); +await client.loadFrtkProfile({ profile, layout }); +const { generation } = await client.discoverFrtkCatalog(); +const recruiting = await createLiveRecruitingService({ client, generation }); + +const state = await recruiting.readState({ + targetRow: selected.userRecruitTargetRow, + boardRow: selected.recruitingBoardRow, + pitchRow: selected.activePitchRow, + visitRow: selected.activeVisitRow, +}); + +await recruiting.setContactAction({ + transactionId: `recruiting.dm.${Date.now()}`, + targetRow: selected.userRecruitTargetRow, + boardRow: selected.recruitingBoardRow, + action: 'dm-player', + enabled: true, +}); +``` + +State plainly that the save-backed app supplies row numbers and profile fingerprints, the hook supplies live discovery/guarded transactions, and this API does not allocate rows. + +- [ ] **Step 2: Record the verified scope and exclusions** + +In `docs/research/runtime-verification.md`, add a short table with the four Unique IDs, table IDs, record sizes, Brooks commit, and supported operation. Follow it with one sentence excluding allocation/freelists, `SendTheHouse`, scholarships, scouting, board membership, pitch-intensity changes, and all in-game validation for this delivery. + +- [ ] **Step 3: Run the complete offline verification gate** + +```powershell +npm run check +npm test +cmake --build native/build-frtk --config Release --target cfb27_frtk_discovery_smoke cfb27_frtk_catalog_smoke cfb27_frtk_record_access_smoke +native/build-frtk/Release/cfb27_frtk_discovery_smoke.exe +native/build-frtk/Release/cfb27_frtk_catalog_smoke.exe +native/build-frtk/Release/cfb27_frtk_record_access_smoke.exe +``` + +Expected: both npm commands exit 0 and each native executable prints its `passed` line. Do not perform an installed-host or game launch after this gate. + +- [ ] **Step 4: Scan committed changes for prohibited evidence** + +```powershell +git diff --check +git diff --cached --check +rg -n "43372|0x366|0x369|0x384|Orlando|Gerald|DYNASTY-TESTER|savePath|patternHex|maskHex|recordHex" packages/sdk native docs/frtk-table-api.md docs/research/runtime-verification.md +``` + +Expected: both diff checks are silent. The repository scan may find generic API documentation for `patternHex`/`maskHex`; inspect every hit and confirm no new live address, person, save path, raw fingerprint, or memory dump was added by this work. + +- [ ] **Step 5: Commit documentation** + +```powershell +git add -- docs/frtk-table-api.md docs/research/runtime-verification.md +git commit -m "docs: explain live recruiting SDK wiring" +``` diff --git a/docs/superpowers/specs/2026-07-14-brooks-live-recruiting-import-design.md b/docs/superpowers/specs/2026-07-14-brooks-live-recruiting-import-design.md index 609037e..d87d7f8 100644 --- a/docs/superpowers/specs/2026-07-14-brooks-live-recruiting-import-design.md +++ b/docs/superpowers/specs/2026-07-14-brooks-live-recruiting-import-design.md @@ -16,50 +16,51 @@ Committed hook artifacts must not contain Brooks's PID, absolute addresses, save ## Architecture -Keep the native host and protocol unchanged. Add a focused CommonJS SDK module that composes the hook's existing `scanMemory`, `readMemory`, and `writeTransaction` calls behind typed recruiting operations. This is faster and lower risk than expanding the native FrTk protocol or pretending a generic record-allocation API already exists. +Keep the protocol unchanged and reuse the hook's existing typed FrTk catalog: `readFrtkRecords` for reads and `transactFrtkFields` for guarded writes. Add a focused CommonJS SDK service that accepts the discovered catalog generation plus caller-resolved row numbers. Brooks's app already resolves the selected target, board, pitch, and visit rows from the loaded save, so rebuilding his raw-address calibration layer inside the hook would duplicate work and expose more failure modes. -The module owns session calibration, codecs, validation, and expected-byte transactions. Consumers receive decoded recruiting values and stable sanitized errors, never addresses, masks, or byte buffers. +The only host-side authority change is to promote the four Brooks-verified table mirrors after normal profile discovery identifies their persistent Unique IDs and expected layouts: UserRecruitTarget `3987156317`, ActiveVisitInfo `3093586546`, ActiveRecruitingPitch `1559900276`, and RecruitingBoard `220276943`. File profiles remain `discovery_only`; runtime discovery performs the evidence-backed promotion exactly as it already does for Recruit and ProspectTargetSchool. + +The SDK service owns action-cost accounting, enum/range validation, and transaction composition. Consumers receive decoded recruiting values and stable sanitized errors, never addresses, masks, or byte buffers. ## Supported surface The first delivery supports only operations backed by Brooks's completed evidence: -- locate and read a `UserRecruitTarget` record for a selected recruit; +- read the caller-resolved `UserRecruitTarget` row for a selected recruit; - read RecruitingBoard total, processed, and assigned hours from table 4251; - enable or disable DM the Player, Browse Social Media, and Contact Friends & Family while adjusting assigned hours in the same guarded transaction; - change `CurrentNILOffer` in place; -- rewrite the content of an existing ActiveRecruitingPitch row without allocating or freeing a row; +- change the pitch enum in an existing ActiveRecruitingPitch row while preserving its current intensity and cost; - rewrite the content of an existing ActiveVisitInfo row without allocating or freeing a row. -`SendTheHouse` remains excluded until Brooks resolves the observed-bit versus schema-mask conflict. Scholarship offers, scouting, board membership changes, new pitch/visit creation, pitch/visit removal, and any operation that changes a FrTk freelist remain excluded. +`SendTheHouse` remains excluded until Brooks resolves the observed-bit versus schema-mask conflict. Scholarship offers, scouting, board membership changes, pitch-intensity changes, new pitch/visit creation, pitch/visit removal, and any operation that changes a FrTk freelist remain excluded. ## Session and transaction flow -1. Discover the current game and negotiate the existing memory and guarded-transaction capabilities. -2. Calibrate the authoritative live mirrors using the masked reference signatures from the upstream layout. -3. Reject zero or multiple authoritative candidates. -4. Bind calibration to the current PID and discard it when the game, host session, or selected save changes. -5. Read the current record and board-hour bytes immediately before composing a mutation. -6. Validate field ranges, action cost, available board hours, and existing pitch/visit references. -7. Submit one expected-byte `writeTransaction` containing every affected field and hours cell. -8. Return only decoded state or a stable sanitized error. +1. Load a caller-built FrTk profile, discover the catalog, and retain its generation. +2. Require all four recruiting tables to be present with `direct_verified` runtime authority. +3. Accept the target and board row numbers already resolved by the save-backed consumer; accept an existing pitch or visit row only for the corresponding content rewrite. +4. Read the current typed fields immediately before composing a mutation. +5. Validate field ranges, action cost, available board hours, and existing pitch/visit row selection. +6. Submit one `transactFrtkFields` call containing every affected field, including RecruitingBoard assigned hours for contact-action changes. +7. Return only decoded state or a stable sanitized error. The service must use the table 4251 RecruitingBoard mirror for hours. The earlier board-array hours candidate was a stale copy and is prohibited. ## Failure behavior -Every uncertainty fails closed. Calibration ambiguity, PID drift, missing references, unsupported actions, insufficient hours, unexpected bytes, transaction rollback, or malformed host responses produce a stable error and no partial success result. Dry-run composition remains available for tests and diagnostics, but no raw operations are returned to consumer applications. +Every uncertainty fails closed. Missing or stale catalog generations, unverified table authority, missing rows, unsupported actions, insufficient hours, transaction rollback, or malformed host responses produce a stable error and no partial success result. Dry-run composition remains available for tests and diagnostics, but no raw field-operation list is returned to consumer applications. ## Testing Testing is fully automated and offline: -- pure codec tests for references, UserRecruitTarget fields, pitch values, visit values, and packed hours; -- calibration tests using a fake client and synthetic unique/ambiguous/no-match memory pages; +- service tests using a fake typed client and synthetic decoded records; +- discovery smoke tests proving only the four pinned Unique IDs receive runtime write authority; - composer tests derived from Brooks's verified before/after values; - transaction tests proving contact bits and board hours are changed together; -- failure tests for stale PID, missing pitch/visit rows, insufficient hours, expected-byte mismatch, and rollback errors; -- boundary tests proving the public API returns no addresses, patterns, masks, or bytes. +- failure tests for stale generation, unverified authority, missing pitch/visit rows, insufficient hours, field-transaction failure, and rollback errors; +- boundary tests proving the public API returns no addresses, patterns, masks, bytes, or raw changes. Run the existing repository checks and test suite. No CFB27, MMC, installed-host, weekly-advance, or autosave gate is required for this delivery. From 0dcbf4ba74b42a4dc7d70e3c5d238b0f5c925cfe Mon Sep 17 00:00:00 2001 From: Eric Date: Tue, 14 Jul 2026 16:31:17 -0500 Subject: [PATCH 3/5] feat: verify live recruiting table authority --- native/host/frtk_discovery.cpp | 29 +++++++++++++--- native/smoke/frtk_discovery_smoke.cpp | 49 +++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 5 deletions(-) diff --git a/native/host/frtk_discovery.cpp b/native/host/frtk_discovery.cpp index 43f3782..23e855a 100644 --- a/native/host/frtk_discovery.cpp +++ b/native/host/frtk_discovery.cpp @@ -15,8 +15,29 @@ constexpr std::size_t kMaximumDescriptorBlobBytes = 64ull * 1024 * 1024; constexpr std::size_t kMaximumReadRangeBytes = 64ull * 1024; constexpr std::size_t kMaximumBatchBytes = 256ull * 1024; constexpr std::uint32_t kPlayerUniqueId = 1612938518u; -constexpr std::uint32_t kRecruitUniqueId = 1873209313u; -constexpr std::uint32_t kProspectTargetSchoolUniqueId = 3789266353u; + +struct DirectWriteEvidence { + std::uint16_t table_id; + std::uint32_t unique_id; + std::uint32_t record_size; +}; + +constexpr std::array kDirectWriteEvidence{ + DirectWriteEvidence{4269, 1873209313u, 24}, + DirectWriteEvidence{5840, 3789266353u, 4}, + DirectWriteEvidence{4168, 3987156317u, 36}, + DirectWriteEvidence{4176, 3093586546u, 4}, + DirectWriteEvidence{4190, 1559900276u, 4}, + DirectWriteEvidence{4251, 220276943u, 12}, +}; + +bool HasDirectWriteEvidence(const TableProfile& table) { + return std::ranges::any_of(kDirectWriteEvidence, [&](const auto& evidence) { + return table.table_id == evidence.table_id && + table.unique_id == evidence.unique_id && + table.record_size == evidence.record_size; + }); +} struct Candidate { TableDescriptor descriptor; @@ -292,9 +313,7 @@ TableDiscovery DiscoverWordSwappedDescriptor( .allocation_size = blob_size, .storage = TableStorage::kWordSwappedRecords, .direct_write_verified = - table.unique_id == kRecruitUniqueId || - table.unique_id == - kProspectTargetSchoolUniqueId}); + HasDirectWriteEvidence(table)}); } } if (candidates.size() == 1) { diff --git a/native/smoke/frtk_discovery_smoke.cpp b/native/smoke/frtk_discovery_smoke.cpp index 7a7e2ca..dab270f 100644 --- a/native/smoke/frtk_discovery_smoke.cpp +++ b/native/smoke/frtk_discovery_smoke.cpp @@ -67,6 +67,20 @@ TableProfile Table(std::string name, std::uint16_t id, std::uint32_t capacity) { return table; } +TableProfile EvidenceTable(std::string name, std::uint16_t table_id, + std::uint32_t unique_id, + std::uint32_t record_size) { + auto table = Table(std::move(name), table_id, 10); + table.unique_id = unique_id; + table.record_size = record_size; + for (auto& row : table.rows) { + row.pattern.resize(record_size, + static_cast(row.row_index)); + row.mask.assign(record_size, 0xFF); + } + return table; +} + json Field(std::string name, std::string encoding, std::uint32_t offset, std::uint32_t storage, std::uint32_t width, std::optional reference_table_id = std::nullopt) { @@ -495,6 +509,40 @@ void TestLiveDescriptorAndWordOrderDiscovery() { "live descriptor discovery performed global row scans"); } +void TestEvidenceBackedDirectWriteAuthority() { + const std::array verified{ + std::tuple{"Recruit", 4269u, 1873209313u, 24u}, + std::tuple{"ProspectTargetSchool", 5840u, 3789266353u, 4u}, + std::tuple{"UserRecruitTarget", 4168u, 3987156317u, 36u}, + std::tuple{"ActiveVisitInfo", 4176u, 3093586546u, 4u}, + std::tuple{"ActiveRecruitingPitch", 4190u, 1559900276u, 4u}, + std::tuple{"RecruitingBoard", 4251u, 220276943u, 12u}, + }; + for (const auto& [name, table_id, unique_id, record_size] : verified) { + ProfileBundle bundle; + bundle.tables = {EvidenceTable(name, static_cast(table_id), + unique_id, record_size)}; + LoadSchema(bundle); + FakeBackend backend; + InstallLiveDescriptorTable(bundle.tables[0], backend, 0x500000, 0x600000, + 48, 16); + const auto result = DiscoverTables(bundle, backend); + Require(State(result, unique_id).descriptor->direct_write_verified, + "verified table did not receive direct authority"); + } + + ProfileBundle mismatch; + mismatch.tables = { + EvidenceTable("UserRecruitTarget", 4168, 3987156317u, 35)}; + LoadSchema(mismatch); + FakeBackend backend; + InstallLiveDescriptorTable(mismatch.tables[0], backend, 0x700000, 0x800000, + 48, 16); + const auto result = DiscoverTables(mismatch, backend); + Require(!State(result, 3987156317u).descriptor->direct_write_verified, + "wrong record size received direct authority"); +} + void TestLiveDescriptorSkipsInvalidSignatureCopy() { auto bundle = Bundle(); bundle.tables.resize(1); @@ -892,6 +940,7 @@ void TestSchemaAuthoritativeRelationshipFields() { int main() { try { TestLiveDescriptorAndWordOrderDiscovery(); + TestEvidenceBackedDirectWriteAuthority(); TestLiveDescriptorSkipsInvalidSignatureCopy(); TestLiveDescriptorAcceptsAlternateEndPointerOffset(); TestLiveDescriptorRelationshipWordOrder(); From e3e317c3d06188becace271c96cc39d83a1ca49a Mon Sep 17 00:00:00 2001 From: Eric Date: Tue, 14 Jul 2026 16:37:29 -0500 Subject: [PATCH 4/5] feat: add typed live recruiting service --- package.json | 2 +- packages/sdk/index.cjs | 12 + packages/sdk/src/errors.cjs | 2 + packages/sdk/src/live-recruiting-layout.cjs | 114 +++++++ packages/sdk/src/live-recruiting.cjs | 355 ++++++++++++++++++++ packages/sdk/test/live-recruiting.test.cjs | 301 +++++++++++++++++ 6 files changed, 785 insertions(+), 1 deletion(-) create mode 100644 packages/sdk/src/live-recruiting-layout.cjs create mode 100644 packages/sdk/src/live-recruiting.cjs create mode 100644 packages/sdk/test/live-recruiting.test.cjs diff --git a/package.json b/package.json index acae5f3..c8e6b91 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "packages/cli" ], "scripts": { - "check": "node --check packages/sdk/index.cjs && node --check packages/sdk/src/validation.cjs && node --check packages/sdk/src/frtk-fields.cjs && node --check packages/sdk/src/frtk-profile.cjs && node --check packages/cli/bin/cfb27lua.cjs && node --check packages/cli/src/main.cjs && node --check scripts/build-frtk-profile.cjs && node --check scripts/package-release.cjs && node --check scripts/run-tests.cjs", + "check": "node --check packages/sdk/index.cjs && node --check packages/sdk/src/validation.cjs && node --check packages/sdk/src/frtk-fields.cjs && node --check packages/sdk/src/frtk-profile.cjs && node --check packages/sdk/src/live-recruiting-layout.cjs && node --check packages/sdk/src/live-recruiting.cjs && node --check packages/cli/bin/cfb27lua.cjs && node --check packages/cli/src/main.cjs && node --check scripts/build-frtk-profile.cjs && node --check scripts/package-release.cjs && node --check scripts/run-tests.cjs", "test": "node scripts/run-tests.cjs", "build:frtk-profile": "node scripts/build-frtk-profile.cjs", "pack:preview": "node scripts/package-release.cjs" diff --git a/packages/sdk/index.cjs b/packages/sdk/index.cjs index 0189b0d..2b38d0b 100644 --- a/packages/sdk/index.cjs +++ b/packages/sdk/index.cjs @@ -13,6 +13,14 @@ const { encodeField, } = require('./src/frtk-fields.cjs'); const { compileFrtkArtifacts } = require('./src/frtk-profile.cjs'); +const { + LIVE_RECRUITING_EVIDENCE, + LIVE_RECRUITING_TABLES, +} = require('./src/live-recruiting-layout.cjs'); +const { + CONTACT_ACTIONS, + createLiveRecruitingService, +} = require('./src/live-recruiting.cjs'); module.exports = { ERROR_CODES, @@ -31,4 +39,8 @@ module.exports = { decodeField, encodeField, compileFrtkArtifacts, + LIVE_RECRUITING_EVIDENCE, + LIVE_RECRUITING_TABLES, + CONTACT_ACTIONS, + createLiveRecruitingService, }; diff --git a/packages/sdk/src/errors.cjs b/packages/sdk/src/errors.cjs index 2a23a42..19b21b5 100644 --- a/packages/sdk/src/errors.cjs +++ b/packages/sdk/src/errors.cjs @@ -24,6 +24,8 @@ const ERROR_CODES = Object.freeze([ 'FRTK_CATALOG_STALE', 'FRTK_FIELD_INVALID', 'FRTK_AUTHORITY_UNPROVEN', + 'RECRUITING_ACTION_UNSUPPORTED', + 'RECRUITING_HOURS_INSUFFICIENT', 'SCAN_LIMIT_EXCEEDED', 'TOO_MANY_MATCHES', 'INSTALLATION_CONFLICT', diff --git a/packages/sdk/src/live-recruiting-layout.cjs b/packages/sdk/src/live-recruiting-layout.cjs new file mode 100644 index 0000000..6002395 --- /dev/null +++ b/packages/sdk/src/live-recruiting-layout.cjs @@ -0,0 +1,114 @@ +'use strict'; + +function deepFreeze(value) { + for (const child of Object.values(value)) { + if (child && typeof child === 'object' && !Object.isFrozen(child)) deepFreeze(child); + } + return Object.freeze(value); +} + +const LIVE_RECRUITING_EVIDENCE = deepFreeze({ + upstreamCommit: 'b2b5a7ce4216c5838f1dbd2fb5a76dba6d67e7fe', + layoutVersion: '1.2.0', +}); + +const LIVE_RECRUITING_TABLES = deepFreeze({ + userTarget: { + logicalName: 'UserRecruitTarget', + tableId: 4168, + uniqueId: 3987156317, + capacity: 1120, + recordSize: 36, + fields: [ + { + name: 'CurrentNILOffer', encoding: 'bitfield', byteOffset: 30, + storageBytes: 2, bitOffset: 6, bitWidth: 10, minimum: 0, maximum: 1023, + referenceTableId: null, + }, + { + name: 'ContactFriendsAndFamily', encoding: 'bitfield', byteOffset: 34, + storageBytes: 1, bitOffset: 4, bitWidth: 1, minimum: 0, maximum: 1, + referenceTableId: null, + }, + { + name: 'ContactHighSchoolCoaches', encoding: 'bitfield', byteOffset: 34, + storageBytes: 1, bitOffset: 5, bitWidth: 1, minimum: 0, maximum: 1, + referenceTableId: null, + }, + { + name: 'SearchSocialMedia', encoding: 'bitfield', byteOffset: 34, + storageBytes: 1, bitOffset: 6, bitWidth: 1, minimum: 0, maximum: 1, + referenceTableId: null, + }, + ], + }, + visit: { + logicalName: 'ActiveVisitInfo', + tableId: 4176, + uniqueId: 3093586546, + capacity: 4830, + recordSize: 4, + fields: [ + { + name: 'Activity', encoding: 'bitfield', byteOffset: 0, storageBytes: 3, + bitOffset: 0, bitWidth: 23, minimum: 0, maximum: 15, + referenceTableId: null, + }, + { + name: 'WeekType', encoding: 'bitfield', byteOffset: 2, storageBytes: 2, + bitOffset: 7, bitWidth: 4, minimum: 0, maximum: 8, + referenceTableId: null, + }, + { + name: 'WeekNumber', encoding: 'bitfield', byteOffset: 3, storageBytes: 1, + bitOffset: 3, bitWidth: 5, minimum: 0, maximum: 31, + referenceTableId: null, + }, + ], + }, + pitch: { + logicalName: 'ActiveRecruitingPitch', + tableId: 4190, + uniqueId: 1559900276, + capacity: 9380, + recordSize: 4, + fields: [ + { + name: 'Intensity', encoding: 'bitfield', byteOffset: 0, storageBytes: 4, + bitOffset: 0, bitWidth: 27, minimum: 0, maximum: 4, + referenceTableId: null, + }, + { + name: 'Pitch', encoding: 'bitfield', byteOffset: 3, storageBytes: 1, + bitOffset: 3, bitWidth: 5, minimum: 0, maximum: 22, + referenceTableId: null, + }, + ], + }, + board: { + logicalName: 'RecruitingBoard', + tableId: 4251, + uniqueId: 220276943, + capacity: 138, + recordSize: 12, + fields: [ + { + name: 'RecruitingHoursProcessed', encoding: 'bitfield', byteOffset: 4, + storageBytes: 3, bitOffset: 0, bitWidth: 20, minimum: 0, maximum: 4095, + referenceTableId: null, + }, + { + name: 'RecruitingHoursTotal', encoding: 'bitfield', byteOffset: 6, + storageBytes: 2, bitOffset: 4, bitWidth: 12, minimum: 0, maximum: 4095, + referenceTableId: null, + }, + { + name: 'RecruitingHoursAssigned', encoding: 'unsigned', byteOffset: 8, + storageBytes: 4, bitOffset: 0, bitWidth: 32, minimum: 0, maximum: 4095, + referenceTableId: null, + }, + ], + }, +}); + +module.exports = { LIVE_RECRUITING_EVIDENCE, LIVE_RECRUITING_TABLES }; diff --git a/packages/sdk/src/live-recruiting.cjs b/packages/sdk/src/live-recruiting.cjs new file mode 100644 index 0000000..a8bbd9c --- /dev/null +++ b/packages/sdk/src/live-recruiting.cjs @@ -0,0 +1,355 @@ +'use strict'; + +const { Cfb27HookError } = require('./errors.cjs'); +const { hasOnlyKeys, isSafeIntegerBetween } = require('./validation.cjs'); +const { LIVE_RECRUITING_TABLES } = require('./live-recruiting-layout.cjs'); + +const TRANSACTION_ID = /^[A-Za-z0-9._-]{1,64}$/; +const MAX_ROW = 0xFFFFFFFF; + +const CONTACT_ACTIONS = Object.freeze({ + 'dm-player': Object.freeze({ field: 'ContactHighSchoolCoaches', hours: 10 }), + 'browse-social-media': Object.freeze({ field: 'SearchSocialMedia', hours: 5 }), + 'friends-family': Object.freeze({ field: 'ContactFriendsAndFamily', hours: 25 }), +}); + +const TARGET_FIELDS = Object.freeze([ + 'CurrentNILOffer', + 'ContactHighSchoolCoaches', + 'SearchSocialMedia', + 'ContactFriendsAndFamily', +]); +const BOARD_FIELDS = Object.freeze([ + 'RecruitingHoursProcessed', + 'RecruitingHoursTotal', + 'RecruitingHoursAssigned', +]); +const PITCH_FIELDS = Object.freeze(['Pitch', 'Intensity']); +const VISIT_FIELDS = Object.freeze(['WeekNumber', 'WeekType', 'Activity']); + +function fail(code, message) { + return new Cfb27HookError(code, message); +} + +function invalidRequest() { + return fail('INVALID_REQUEST', 'Live recruiting request is invalid'); +} + +function invalidField() { + return fail('FRTK_FIELD_INVALID', 'Live recruiting field or value is invalid'); +} + +function invalidResponse() { + return fail('INVALID_RESPONSE', 'Live recruiting state is invalid'); +} + +function requireRow(value) { + if (!isSafeIntegerBetween(value, 0, MAX_ROW)) throw invalidRequest(); + return value; +} + +function requireRange(value, minimum, maximum) { + if (!isSafeIntegerBetween(value, minimum, maximum)) throw invalidField(); + return value; +} + +function requireMutation(options, keys) { + const allowed = [...keys, 'dryRun']; + if (!hasOnlyKeys(options, allowed) || keys.some((key) => !Object.hasOwn(options, key)) || + (Object.hasOwn(options, 'dryRun') && typeof options.dryRun !== 'boolean') || + typeof options.transactionId !== 'string' || !TRANSACTION_ID.test(options.transactionId)) { + throw invalidRequest(); + } + return Boolean(options.dryRun); +} + +function selector(table, row, fields) { + return { uniqueId: table.uniqueId, row, fields: [...fields] }; +} + +function decodeRecord(record, expected) { + if (!record || typeof record !== 'object' || Array.isArray(record) || + Object.keys(record).sort().join(',') !== 'row,uniqueId,values' || + record.uniqueId !== expected.uniqueId || record.row !== expected.row || + !Array.isArray(record.values) || record.values.length !== expected.fields.length) { + throw invalidResponse(); + } + const values = {}; + for (let index = 0; index < expected.fields.length; index += 1) { + const item = record.values[index]; + const field = expected.fields[index]; + if (!item || typeof item !== 'object' || Array.isArray(item) || + Object.keys(item).sort().join(',') !== 'field,value' || + item.field !== field || Object.hasOwn(values, field) || + !Number.isSafeInteger(item.value)) { + throw invalidResponse(); + } + values[field] = item.value; + } + return values; +} + +function requireDecodedRange(values, field, minimum, maximum) { + if (!isSafeIntegerBetween(values[field], minimum, maximum)) throw invalidResponse(); +} + +function validateTarget(values) { + requireDecodedRange(values, 'CurrentNILOffer', 0, 1023); + for (const field of TARGET_FIELDS.slice(1)) requireDecodedRange(values, field, 0, 1); +} + +function validateBoard(values) { + for (const field of BOARD_FIELDS) requireDecodedRange(values, field, 0, 4095); + if (values.RecruitingHoursAssigned > values.RecruitingHoursTotal) { + throw invalidResponse(); + } +} + +function validatePitch(values) { + requireDecodedRange(values, 'Pitch', 0, 19); + requireDecodedRange(values, 'Intensity', 0, 2); +} + +function validateVisit(values) { + requireDecodedRange(values, 'WeekNumber', 0, 31); + requireDecodedRange(values, 'WeekType', 0, 6); + requireDecodedRange(values, 'Activity', 0, 13); +} + +async function createLiveRecruitingService(options = {}) { + if (!options || typeof options !== 'object' || Array.isArray(options) || + Object.keys(options).sort().join(',') !== 'client,generation' || + !options.client || typeof options.client.inspectFrtkCatalog !== 'function' || + typeof options.client.readFrtkRecords !== 'function' || + typeof options.client.transactFrtkFields !== 'function' || + !isSafeIntegerBetween(options.generation, 1, Number.MAX_SAFE_INTEGER)) { + throw invalidRequest(); + } + const { client, generation } = options; + const catalog = await client.inspectFrtkCatalog({ generation }); + if (!catalog || catalog.generation !== generation || !Array.isArray(catalog.tables)) { + throw invalidResponse(); + } + for (const table of Object.values(LIVE_RECRUITING_TABLES)) { + const found = catalog.tables.find((candidate) => candidate?.uniqueId === table.uniqueId); + if (!found || found.authorityStatus !== 'direct_verified') { + throw fail('FRTK_AUTHORITY_UNPROVEN', 'Live recruiting table authority is unproven'); + } + } + + async function readSelectors(selectors) { + const result = await client.readFrtkRecords({ generation, records: selectors }); + if (!result || result.generation !== generation || !Array.isArray(result.records) || + result.records.length !== selectors.length) { + throw invalidResponse(); + } + return result.records.map((record, index) => decodeRecord(record, selectors[index])); + } + + async function applyChanges(transactionId, changes, summary, dryRun) { + if (changes.length === 0) { + return { transactionId, status: 'unchanged', changedFields: 0, ...summary }; + } + if (dryRun) { + return { transactionId, status: 'dry_run', changedFields: changes.length, ...summary }; + } + const result = await client.transactFrtkFields({ transactionId, generation, changes }); + if (!result || result.transactionId !== transactionId || + result.status !== 'applied_verified' || result.changedFields !== changes.length) { + throw invalidResponse(); + } + return { + transactionId: result.transactionId, + status: result.status, + changedFields: result.changedFields, + ...summary, + }; + } + + async function readState(readOptions = {}) { + if (!hasOnlyKeys(readOptions, ['targetRow', 'boardRow', 'pitchRow', 'visitRow']) || + !Object.hasOwn(readOptions, 'targetRow') || !Object.hasOwn(readOptions, 'boardRow')) { + throw invalidRequest(); + } + const targetRow = requireRow(readOptions.targetRow); + const boardRow = requireRow(readOptions.boardRow); + const selectors = [ + selector(LIVE_RECRUITING_TABLES.userTarget, targetRow, TARGET_FIELDS), + selector(LIVE_RECRUITING_TABLES.board, boardRow, BOARD_FIELDS), + ]; + if (Object.hasOwn(readOptions, 'pitchRow')) { + selectors.push(selector(LIVE_RECRUITING_TABLES.pitch, + requireRow(readOptions.pitchRow), PITCH_FIELDS)); + } + if (Object.hasOwn(readOptions, 'visitRow')) { + selectors.push(selector(LIVE_RECRUITING_TABLES.visit, + requireRow(readOptions.visitRow), VISIT_FIELDS)); + } + const records = await readSelectors(selectors); + const target = records[0]; + const board = records[1]; + validateTarget(target); + validateBoard(board); + let cursor = 2; + let pitch = null; + let visit = null; + if (Object.hasOwn(readOptions, 'pitchRow')) { + const values = records[cursor++]; + validatePitch(values); + pitch = { row: readOptions.pitchRow, pitch: values.Pitch, intensity: values.Intensity }; + } + if (Object.hasOwn(readOptions, 'visitRow')) { + const values = records[cursor]; + validateVisit(values); + visit = { + row: readOptions.visitRow, + weekNumber: values.WeekNumber, + weekType: values.WeekType, + activity: values.Activity, + }; + } + return { + generation, + targetRow, + currentNilOffer: target.CurrentNILOffer, + contacts: { + 'dm-player': Boolean(target.ContactHighSchoolCoaches), + 'browse-social-media': Boolean(target.SearchSocialMedia), + 'friends-family': Boolean(target.ContactFriendsAndFamily), + }, + board: { + row: boardRow, + total: board.RecruitingHoursTotal, + processed: board.RecruitingHoursProcessed, + assigned: board.RecruitingHoursAssigned, + available: board.RecruitingHoursTotal - board.RecruitingHoursAssigned, + }, + pitch, + visit, + }; + } + + async function setContactAction(mutation = {}) { + const dryRun = requireMutation(mutation, + ['transactionId', 'targetRow', 'boardRow', 'action', 'enabled']); + const targetRow = requireRow(mutation.targetRow); + const boardRow = requireRow(mutation.boardRow); + if (typeof mutation.enabled !== 'boolean') throw invalidRequest(); + const action = CONTACT_ACTIONS[mutation.action]; + if (!action) { + throw fail('RECRUITING_ACTION_UNSUPPORTED', 'Recruiting action is unsupported'); + } + const state = await readState({ targetRow, boardRow }); + const enabled = mutation.enabled; + if (state.contacts[mutation.action] === enabled) { + return { + transactionId: mutation.transactionId, + status: 'unchanged', + changedFields: 0, + action: mutation.action, + enabled, + assignedHours: state.board.assigned, + availableHours: state.board.available, + }; + } + const nextAssigned = state.board.assigned + (enabled ? action.hours : -action.hours); + if (enabled && nextAssigned > state.board.total) { + throw fail('RECRUITING_HOURS_INSUFFICIENT', 'Recruiting hours are insufficient'); + } + if (nextAssigned < 0) throw invalidResponse(); + return applyChanges(mutation.transactionId, [ + { uniqueId: LIVE_RECRUITING_TABLES.userTarget.uniqueId, row: targetRow, + field: action.field, value: enabled ? 1 : 0 }, + { uniqueId: LIVE_RECRUITING_TABLES.board.uniqueId, row: boardRow, + field: 'RecruitingHoursAssigned', value: nextAssigned }, + ], { + action: mutation.action, + enabled, + assignedHours: nextAssigned, + availableHours: state.board.total - nextAssigned, + }, dryRun); + } + + async function setNilOffer(mutation = {}) { + const dryRun = requireMutation(mutation, + ['transactionId', 'targetRow', 'amount']); + const targetRow = requireRow(mutation.targetRow); + const amount = requireRange(mutation.amount, 0, 1023); + const [target] = await readSelectors([ + selector(LIVE_RECRUITING_TABLES.userTarget, targetRow, ['CurrentNILOffer']), + ]); + requireDecodedRange(target, 'CurrentNILOffer', 0, 1023); + const changes = target.CurrentNILOffer === amount ? [] : [{ + uniqueId: LIVE_RECRUITING_TABLES.userTarget.uniqueId, + row: targetRow, + field: 'CurrentNILOffer', + value: amount, + }]; + return applyChanges(mutation.transactionId, changes, { + currentNilOffer: target.CurrentNILOffer, + nextNilOffer: amount, + }, dryRun); + } + + async function rewritePitch(mutation = {}) { + const dryRun = requireMutation(mutation, + ['transactionId', 'pitchRow', 'pitch']); + const pitchRow = requireRow(mutation.pitchRow); + const pitch = requireRange(mutation.pitch, 0, 19); + const [values] = await readSelectors([ + selector(LIVE_RECRUITING_TABLES.pitch, pitchRow, PITCH_FIELDS), + ]); + validatePitch(values); + const changes = values.Pitch === pitch ? [] : [{ + uniqueId: LIVE_RECRUITING_TABLES.pitch.uniqueId, + row: pitchRow, + field: 'Pitch', + value: pitch, + }]; + return applyChanges(mutation.transactionId, changes, { + pitchRow, + pitch, + intensity: values.Intensity, + }, dryRun); + } + + async function rewriteVisit(mutation = {}) { + const dryRun = requireMutation(mutation, + ['transactionId', 'visitRow', 'weekNumber', 'weekType', 'activity']); + const visitRow = requireRow(mutation.visitRow); + const requested = { + WeekNumber: requireRange(mutation.weekNumber, 0, 31), + WeekType: requireRange(mutation.weekType, 0, 6), + Activity: requireRange(mutation.activity, 0, 13), + }; + const [values] = await readSelectors([ + selector(LIVE_RECRUITING_TABLES.visit, visitRow, VISIT_FIELDS), + ]); + validateVisit(values); + const changes = VISIT_FIELDS.filter((field) => values[field] !== requested[field]) + .map((field) => ({ + uniqueId: LIVE_RECRUITING_TABLES.visit.uniqueId, + row: visitRow, + field, + value: requested[field], + })); + return applyChanges(mutation.transactionId, changes, { + visit: { + row: visitRow, + weekNumber: requested.WeekNumber, + weekType: requested.WeekType, + activity: requested.Activity, + }, + }, dryRun); + } + + return Object.freeze({ + readState, + setContactAction, + setNilOffer, + rewritePitch, + rewriteVisit, + }); +} + +module.exports = { CONTACT_ACTIONS, createLiveRecruitingService }; diff --git a/packages/sdk/test/live-recruiting.test.cjs b/packages/sdk/test/live-recruiting.test.cjs new file mode 100644 index 0000000..110483c --- /dev/null +++ b/packages/sdk/test/live-recruiting.test.cjs @@ -0,0 +1,301 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { encodeField, decodeField } = require('../src/frtk-fields.cjs'); +const { + LIVE_RECRUITING_EVIDENCE, + LIVE_RECRUITING_TABLES, +} = require('../src/live-recruiting-layout.cjs'); +const { + CONTACT_ACTIONS, + createLiveRecruitingService, +} = require('../src/live-recruiting.cjs'); + +const DEFAULT_STATE = Object.freeze({ + target: Object.freeze({ + CurrentNILOffer: 180, + ContactHighSchoolCoaches: 1, + SearchSocialMedia: 0, + ContactFriendsAndFamily: 1, + }), + board: Object.freeze({ + RecruitingHoursTotal: 550, + RecruitingHoursProcessed: 15, + RecruitingHoursAssigned: 85, + }), + pitch: Object.freeze({ Pitch: 3, Intensity: 0 }), + visit: Object.freeze({ WeekNumber: 1, WeekType: 1, Activity: 3 }), +}); + +function clone(value) { + return JSON.parse(JSON.stringify(value)); +} + +function fakeTypedClient(options = {}) { + const state = clone(DEFAULT_STATE); + for (const key of ['target', 'board', 'pitch', 'visit']) { + Object.assign(state[key], options[key] || {}); + } + const transactions = []; + const reads = []; + const identities = Object.values(LIVE_RECRUITING_TABLES); + const byUniqueId = new Map([ + [LIVE_RECRUITING_TABLES.userTarget.uniqueId, state.target], + [LIVE_RECRUITING_TABLES.board.uniqueId, state.board], + [LIVE_RECRUITING_TABLES.pitch.uniqueId, state.pitch], + [LIVE_RECRUITING_TABLES.visit.uniqueId, state.visit], + ]); + + return { + transactions, + reads, + async inspectFrtkCatalog({ generation }) { + return { + generation, + tables: identities.map((table) => ({ + uniqueId: table.uniqueId, + logicalName: table.logicalName, + authorityStatus: options.authorityOverride || 'direct_verified', + capacity: table.capacity, + profileId: 'synthetic-profile', + generation, + evidence: [], + })), + }; + }, + async readFrtkRecords(request) { + reads.push(clone(request)); + if (options.readResult) return clone(options.readResult); + return { + generation: request.generation, + records: request.records.map((selector) => { + const source = byUniqueId.get(selector.uniqueId); + return { + uniqueId: selector.uniqueId, + row: selector.row, + values: selector.fields.map((field) => ({ field, value: source[field] })), + }; + }), + }; + }, + async transactFrtkFields(request) { + transactions.push(clone(request)); + for (const change of request.changes) { + byUniqueId.get(change.uniqueId)[change.field] = change.value; + } + return { + transactionId: request.transactionId, + status: 'applied_verified', + changedFields: request.changes.length, + }; + }, + }; +} + +test('exports only sanitized verified table metadata', () => { + assert.equal(LIVE_RECRUITING_EVIDENCE.upstreamCommit, + 'b2b5a7ce4216c5838f1dbd2fb5a76dba6d67e7fe'); + assert.equal(LIVE_RECRUITING_EVIDENCE.layoutVersion, '1.2.0'); + assert.deepEqual(Object.values(LIVE_RECRUITING_TABLES).map((table) => table.uniqueId), + [3987156317, 3093586546, 1559900276, 220276943]); + assert.doesNotMatch(JSON.stringify(LIVE_RECRUITING_TABLES), + /address|patternHex|maskHex|recordHex|pid|savePath/i); +}); + +test('sanitized field definitions encode Brooks verified packed values', () => { + const find = (table, field) => table.fields.find((candidate) => candidate.name === field); + let target = Buffer.alloc(36); + target = encodeField(target, find(LIVE_RECRUITING_TABLES.userTarget, + 'ContactFriendsAndFamily'), 1); + target = encodeField(target, find(LIVE_RECRUITING_TABLES.userTarget, + 'ContactHighSchoolCoaches'), 1); + target = encodeField(target, find(LIVE_RECRUITING_TABLES.userTarget, + 'SearchSocialMedia'), 1); + assert.equal(target[34], 0x0E); + + let pitch = Buffer.alloc(4); + pitch = encodeField(pitch, find(LIVE_RECRUITING_TABLES.pitch, 'Intensity'), 2); + pitch = encodeField(pitch, find(LIVE_RECRUITING_TABLES.pitch, 'Pitch'), 2); + assert.deepEqual(pitch, Buffer.from([0, 0, 0, 0x42])); + + let visit = Buffer.alloc(4); + visit = encodeField(visit, find(LIVE_RECRUITING_TABLES.visit, 'WeekNumber'), 1); + visit = encodeField(visit, find(LIVE_RECRUITING_TABLES.visit, 'WeekType'), 1); + visit = encodeField(visit, find(LIVE_RECRUITING_TABLES.visit, 'Activity'), 3); + assert.deepEqual(visit, Buffer.from([0, 0, 0x06, 0x21])); + assert.equal(decodeField(visit, find(LIVE_RECRUITING_TABLES.visit, 'Activity')), 3); +}); + +test('requires all four tables to have direct_verified authority', async () => { + const client = fakeTypedClient({ authorityOverride: 'discovery_only' }); + await assert.rejects(createLiveRecruitingService({ client, generation: 7 }), + (error) => error.code === 'FRTK_AUTHORITY_UNPROVEN'); +}); + +test('reads decoded target, board, pitch, and visit state', async () => { + const client = fakeTypedClient(); + const service = await createLiveRecruitingService({ client, generation: 7 }); + const state = await service.readState({ + targetRow: 12, + boardRow: 33, + pitchRow: 4, + visitRow: 2, + }); + assert.deepEqual(state.board, { + row: 33, + total: 550, + processed: 15, + assigned: 85, + available: 465, + }); + assert.deepEqual(state.contacts, { + 'dm-player': true, + 'browse-social-media': false, + 'friends-family': true, + }); + assert.equal(state.currentNilOffer, 180); + assert.deepEqual(state.pitch, { row: 4, pitch: 3, intensity: 0 }); + assert.deepEqual(state.visit, + { row: 2, weekNumber: 1, weekType: 1, activity: 3 }); + assert.doesNotMatch(JSON.stringify(state), + /address|bytes|changes|mask|pattern/i); +}); + +test('toggles a contact and assigned hours in one transaction', async () => { + const client = fakeTypedClient(); + const service = await createLiveRecruitingService({ client, generation: 7 }); + const result = await service.setContactAction({ + transactionId: 'recruiting.dm.1', + targetRow: 12, + boardRow: 33, + action: 'browse-social-media', + enabled: true, + }); + assert.deepEqual(client.transactions[0].changes, [ + { uniqueId: 3987156317, row: 12, field: 'SearchSocialMedia', value: 1 }, + { uniqueId: 220276943, row: 33, field: 'RecruitingHoursAssigned', value: 90 }, + ]); + assert.deepEqual(result, { + transactionId: 'recruiting.dm.1', + status: 'applied_verified', + changedFields: 2, + action: 'browse-social-media', + enabled: true, + assignedHours: 90, + availableHours: 460, + }); +}); + +test('fails closed on insufficient hours and does not transact', async () => { + const client = fakeTypedClient({ + target: { ContactHighSchoolCoaches: 0 }, + board: { total: 100, processed: 10, assigned: 95, + RecruitingHoursTotal: 100, RecruitingHoursProcessed: 10, + RecruitingHoursAssigned: 95 }, + }); + const service = await createLiveRecruitingService({ client, generation: 7 }); + await assert.rejects(service.setContactAction({ + transactionId: 'recruiting.dm.2', + targetRow: 12, + boardRow: 33, + action: 'dm-player', + enabled: true, + }), (error) => error.code === 'RECRUITING_HOURS_INSUFFICIENT'); + assert.equal(client.transactions.length, 0); +}); + +test('dry runs and no-op writes never send a transaction or raw changes', async () => { + const client = fakeTypedClient(); + const service = await createLiveRecruitingService({ client, generation: 7 }); + const preview = await service.setNilOffer({ + transactionId: 'recruiting.nil.1', + targetRow: 12, + amount: 200, + dryRun: true, + }); + assert.deepEqual(preview, { + transactionId: 'recruiting.nil.1', + status: 'dry_run', + changedFields: 1, + currentNilOffer: 180, + nextNilOffer: 200, + }); + assert.doesNotMatch(JSON.stringify(preview), /changes|address|bytes/i); + const noOp = await service.setContactAction({ + transactionId: 'recruiting.dm.3', + targetRow: 12, + boardRow: 33, + action: 'dm-player', + enabled: true, + }); + assert.equal(noOp.status, 'unchanged'); + assert.equal(client.transactions.length, 0); +}); + +test('rewrites only an existing pitch enum and an existing visit row', async () => { + const client = fakeTypedClient(); + const service = await createLiveRecruitingService({ client, generation: 7 }); + const pitch = await service.rewritePitch({ + transactionId: 'recruiting.pitch.1', + pitchRow: 4, + pitch: 4, + }); + const visit = await service.rewriteVisit({ + transactionId: 'recruiting.visit.1', + visitRow: 2, + weekNumber: 2, + weekType: 1, + activity: 6, + }); + assert.deepEqual(client.transactions[0].changes, + [{ uniqueId: 1559900276, row: 4, field: 'Pitch', value: 4 }]); + assert.deepEqual(client.transactions[1].changes, [ + { uniqueId: 3093586546, row: 2, field: 'WeekNumber', value: 2 }, + { uniqueId: 3093586546, row: 2, field: 'Activity', value: 6 }, + ]); + assert.equal(pitch.intensity, 0); + assert.deepEqual(visit.visit, + { row: 2, weekNumber: 2, weekType: 1, activity: 6 }); +}); + +test('validates supported actions and decoded enum ranges', async () => { + const client = fakeTypedClient(); + const service = await createLiveRecruitingService({ client, generation: 7 }); + assert.deepEqual(Object.keys(CONTACT_ACTIONS), + ['dm-player', 'browse-social-media', 'friends-family']); + await assert.rejects(service.setContactAction({ + transactionId: 'recruiting.unsupported.1', targetRow: 12, boardRow: 33, + action: 'send-the-house', enabled: true, + }), (error) => error.code === 'RECRUITING_ACTION_UNSUPPORTED'); + for (const [method, options] of [ + ['setNilOffer', { transactionId: 'range.nil', targetRow: 12, amount: 1024 }], + ['rewritePitch', { transactionId: 'range.pitch', pitchRow: 4, pitch: 20 }], + ['rewriteVisit', { transactionId: 'range.week', visitRow: 2, + weekNumber: 32, weekType: 1, activity: 3 }], + ['rewriteVisit', { transactionId: 'range.weektype', visitRow: 2, + weekNumber: 1, weekType: 7, activity: 3 }], + ['rewriteVisit', { transactionId: 'range.activity', visitRow: 2, + weekNumber: 1, weekType: 1, activity: 14 }], + ]) { + await assert.rejects(service[method](options), + (error) => error.code === 'FRTK_FIELD_INVALID'); + } + assert.equal(client.transactions.length, 0); +}); + +test('rejects malformed typed read responses without leaking host data', async () => { + const client = fakeTypedClient({ + readResult: { + generation: 7, + records: [{ uniqueId: 3987156317, row: 12, + values: [{ field: 'CurrentNILOffer', value: 180 }, + { field: 'CurrentNILOffer', value: 999 }] }], + }, + }); + const service = await createLiveRecruitingService({ client, generation: 7 }); + await assert.rejects(service.setNilOffer({ + transactionId: 'recruiting.bad-response', targetRow: 12, amount: 200, + }), (error) => error.code === 'INVALID_RESPONSE' && + !JSON.stringify(error).includes('999')); +}); From f3fec1f7f52bf0929dee322be8c8094f20aa8e08 Mon Sep 17 00:00:00 2001 From: Eric Date: Tue, 14 Jul 2026 16:40:01 -0500 Subject: [PATCH 5/5] docs: explain live recruiting SDK wiring --- docs/frtk-table-api.md | 47 +++++++++++++++++++++++++++ docs/research/runtime-verification.md | 21 ++++++++++++ 2 files changed, 68 insertions(+) diff --git a/docs/frtk-table-api.md b/docs/frtk-table-api.md index b4437f1..b22c80d 100644 --- a/docs/frtk-table-api.md +++ b/docs/frtk-table-api.md @@ -77,6 +77,50 @@ Addresses, byte buffers, masks, offsets, memory ranges, and guarded transaction operations remain host-internal. Raw memory diagnostic methods are separate and retain their existing contracts. +## Live recruiting service + +`createLiveRecruitingService({ client, generation })` wraps the typed API for +the Brooks-verified existing-row recruiting surface. The caller's save-backed +model supplies the `UserRecruitTarget`, RecruitingBoard, existing pitch, and +existing visit row numbers. The hook supplies live catalog discovery, decoded +reads, authority checks, and guarded transactions: + +```js +const { createClient, createLiveRecruitingService } = require('@cfb27/lua-hook'); + +const client = createClient({ pid }); +await client.loadFrtkProfile({ profile, layout }); +const { generation } = await client.discoverFrtkCatalog(); +const recruiting = await createLiveRecruitingService({ client, generation }); + +const state = await recruiting.readState({ + targetRow: selected.userRecruitTargetRow, + boardRow: selected.recruitingBoardRow, + pitchRow: selected.activePitchRow, + visitRow: selected.activeVisitRow, +}); + +await recruiting.setContactAction({ + transactionId: `recruiting.dm.${Date.now()}`, + targetRow: selected.userRecruitTargetRow, + boardRow: selected.recruitingBoardRow, + action: 'dm-player', + enabled: true, +}); +``` + +The service also exposes `setNilOffer`, `rewritePitch`, and `rewriteVisit`. +Contact actions are exactly `dm-player`, `browse-social-media`, and +`friends-family`; each contact bit and its RecruitingBoard assigned-hours delta +share one field transaction. `rewritePitch` preserves the row's current +intensity and cost. Every mutation accepts `dryRun: true` and returns a decoded +summary without exposing the internal field changes. + +`LIVE_RECRUITING_TABLES` exports the sanitized identities and field definitions +needed by a save-backed profile builder. The builder still supplies its own +local fingerprints. This API never allocates or frees a row and does not add or +remove pitches, visits, or board members. + Stable FrTk errors are `FRTK_PROFILE_INVALID`, `FRTK_DISCOVERY_FAILED`, `FRTK_DISCOVERY_TIMEOUT`, `FRTK_CATALOG_STALE`, `FRTK_FIELD_INVALID`, and @@ -86,3 +130,6 @@ progress object: phase, public Unique ID or `null`, zero-based fingerprint ordinal or `null`, completed fingerprint count, elapsed milliseconds, and bounded cumulative page, chunk, scanned-byte, candidate-window, and capped-match counters. It never exposes private memory or fingerprint material. + +The recruiting wrapper additionally reports `RECRUITING_ACTION_UNSUPPORTED` +and `RECRUITING_HOURS_INSUFFICIENT` for its two domain-specific failures. diff --git a/docs/research/runtime-verification.md b/docs/research/runtime-verification.md index 1ad5cd0..fc08564 100644 --- a/docs/research/runtime-verification.md +++ b/docs/research/runtime-verification.md @@ -242,3 +242,24 @@ After the earlier live session, both applications were closed and the supported uninstall restored the game and MMC active proxies. Independent SHA-256 checks of both files returned `3E87682118E593F334BA665826E2A6AB85BA460F2E1FE95B173A7199863AD454`. + +## Imported live recruiting evidence on July 14, 2026 + +The existing-row recruiting SDK surface imports sanitized behavioral evidence +from Brooks's `cfb27-dynasty-modding` commit +`b2b5a7ce4216c5838f1dbd2fb5a76dba6d67e7fe`, layout version `1.2.0`. Runtime +write authority requires the exact current-build table ID, persistent Unique ID, +and record size shown here; file profiles remain `discovery_only`. + +| Table | Table ID | Unique ID | Record size | Imported operation | +|---|---:|---:|---:|---| +| UserRecruitTarget | 4168 | 3987156317 | 36 | Contact booleans and `CurrentNILOffer` | +| ActiveVisitInfo | 4176 | 3093586546 | 4 | Rewrite an existing visit's week, week type, and activity | +| ActiveRecruitingPitch | 4190 | 1559900276 | 4 | Rewrite an existing pitch enum while preserving intensity | +| RecruitingBoard | 4251 | 220276943 | 12 | Read total/processed/assigned hours and atomically adjust assigned hours with contacts | + +This delivery excludes allocation and freelist changes, `SendTheHouse`, +scholarships, scouting, board membership, pitch-intensity changes, and pitch or +visit creation/removal. Verification for this import is automated and offline; +no additional installed-host, CFB27, MMC, weekly-advance, or autosave gate was +performed.