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. 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 new file mode 100644 index 0000000..d87d7f8 --- /dev/null +++ b/docs/superpowers/specs/2026-07-14-brooks-live-recruiting-import-design.md @@ -0,0 +1,69 @@ +# 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 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 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: + +- 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; +- 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, 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. 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. 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: + +- 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 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. + +## 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. 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(); 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')); +});