diff --git a/fantasy-auction-tracker/.gitignore b/fantasy-auction-tracker/.gitignore new file mode 100644 index 0000000..552f221 --- /dev/null +++ b/fantasy-auction-tracker/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +*.log diff --git a/fantasy-auction-tracker/README.md b/fantasy-auction-tracker/README.md new file mode 100644 index 0000000..31c37ac --- /dev/null +++ b/fantasy-auction-tracker/README.md @@ -0,0 +1,283 @@ +# Auction Draft Tracker + +A Firefox add-on that watches a fantasy football **auction** draft room and +turns it into a live analytical layer: who was taken, for how much, what every +team has left, what they still need, and what the player on the block is +actually worth *in this market* rather than in the one your rankings assumed. + +Read-only by design. It observes and advises; it never bids. + +--- + +## Why it works this way + +**It reads the DOM, not the screen.** A browser extension already lives inside +the page, so pixel-scraping and OCR are a last resort, not the design. Three +detection layers, best first: + +| Layer | How | Confidence | Notes | +|---|---|---|---| +| `ws` | wraps `window.WebSocket` in the page world and mirrors frames | 1.00 | exact player ids, prices, teams; instant | +| `dom` | `MutationObserver` over the draft board | 0.85 | works anywhere, breaks on redesigns | +| `ocr` | canvas fallback (not yet implemented) | 0.55 | only if the room renders to `` | + +Both implemented layers run **at the same time**. The store deduplicates, so +the DOM layer silently covers whatever the WebSocket mapping misses instead of +leaving a hole you notice only after the draft. + +**State is an append-only event log.** Every observation is an event; all +budgets, rosters and analytics are a pure function of that log +(`src/core/reducer.js`). That buys four things that matter on draft day: + +- **Replay** — rehearse against a recorded draft (`npm run replay`) +- **Undo** — a bad parse is retracted or corrected, not surgically unwound +- **Recovery** — refresh the draft room mid-auction and lose nothing +- **Post-mortem** — export the log and study what the league actually paid + +**Low-confidence events are quarantined, not applied.** Anything the adapter +was unsure about lands in a review queue in the sidebar with Confirm/Discard +buttons. Silence never means "fine" — unresolved problems surface as alerts. + +--- + +## The analytics + +The tracking is table stakes. These are the numbers worth having: + +**Inflation** — `remaining money ÷ remaining par value`, after removing the +$1-per-open-slot floor that can never chase value (the "discretionary" form). +Above 1.0 you must overpay or go home; below 1.0 there are bargains ahead and +patience pays. This is the single most valuable live number in an auction and +nobody tracks it by hand. + +**Max bid per team** — `money left − $1 × (other open slots)`. The real ceiling +a rival can reach, which is usually far below their raw remaining budget. The +Teams tab sorts by it, so you can see at a glance who can actually fight you. + +**Positional scarcity** — startable players remaining versus starting slots the +league still has to fill, with replacement level derived from your roster +settings (flex demand split across eligible positions). Below 1.0 means a run +is coming. + +**Tier cliff** — the drop from the best available at a position to the next +one. A big cliff is what justifies paying over par: losing that player costs +you the whole gap, not a dollar. + +**Budget pressure** — which teams are nearly locked into $1 bids. Your cue to +nominate expensive players you *don't* want while the field can still pay. + +**Bid advice** — combines the above into `walk-away` (par × inflation, the +disciplined number) and `ceiling` (walk-away + a configurable slice of the tier +cliff), both hard-capped by your own max bid so it never advises a bid you +cannot legally make. An explicit price target on your board replaces the +market-derived number entirely — see below. + +--- + +## The target board + +Valuations say what a player is worth to the market. Targets say what he is +worth **to you** — a different question, and the one you actually bid off. + +**Tiers** group players who are interchangeable *to you*. The point is that you +need N players from a tier, not one specific name, so losing one only matters +when the tier runs dry. The board flags each tier as healthy, **critical** (two +or fewer names left) or **exhausted**. + +**Price targets** are yours and override the market-derived ceiling. Set $42 on +a player and the Live tab will say PASS at $43 no matter what the CSV thinks. +Every price and tier on the board is an editable cell — retuning mid-draft is +one click, and it saves immediately. Leave a price blank to fall back to par. + +Optional **adjust for inflation** scales your targets by the live market rate, +because a $40 target in a market running 1.2× is really a $48 target — holding +the nominal number while everything inflates means quietly targeting a worse +player. Off by default; when on, the adjusted figure is shown next to your +typed one so the override is visible rather than surprising. + +**Plan feasibility** is the number that earns the tab. It walks your open +targets in tier order, fits each into a remaining roster slot, and compares the +total against the money you actually have: + +``` +plan: $127 of targets for 6 of 13 open slots, $99 available -- SHORT BY $35 +``` + +Discovering at $12 remaining that your plan needed $60 is the classic auction +death. This makes it impossible to walk into. + +Seed a starting board from your CSV in one click (it uses the export's own tier +column when there is one, otherwise bands by value), then edit. Seeding merges +rather than replaces, so it never wipes prices you have already tuned. + + +--- + +## Install (temporary, for development) + +``` +about:debugging → This Firefox → Load Temporary Add-on → pick manifest.json +``` + +Opens as a **sidebar** (`Ctrl+Shift+Y` or View → Sidebar), which is what you +want next to a draft room — no overlay fighting the page for space. + +Requires Firefox 128+ (MV3 background modules). No build step, no bundler, no +`npm install` — it is plain ES modules that load directly. + +## Set up before draft day + +1. **Sidebar → Setup → League** — teams, budget, min bid, roster slots + (`QB,RB,RB,WR,WR,WR,TE,FLEX,K,DST,BN x6`), and **your team id exactly as the + draft room spells it**. +2. **Setup → Valuations** — import a CSV with player / position / auction value + columns. FantasyPros exports work as-is; headers are alias-matched, and + `RB1`-style positions are split into position + positional rank. Values are + **rescaled to your league's total money**, so a $200/12-team sheet is + corrected automatically for a $300/10-team league. +3. **Calibrate against a mock draft** — see below. Do not skip this. + +## Calibrating on a CBS Salary Cap mock draft + +The selectors in `src/adapters/profiles.js` are **provisional guesses** written +without access to a live auction room, and every platform reskins between +seasons. This takes about two minutes and turns them into verified ones. + +Open a **CBS Salary Cap Mock Draft** — never calibrate in your real league — +and paste `tools/selftest.js` into the browser console: + +```js +__auctionSelfTest() // check every selector against the live page +``` + +You get a table with one row per selector and a verdict: + +| Status | Meaning | +|---|---| +| `OK` | resolved, with a sample of what it matched | +| `FAIL` | the container exists but this selector matched nothing — **wrong, fix it** | +| `INVALID` | not valid CSS | +| `EMPTY` | nothing to match yet; it tells you which draft action would populate it | +| `NO-SCOPE` | its parent container is absent, so it had nowhere to look — not broken | + +That `FAIL` / `EMPTY` distinction is the point: a mid-draft lull with no player +on the block is not the same as a broken selector, and conflating them sends you +chasing selectors that are already correct. + +Then nominate a player, run the bidding up, let one sale complete, and re-run. +It prints the exact events the adapter would emit, warns about sales parsed +without a position (those never auto-apply), and ends with a pasteable selector +block for the sidebar's Advanced panel. + +Also confirm the room is observable at all: + +```js +__auctionSelfTestLive() // watch 45s of live bidding +``` + +If it reports fewer than two distinct states while bids are visibly moving, the +room renders through canvas or shadow DOM and the DOM layer cannot see it — the +WebSocket capture is then the only viable path. + +To hunt for replacements for anything that failed, `tools/calibrate.js`: + +```js +__auctionCalibrate() // survey candidate selectors +__auctionCalibrate('Bijan Robinson') // locate a known player name +__auctionWatch() // log the most-mutated nodes for 30s +``` + +Better still, get the WebSocket mapping right — it beats every selector: + +1. Run a mock draft with the add-on loaded. +2. **Setup → Export WS capture**. +3. Feed the capture to `guessMapping()` in `src/adapters/ws.js` for candidate + field paths, confirm them by eye, and save the mapping to + `browser.storage.local` under `mapping`. + +Until a mapping exists the WS layer stays in **RECORD** mode (observe and +capture only) and the DOM layer does the work. + +## Rehearsing + +```bash +npm install # linkedom, for the DOM tests only — the add-on ships dependency-free +npm test # 124 tests + +# rehearse a full draft, or freeze it mid-auction +npm run replay -- fixtures/sample-draft.json fixtures/sample-values.csv +npm run replay -- fixtures/sample-draft.json fixtures/sample-values.csv --at 300 + +# rehearse your target board against it +npm run replay -- fixtures/sample-draft.json fixtures/sample-values.csv \ + --at 150 --targets fixtures/sample-targets.json --me team-4 +``` + +The last one prints exactly what the Targets tab would show: + +``` +target board + tier 1: 0/6 open won 0 ($0) lost 6 EXHAUSTED + tier 2: 1/6 open won 2 ($41) lost 3 CRITICAL + tier 3: 5/6 open won 0 ($0) lost 1 + +plan: $127 of targets for 6 of 13 open slots, $99 available -- SHORT BY $35 +``` + +`tools/simulate.js` generates a deterministic, internally-consistent 12-team +$200 auction (192 sales, every team landing on exactly the cap) plus the +matching valuation CSV. The replay tests assert on it end to end: no player +drafted twice, no team overspent, every dollar accounted for, and reducing a +prefix of the log equals replaying to that point — the property page-refresh +recovery depends on. + +## Layout + +``` +src/core/ pure logic, no DOM, no browser APIs — everything tested + events.js event vocabulary + validation + reducer.js log -> state (pure, total, deterministic) + store.js append-only log + dedup + subscribe + analytics.js inflation, scarcity, cliffs, bid advice + valuations.js CSV import + league rescaling + targets.js tiers, price targets, plan feasibility + players.js name normalization + fuzzy resolution + config.js roster slots, flex eligibility, replacement level +src/adapters/ platform seam — the only place site knowledge lives +src/content/ content script + page-world WebSocket tap +src/background/ owns the store, persists to storage +src/sidebar/ view layer only; computes nothing +tools/ simulate, replay, calibrate, selftest +``` + +`src/core/` has no browser dependency, which is why the whole analytical layer +runs under `node --test` with no DOM shim. + +## Status + +**Working and tested (124 tests):** the core, analytics, the target board, +valuation import, store/persistence, sidebar, DOM adapter, WS tap and record +mode, the self-test tool, replay and simulation. + +The DOM adapter is driven through a real DOM (linkedom) against +`fixtures/cbs-draft-room.html` — a CBS-shaped room deliberately built messy +(header rows, `$58` / `58` / ` $7 ` price formats, a suffixed name, a `D/ST` +alias, a row with no position) — and the full chain from DOM mutation to league +state is asserted end to end. + +**Not verified: that the CBS selectors match the live site.** The fixture is a +reconstruction, not a capture; it proves the adapter handles a room of that +*shape*, not that CBS uses those class names. Nothing runnable offline can prove +that — `__auctionSelfTest()` in a real mock draft is what closes the gap, and it +is built to say so loudly rather than pass quietly. + +Also provisional: the WS field mappings, which need a real capture, so the +WebSocket layer ships in record-only mode. The OCR layer is designed for but not +implemented — only needed if a platform renders its draft board to canvas. + +## A note on scope + +This tool observes your own draft and gives you advice. It does not place bids, +automate any interaction, or send anything anywhere — all state stays in +`browser.storage.local`. Keep it that way: auto-bidding would put you crosswise +with every platform's terms of service. diff --git a/fantasy-auction-tracker/fixtures/cbs-draft-room.html b/fantasy-auction-tracker/fixtures/cbs-draft-room.html new file mode 100644 index 0000000..8c91603 --- /dev/null +++ b/fantasy-auction-tracker/fixtures/cbs-draft-room.html @@ -0,0 +1,84 @@ + +
+ +
+
+ Ja'Marr Chase + WR + CIN +
+
+ $47 + Gridiron Gurus +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PlayerPosPriceTeam
Bijan RobinsonRB$58Team Chaos
Marvin Harrison Jr.WR44Waiver Wire Warriors
San Francisco 49ersD/ST $7 Team Chaos
Tucker Kraft$3Gridiron Gurus
+ +
+
+ Team Chaos + $135 +
+
+ Gridiron Gurus + $197 +
+
+ Waiver Wire Warriors + $156 +
+
+ +
diff --git a/fantasy-auction-tracker/fixtures/sample-draft.json b/fantasy-auction-tracker/fixtures/sample-draft.json new file mode 100644 index 0000000..8b456c3 --- /dev/null +++ b/fantasy-auction-tracker/fixtures/sample-draft.json @@ -0,0 +1,8487 @@ +{ + "version": 1, + "config": { + "numTeams": 12, + "budget": 200, + "minBid": 1, + "rosterSlots": [ + "QB", + "RB", + "RB", + "WR", + "WR", + "WR", + "TE", + "FLEX", + "K", + "DST", + "BN", + "BN", + "BN", + "BN", + "BN", + "BN" + ], + "myTeamId": null, + "reviewThreshold": 0.8 + }, + "log": [ + { + "id": "e1", + "type": "LEAGUE_CONFIGURED", + "ts": 1700000001000, + "source": "manual", + "confidence": 1, + "payload": { + "numTeams": 12, + "budget": 200, + "minBid": 1, + "rosterSlots": [ + "QB", + "RB", + "RB", + "WR", + "WR", + "WR", + "TE", + "FLEX", + "K", + "DST", + "BN", + "BN", + "BN", + "BN", + "BN", + "BN" + ], + "myTeamId": null, + "reviewThreshold": 0.8 + } + }, + { + "id": "e2", + "type": "TEAM_REGISTERED", + "ts": 1700000002000, + "source": "ws", + "confidence": 1, + "payload": { + "teamId": "team-1", + "teamName": "Team 1" + } + }, + { + "id": "e3", + "type": "TEAM_REGISTERED", + "ts": 1700000003000, + "source": "ws", + "confidence": 1, + "payload": { + "teamId": "team-2", + "teamName": "Team 2" + } + }, + { + "id": "e4", + "type": "TEAM_REGISTERED", + "ts": 1700000004000, + "source": "ws", + "confidence": 1, + "payload": { + "teamId": "team-3", + "teamName": "Team 3" + } + }, + { + "id": "e5", + "type": "TEAM_REGISTERED", + "ts": 1700000005000, + "source": "ws", + "confidence": 1, + "payload": { + "teamId": "team-4", + "teamName": "Team 4" + } + }, + { + "id": "e6", + "type": "TEAM_REGISTERED", + "ts": 1700000006000, + "source": "ws", + "confidence": 1, + "payload": { + "teamId": "team-5", + "teamName": "Team 5" + } + }, + { + "id": "e7", + "type": "TEAM_REGISTERED", + "ts": 1700000007000, + "source": "ws", + "confidence": 1, + "payload": { + "teamId": "team-6", + "teamName": "Team 6" + } + }, + { + "id": "e8", + "type": "TEAM_REGISTERED", + "ts": 1700000008000, + "source": "ws", + "confidence": 1, + "payload": { + "teamId": "team-7", + "teamName": "Team 7" + } + }, + { + "id": "e9", + "type": "TEAM_REGISTERED", + "ts": 1700000009000, + "source": "ws", + "confidence": 1, + "payload": { + "teamId": "team-8", + "teamName": "Team 8" + } + }, + { + "id": "e10", + "type": "TEAM_REGISTERED", + "ts": 1700000010000, + "source": "ws", + "confidence": 1, + "payload": { + "teamId": "team-9", + "teamName": "Team 9" + } + }, + { + "id": "e11", + "type": "TEAM_REGISTERED", + "ts": 1700000011000, + "source": "ws", + "confidence": 1, + "payload": { + "teamId": "team-10", + "teamName": "Team 10" + } + }, + { + "id": "e12", + "type": "TEAM_REGISTERED", + "ts": 1700000012000, + "source": "ws", + "confidence": 1, + "payload": { + "teamId": "team-11", + "teamName": "Team 11" + } + }, + { + "id": "e13", + "type": "TEAM_REGISTERED", + "ts": 1700000013000, + "source": "ws", + "confidence": 1, + "payload": { + "teamId": "team-12", + "teamName": "Team 12" + } + }, + { + "id": "e14", + "type": "NOMINATION", + "ts": 1700000014000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Nico Parsons RB1", + "position": "RB", + "nflTeam": "SF", + "openingBid": 1, + "nominatingTeamId": "team-1" + } + }, + { + "id": "e15", + "type": "BID", + "ts": 1700000015000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 23, + "teamId": "team-7" + } + }, + { + "id": "e16", + "type": "BID", + "ts": 1700000016000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 38, + "teamId": "team-12" + } + }, + { + "id": "e17", + "type": "SOLD", + "ts": 1700000017000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Nico Parsons RB1", + "position": "RB", + "nflTeam": "SF", + "teamId": "team-10", + "teamName": "Team 10", + "price": 46 + } + }, + { + "id": "e18", + "type": "NOMINATION", + "ts": 1700000018000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Amon-Ra Wilson WR1", + "position": "WR", + "nflTeam": "PHI", + "openingBid": 1, + "nominatingTeamId": "team-6" + } + }, + { + "id": "e19", + "type": "BID", + "ts": 1700000019000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 26, + "teamId": "team-2" + } + }, + { + "id": "e20", + "type": "BID", + "ts": 1700000020000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 43, + "teamId": "team-4" + } + }, + { + "id": "e21", + "type": "SOLD", + "ts": 1700000021000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Amon-Ra Wilson WR1", + "position": "WR", + "nflTeam": "PHI", + "teamId": "team-6", + "teamName": "Team 6", + "price": 53 + } + }, + { + "id": "e22", + "type": "NOMINATION", + "ts": 1700000022000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Kyren Robinson WR2", + "position": "WR", + "nflTeam": "PHI", + "openingBid": 1, + "nominatingTeamId": "team-7" + } + }, + { + "id": "e23", + "type": "BID", + "ts": 1700000023000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 22, + "teamId": "team-3" + } + }, + { + "id": "e24", + "type": "BID", + "ts": 1700000024000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 37, + "teamId": "team-8" + } + }, + { + "id": "e25", + "type": "SOLD", + "ts": 1700000025000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Kyren Robinson WR2", + "position": "WR", + "nflTeam": "PHI", + "teamId": "team-9", + "teamName": "Team 9", + "price": 45 + } + }, + { + "id": "e26", + "type": "NOMINATION", + "ts": 1700000026000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Deebo Pacheco RB2", + "position": "RB", + "nflTeam": "BUF", + "openingBid": 1, + "nominatingTeamId": "team-11" + } + }, + { + "id": "e27", + "type": "BID", + "ts": 1700000027000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 19, + "teamId": "team-5" + } + }, + { + "id": "e28", + "type": "BID", + "ts": 1700000028000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 31, + "teamId": "team-10" + } + }, + { + "id": "e29", + "type": "SOLD", + "ts": 1700000029000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Deebo Pacheco RB2", + "position": "RB", + "nflTeam": "BUF", + "teamId": "team-7", + "teamName": "Team 7", + "price": 38 + } + }, + { + "id": "e30", + "type": "NOMINATION", + "ts": 1700000030000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Tank Gibbs WR8", + "position": "WR", + "nflTeam": "BAL", + "openingBid": 1, + "nominatingTeamId": "team-2" + } + }, + { + "id": "e31", + "type": "BID", + "ts": 1700000031000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 20, + "teamId": "team-5" + } + }, + { + "id": "e32", + "type": "BID", + "ts": 1700000032000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 33, + "teamId": "team-3" + } + }, + { + "id": "e33", + "type": "SOLD", + "ts": 1700000033000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Tank Gibbs WR8", + "position": "WR", + "nflTeam": "BAL", + "teamId": "team-3", + "teamName": "Team 3", + "price": 41 + } + }, + { + "id": "e34", + "type": "NOMINATION", + "ts": 1700000034000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Breece Odunze RB3", + "position": "RB", + "nflTeam": "CIN", + "openingBid": 1, + "nominatingTeamId": "team-7" + } + }, + { + "id": "e35", + "type": "BID", + "ts": 1700000035000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 29, + "teamId": "team-3" + } + }, + { + "id": "e36", + "type": "BID", + "ts": 1700000036000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 48, + "teamId": "team-6" + } + }, + { + "id": "e37", + "type": "SOLD", + "ts": 1700000037000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Breece Odunze RB3", + "position": "RB", + "nflTeam": "CIN", + "teamId": "team-3", + "teamName": "Team 3", + "price": 58 + } + }, + { + "id": "e38", + "type": "NOMINATION", + "ts": 1700000038000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Blake Rice WR4", + "position": "WR", + "nflTeam": "TB", + "openingBid": 1, + "nominatingTeamId": "team-3" + } + }, + { + "id": "e39", + "type": "BID", + "ts": 1700000039000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 30, + "teamId": "team-4" + } + }, + { + "id": "e40", + "type": "BID", + "ts": 1700000040000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 50, + "teamId": "team-7" + } + }, + { + "id": "e41", + "type": "SOLD", + "ts": 1700000041000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Blake Rice WR4", + "position": "WR", + "nflTeam": "TB", + "teamId": "team-4", + "teamName": "Team 4", + "price": 60 + } + }, + { + "id": "e42", + "type": "NOMINATION", + "ts": 1700000042000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Garrett Johnston RB10", + "position": "RB", + "nflTeam": "NYG", + "openingBid": 1, + "nominatingTeamId": "team-11" + } + }, + { + "id": "e43", + "type": "BID", + "ts": 1700000043000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 18, + "teamId": "team-12" + } + }, + { + "id": "e44", + "type": "BID", + "ts": 1700000044000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 30, + "teamId": "team-9" + } + }, + { + "id": "e45", + "type": "SOLD", + "ts": 1700000045000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Garrett Johnston RB10", + "position": "RB", + "nflTeam": "NYG", + "teamId": "team-1", + "teamName": "Team 1", + "price": 36 + } + }, + { + "id": "e46", + "type": "NOMINATION", + "ts": 1700000046000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Micah McBride WR3", + "position": "WR", + "nflTeam": "NO", + "openingBid": 1, + "nominatingTeamId": "team-9" + } + }, + { + "id": "e47", + "type": "BID", + "ts": 1700000047000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 17, + "teamId": "team-4" + } + }, + { + "id": "e48", + "type": "BID", + "ts": 1700000048000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 28, + "teamId": "team-12" + } + }, + { + "id": "e49", + "type": "SOLD", + "ts": 1700000049000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Micah McBride WR3", + "position": "WR", + "nflTeam": "NO", + "teamId": "team-7", + "teamName": "Team 7", + "price": 35 + } + }, + { + "id": "e50", + "type": "NOMINATION", + "ts": 1700000050000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Cooper Hall RB4", + "position": "RB", + "nflTeam": "NO", + "openingBid": 1, + "nominatingTeamId": "team-5" + } + }, + { + "id": "e51", + "type": "BID", + "ts": 1700000051000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 27, + "teamId": "team-10" + } + }, + { + "id": "e52", + "type": "BID", + "ts": 1700000052000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 45, + "teamId": "team-3" + } + }, + { + "id": "e53", + "type": "SOLD", + "ts": 1700000053000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Cooper Hall RB4", + "position": "RB", + "nflTeam": "NO", + "teamId": "team-2", + "teamName": "Team 2", + "price": 55 + } + }, + { + "id": "e54", + "type": "NOMINATION", + "ts": 1700000054000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Chris Gibbs WR5", + "position": "WR", + "nflTeam": "SF", + "openingBid": 1, + "nominatingTeamId": "team-9" + } + }, + { + "id": "e55", + "type": "BID", + "ts": 1700000055000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 21, + "teamId": "team-9" + } + }, + { + "id": "e56", + "type": "BID", + "ts": 1700000056000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 35, + "teamId": "team-11" + } + }, + { + "id": "e57", + "type": "SOLD", + "ts": 1700000057000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Chris Gibbs WR5", + "position": "WR", + "nflTeam": "SF", + "teamId": "team-11", + "teamName": "Team 11", + "price": 42 + } + }, + { + "id": "e58", + "type": "NOMINATION", + "ts": 1700000058000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Jordan Shakir RB6", + "position": "RB", + "nflTeam": "DAL", + "openingBid": 1, + "nominatingTeamId": "team-9" + } + }, + { + "id": "e59", + "type": "BID", + "ts": 1700000059000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 25, + "teamId": "team-10" + } + }, + { + "id": "e60", + "type": "BID", + "ts": 1700000060000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 42, + "teamId": "team-9" + } + }, + { + "id": "e61", + "type": "SOLD", + "ts": 1700000061000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Jordan Shakir RB6", + "position": "RB", + "nflTeam": "DAL", + "teamId": "team-2", + "teamName": "Team 2", + "price": 51 + } + }, + { + "id": "e62", + "type": "NOMINATION", + "ts": 1700000062000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Jahmyr Parsons WR6", + "position": "WR", + "nflTeam": "LV", + "openingBid": 1, + "nominatingTeamId": "team-5" + } + }, + { + "id": "e63", + "type": "BID", + "ts": 1700000063000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 26, + "teamId": "team-6" + } + }, + { + "id": "e64", + "type": "BID", + "ts": 1700000064000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 43, + "teamId": "team-3" + } + }, + { + "id": "e65", + "type": "SOLD", + "ts": 1700000065000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Jahmyr Parsons WR6", + "position": "WR", + "nflTeam": "LV", + "teamId": "team-8", + "teamName": "Team 8", + "price": 52 + } + }, + { + "id": "e66", + "type": "NOMINATION", + "ts": 1700000066000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Zay Hall WR10", + "position": "WR", + "nflTeam": "ARI", + "openingBid": 1, + "nominatingTeamId": "team-8" + } + }, + { + "id": "e67", + "type": "BID", + "ts": 1700000067000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 17, + "teamId": "team-1" + } + }, + { + "id": "e68", + "type": "BID", + "ts": 1700000068000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 28, + "teamId": "team-7" + } + }, + { + "id": "e69", + "type": "SOLD", + "ts": 1700000069000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Zay Hall WR10", + "position": "WR", + "nflTeam": "ARI", + "teamId": "team-1", + "teamName": "Team 1", + "price": 34 + } + }, + { + "id": "e70", + "type": "NOMINATION", + "ts": 1700000070000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Jordan Hall WR7", + "position": "WR", + "nflTeam": "TB", + "openingBid": 1, + "nominatingTeamId": "team-4" + } + }, + { + "id": "e71", + "type": "BID", + "ts": 1700000071000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 27, + "teamId": "team-10" + } + }, + { + "id": "e72", + "type": "BID", + "ts": 1700000072000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 45, + "teamId": "team-9" + } + }, + { + "id": "e73", + "type": "SOLD", + "ts": 1700000073000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Jordan Hall WR7", + "position": "WR", + "nflTeam": "TB", + "teamId": "team-3", + "teamName": "Team 3", + "price": 55 + } + }, + { + "id": "e74", + "type": "NOMINATION", + "ts": 1700000074000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Quentin Rice RB5", + "position": "RB", + "nflTeam": "DAL", + "openingBid": 1, + "nominatingTeamId": "team-6" + } + }, + { + "id": "e75", + "type": "BID", + "ts": 1700000075000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 18, + "teamId": "team-1" + } + }, + { + "id": "e76", + "type": "BID", + "ts": 1700000076000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 30, + "teamId": "team-1" + } + }, + { + "id": "e77", + "type": "SOLD", + "ts": 1700000077000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Quentin Rice RB5", + "position": "RB", + "nflTeam": "DAL", + "teamId": "team-12", + "teamName": "Team 12", + "price": 36 + } + }, + { + "id": "e78", + "type": "NOMINATION", + "ts": 1700000078000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Zay Parsons RB7", + "position": "RB", + "nflTeam": "CIN", + "openingBid": 1, + "nominatingTeamId": "team-3" + } + }, + { + "id": "e79", + "type": "BID", + "ts": 1700000079000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 25, + "teamId": "team-11" + } + }, + { + "id": "e80", + "type": "BID", + "ts": 1700000080000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 41, + "teamId": "team-12" + } + }, + { + "id": "e81", + "type": "SOLD", + "ts": 1700000081000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Zay Parsons RB7", + "position": "RB", + "nflTeam": "CIN", + "teamId": "team-1", + "teamName": "Team 1", + "price": 50 + } + }, + { + "id": "e82", + "type": "NOMINATION", + "ts": 1700000082000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Rome Collins RB8", + "position": "RB", + "nflTeam": "NO", + "openingBid": 1, + "nominatingTeamId": "team-1" + } + }, + { + "id": "e83", + "type": "BID", + "ts": 1700000083000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 14, + "teamId": "team-9" + } + }, + { + "id": "e84", + "type": "BID", + "ts": 1700000084000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 23, + "teamId": "team-10" + } + }, + { + "id": "e85", + "type": "SOLD", + "ts": 1700000085000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Rome Collins RB8", + "position": "RB", + "nflTeam": "NO", + "teamId": "team-1", + "teamName": "Team 1", + "price": 28 + } + }, + { + "id": "e86", + "type": "NOMINATION", + "ts": 1700000086000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Garrett Corum WR9", + "position": "WR", + "nflTeam": "TB", + "openingBid": 1, + "nominatingTeamId": "team-11" + } + }, + { + "id": "e87", + "type": "BID", + "ts": 1700000087000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 16, + "teamId": "team-10" + } + }, + { + "id": "e88", + "type": "BID", + "ts": 1700000088000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 27, + "teamId": "team-1" + } + }, + { + "id": "e89", + "type": "SOLD", + "ts": 1700000089000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Garrett Corum WR9", + "position": "WR", + "nflTeam": "TB", + "teamId": "team-9", + "teamName": "Team 9", + "price": 33 + } + }, + { + "id": "e90", + "type": "NOMINATION", + "ts": 1700000090000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Trey LaPorta RB9", + "position": "RB", + "nflTeam": "PHI", + "openingBid": 1, + "nominatingTeamId": "team-11" + } + }, + { + "id": "e91", + "type": "BID", + "ts": 1700000091000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 19, + "teamId": "team-2" + } + }, + { + "id": "e92", + "type": "BID", + "ts": 1700000092000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 32, + "teamId": "team-8" + } + }, + { + "id": "e93", + "type": "SOLD", + "ts": 1700000093000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Trey LaPorta RB9", + "position": "RB", + "nflTeam": "PHI", + "teamId": "team-12", + "teamName": "Team 12", + "price": 39 + } + }, + { + "id": "e94", + "type": "NOMINATION", + "ts": 1700000094000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Cooper Dell RB11", + "position": "RB", + "nflTeam": "SEA", + "openingBid": 1, + "nominatingTeamId": "team-10" + } + }, + { + "id": "e95", + "type": "BID", + "ts": 1700000095000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 13, + "teamId": "team-5" + } + }, + { + "id": "e96", + "type": "BID", + "ts": 1700000096000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 22, + "teamId": "team-10" + } + }, + { + "id": "e97", + "type": "SOLD", + "ts": 1700000097000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Cooper Dell RB11", + "position": "RB", + "nflTeam": "SEA", + "teamId": "team-8", + "teamName": "Team 8", + "price": 27 + } + }, + { + "id": "e98", + "type": "NOMINATION", + "ts": 1700000098000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Blake Shakir WR12", + "position": "WR", + "nflTeam": "TB", + "openingBid": 1, + "nominatingTeamId": "team-11" + } + }, + { + "id": "e99", + "type": "BID", + "ts": 1700000099000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 14, + "teamId": "team-8" + } + }, + { + "id": "e100", + "type": "BID", + "ts": 1700000100000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 23, + "teamId": "team-2" + } + }, + { + "id": "e101", + "type": "SOLD", + "ts": 1700000101000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Blake Shakir WR12", + "position": "WR", + "nflTeam": "TB", + "teamId": "team-11", + "teamName": "Team 11", + "price": 29 + } + }, + { + "id": "e102", + "type": "NOMINATION", + "ts": 1700000102000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Deebo Tracy WR13", + "position": "WR", + "nflTeam": "LAR", + "openingBid": 1, + "nominatingTeamId": "team-4" + } + }, + { + "id": "e103", + "type": "BID", + "ts": 1700000103000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 16, + "teamId": "team-5" + } + }, + { + "id": "e104", + "type": "BID", + "ts": 1700000104000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 27, + "teamId": "team-5" + } + }, + { + "id": "e105", + "type": "SOLD", + "ts": 1700000105000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Deebo Tracy WR13", + "position": "WR", + "nflTeam": "LAR", + "teamId": "team-8", + "teamName": "Team 8", + "price": 33 + } + }, + { + "id": "e106", + "type": "NOMINATION", + "ts": 1700000106000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Bijan Kincaid WR11", + "position": "WR", + "nflTeam": "BAL", + "openingBid": 1, + "nominatingTeamId": "team-1" + } + }, + { + "id": "e107", + "type": "BID", + "ts": 1700000107000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 15, + "teamId": "team-12" + } + }, + { + "id": "e108", + "type": "BID", + "ts": 1700000108000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 25, + "teamId": "team-8" + } + }, + { + "id": "e109", + "type": "SOLD", + "ts": 1700000109000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Bijan Kincaid WR11", + "position": "WR", + "nflTeam": "BAL", + "teamId": "team-8", + "teamName": "Team 8", + "price": 31 + } + }, + { + "id": "e110", + "type": "NOMINATION", + "ts": 1700000110000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Kyren Corum TE1", + "position": "TE", + "nflTeam": "BUF", + "openingBid": 1, + "nominatingTeamId": "team-9" + } + }, + { + "id": "e111", + "type": "BID", + "ts": 1700000111000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 13, + "teamId": "team-12" + } + }, + { + "id": "e112", + "type": "BID", + "ts": 1700000112000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 22, + "teamId": "team-5" + } + }, + { + "id": "e113", + "type": "SOLD", + "ts": 1700000113000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Kyren Corum TE1", + "position": "TE", + "nflTeam": "BUF", + "teamId": "team-12", + "teamName": "Team 12", + "price": 27 + } + }, + { + "id": "e114", + "type": "NOMINATION", + "ts": 1700000114000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Sam Kupp QB1", + "position": "QB", + "nflTeam": "ARI", + "openingBid": 1, + "nominatingTeamId": "team-2" + } + }, + { + "id": "e115", + "type": "BID", + "ts": 1700000115000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 17, + "teamId": "team-7" + } + }, + { + "id": "e116", + "type": "BID", + "ts": 1700000116000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 28, + "teamId": "team-4" + } + }, + { + "id": "e117", + "type": "SOLD", + "ts": 1700000117000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Sam Kupp QB1", + "position": "QB", + "nflTeam": "ARI", + "teamId": "team-12", + "teamName": "Team 12", + "price": 35 + } + }, + { + "id": "e118", + "type": "NOMINATION", + "ts": 1700000118000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Jaxon Parsons RB12", + "position": "RB", + "nflTeam": "PHI", + "openingBid": 1, + "nominatingTeamId": "team-4" + } + }, + { + "id": "e119", + "type": "BID", + "ts": 1700000119000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 15, + "teamId": "team-4" + } + }, + { + "id": "e120", + "type": "BID", + "ts": 1700000120000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 25, + "teamId": "team-5" + } + }, + { + "id": "e121", + "type": "SOLD", + "ts": 1700000121000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Jaxon Parsons RB12", + "position": "RB", + "nflTeam": "PHI", + "teamId": "team-7", + "teamName": "Team 7", + "price": 31 + } + }, + { + "id": "e122", + "type": "NOMINATION", + "ts": 1700000122000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Blake Nacua QB2", + "position": "QB", + "nflTeam": "LAR", + "openingBid": 1, + "nominatingTeamId": "team-8" + } + }, + { + "id": "e123", + "type": "BID", + "ts": 1700000123000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 9, + "teamId": "team-7" + } + }, + { + "id": "e124", + "type": "BID", + "ts": 1700000124000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 15, + "teamId": "team-4" + } + }, + { + "id": "e125", + "type": "SOLD", + "ts": 1700000125000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Blake Nacua QB2", + "position": "QB", + "nflTeam": "LAR", + "teamId": "team-4", + "teamName": "Team 4", + "price": 18 + } + }, + { + "id": "e126", + "type": "NOMINATION", + "ts": 1700000126000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Marvin Odunze WR14", + "position": "WR", + "nflTeam": "NYG", + "openingBid": 1, + "nominatingTeamId": "team-9" + } + }, + { + "id": "e127", + "type": "BID", + "ts": 1700000127000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 13, + "teamId": "team-12" + } + }, + { + "id": "e128", + "type": "BID", + "ts": 1700000128000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 22, + "teamId": "team-5" + } + }, + { + "id": "e129", + "type": "SOLD", + "ts": 1700000129000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Marvin Odunze WR14", + "position": "WR", + "nflTeam": "NYG", + "teamId": "team-12", + "teamName": "Team 12", + "price": 27 + } + }, + { + "id": "e130", + "type": "NOMINATION", + "ts": 1700000130000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Justin Nacua WR16", + "position": "WR", + "nflTeam": "SEA", + "openingBid": 1, + "nominatingTeamId": "team-11" + } + }, + { + "id": "e131", + "type": "BID", + "ts": 1700000131000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 11, + "teamId": "team-4" + } + }, + { + "id": "e132", + "type": "BID", + "ts": 1700000132000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 18, + "teamId": "team-2" + } + }, + { + "id": "e133", + "type": "SOLD", + "ts": 1700000133000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Justin Nacua WR16", + "position": "WR", + "nflTeam": "SEA", + "teamId": "team-8", + "teamName": "Team 8", + "price": 23 + } + }, + { + "id": "e134", + "type": "NOMINATION", + "ts": 1700000134000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "CeeDee Brown RB13", + "position": "RB", + "nflTeam": "LAR", + "openingBid": 1, + "nominatingTeamId": "team-4" + } + }, + { + "id": "e135", + "type": "BID", + "ts": 1700000135000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 11, + "teamId": "team-5" + } + }, + { + "id": "e136", + "type": "BID", + "ts": 1700000136000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 18, + "teamId": "team-10" + } + }, + { + "id": "e137", + "type": "SOLD", + "ts": 1700000137000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "CeeDee Brown RB13", + "position": "RB", + "nflTeam": "LAR", + "teamId": "team-5", + "teamName": "Team 5", + "price": 22 + } + }, + { + "id": "e138", + "type": "NOMINATION", + "ts": 1700000138000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Rome London RB15", + "position": "RB", + "nflTeam": "LV", + "openingBid": 1, + "nominatingTeamId": "team-1" + } + }, + { + "id": "e139", + "type": "BID", + "ts": 1700000139000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 11, + "teamId": "team-12" + } + }, + { + "id": "e140", + "type": "BID", + "ts": 1700000140000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 18, + "teamId": "team-6" + } + }, + { + "id": "e141", + "type": "SOLD", + "ts": 1700000141000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Rome London RB15", + "position": "RB", + "nflTeam": "LV", + "teamId": "team-4", + "teamName": "Team 4", + "price": 23 + } + }, + { + "id": "e142", + "type": "NOMINATION", + "ts": 1700000142000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Bijan Nacua WR24", + "position": "WR", + "nflTeam": "PHI", + "openingBid": 1, + "nominatingTeamId": "team-1" + } + }, + { + "id": "e143", + "type": "BID", + "ts": 1700000143000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 9, + "teamId": "team-3" + } + }, + { + "id": "e144", + "type": "BID", + "ts": 1700000144000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 15, + "teamId": "team-2" + } + }, + { + "id": "e145", + "type": "SOLD", + "ts": 1700000145000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Bijan Nacua WR24", + "position": "WR", + "nflTeam": "PHI", + "teamId": "team-1", + "teamName": "Team 1", + "price": 19 + } + }, + { + "id": "e146", + "type": "NOMINATION", + "ts": 1700000146000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Deebo Bowers RB16", + "position": "RB", + "nflTeam": "TB", + "openingBid": 1, + "nominatingTeamId": "team-8" + } + }, + { + "id": "e147", + "type": "BID", + "ts": 1700000147000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 11, + "teamId": "team-8" + } + }, + { + "id": "e148", + "type": "BID", + "ts": 1700000148000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 18, + "teamId": "team-6" + } + }, + { + "id": "e149", + "type": "SOLD", + "ts": 1700000149000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Deebo Bowers RB16", + "position": "RB", + "nflTeam": "TB", + "teamId": "team-9", + "teamName": "Team 9", + "price": 23 + } + }, + { + "id": "e150", + "type": "NOMINATION", + "ts": 1700000150000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Justin Samuel WR17", + "position": "WR", + "nflTeam": "NO", + "openingBid": 1, + "nominatingTeamId": "team-2" + } + }, + { + "id": "e151", + "type": "BID", + "ts": 1700000151000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 11, + "teamId": "team-2" + } + }, + { + "id": "e152", + "type": "BID", + "ts": 1700000152000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 18, + "teamId": "team-9" + } + }, + { + "id": "e153", + "type": "SOLD", + "ts": 1700000153000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Justin Samuel WR17", + "position": "WR", + "nflTeam": "NO", + "teamId": "team-8", + "teamName": "Team 8", + "price": 23 + } + }, + { + "id": "e154", + "type": "NOMINATION", + "ts": 1700000154000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Kyren Rice TE2", + "position": "TE", + "nflTeam": "BUF", + "openingBid": 1, + "nominatingTeamId": "team-11" + } + }, + { + "id": "e155", + "type": "BID", + "ts": 1700000155000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 11, + "teamId": "team-1" + } + }, + { + "id": "e156", + "type": "BID", + "ts": 1700000156000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 18, + "teamId": "team-10" + } + }, + { + "id": "e157", + "type": "SOLD", + "ts": 1700000157000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Kyren Rice TE2", + "position": "TE", + "nflTeam": "BUF", + "teamId": "team-12", + "teamName": "Team 12", + "price": 23 + } + }, + { + "id": "e158", + "type": "NOMINATION", + "ts": 1700000158000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Breece Nabers RB14", + "position": "RB", + "nflTeam": "KC", + "openingBid": 1, + "nominatingTeamId": "team-5" + } + }, + { + "id": "e159", + "type": "BID", + "ts": 1700000159000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 10, + "teamId": "team-12" + } + }, + { + "id": "e160", + "type": "BID", + "ts": 1700000160000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 17, + "teamId": "team-2" + } + }, + { + "id": "e161", + "type": "SOLD", + "ts": 1700000161000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Breece Nabers RB14", + "position": "RB", + "nflTeam": "KC", + "teamId": "team-6", + "teamName": "Team 6", + "price": 21 + } + }, + { + "id": "e162", + "type": "NOMINATION", + "ts": 1700000162000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Marvin Bowers WR15", + "position": "WR", + "nflTeam": "ATL", + "openingBid": 1, + "nominatingTeamId": "team-12" + } + }, + { + "id": "e163", + "type": "BID", + "ts": 1700000163000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 10, + "teamId": "team-6" + } + }, + { + "id": "e164", + "type": "BID", + "ts": 1700000164000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 16, + "teamId": "team-6" + } + }, + { + "id": "e165", + "type": "SOLD", + "ts": 1700000165000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Marvin Bowers WR15", + "position": "WR", + "nflTeam": "ATL", + "teamId": "team-9", + "teamName": "Team 9", + "price": 20 + } + }, + { + "id": "e166", + "type": "NOMINATION", + "ts": 1700000166000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Trey Lamb WR18", + "position": "WR", + "nflTeam": "PHI", + "openingBid": 1, + "nominatingTeamId": "team-11" + } + }, + { + "id": "e167", + "type": "BID", + "ts": 1700000167000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 11, + "teamId": "team-8" + } + }, + { + "id": "e168", + "type": "BID", + "ts": 1700000168000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 18, + "teamId": "team-10" + } + }, + { + "id": "e169", + "type": "SOLD", + "ts": 1700000169000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Trey Lamb WR18", + "position": "WR", + "nflTeam": "PHI", + "teamId": "team-7", + "teamName": "Team 7", + "price": 23 + } + }, + { + "id": "e170", + "type": "NOMINATION", + "ts": 1700000170000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Marvin Johnston WR26", + "position": "WR", + "nflTeam": "CIN", + "openingBid": 1, + "nominatingTeamId": "team-3" + } + }, + { + "id": "e171", + "type": "BID", + "ts": 1700000171000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 10, + "teamId": "team-1" + } + }, + { + "id": "e172", + "type": "BID", + "ts": 1700000172000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 16, + "teamId": "team-8" + } + }, + { + "id": "e173", + "type": "SOLD", + "ts": 1700000173000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Marvin Johnston WR26", + "position": "WR", + "nflTeam": "CIN", + "teamId": "team-10", + "teamName": "Team 10", + "price": 20 + } + }, + { + "id": "e174", + "type": "NOMINATION", + "ts": 1700000174000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Chris Nacua TE3", + "position": "TE", + "nflTeam": "HOU", + "openingBid": 1, + "nominatingTeamId": "team-1" + } + }, + { + "id": "e175", + "type": "BID", + "ts": 1700000175000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 9, + "teamId": "team-4" + } + }, + { + "id": "e176", + "type": "BID", + "ts": 1700000176000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 15, + "teamId": "team-12" + } + }, + { + "id": "e177", + "type": "SOLD", + "ts": 1700000177000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Chris Nacua TE3", + "position": "TE", + "nflTeam": "HOU", + "teamId": "team-7", + "teamName": "Team 7", + "price": 19 + } + }, + { + "id": "e178", + "type": "NOMINATION", + "ts": 1700000178000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Khalil Harrison WR19", + "position": "WR", + "nflTeam": "DAL", + "openingBid": 1, + "nominatingTeamId": "team-7" + } + }, + { + "id": "e179", + "type": "BID", + "ts": 1700000179000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 13, + "teamId": "team-6" + } + }, + { + "id": "e180", + "type": "BID", + "ts": 1700000180000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 21, + "teamId": "team-9" + } + }, + { + "id": "e181", + "type": "SOLD", + "ts": 1700000181000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Khalil Harrison WR19", + "position": "WR", + "nflTeam": "DAL", + "teamId": "team-6", + "teamName": "Team 6", + "price": 26 + } + }, + { + "id": "e182", + "type": "NOMINATION", + "ts": 1700000182000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Drake Kincaid RB17", + "position": "RB", + "nflTeam": "CHI", + "openingBid": 1, + "nominatingTeamId": "team-10" + } + }, + { + "id": "e183", + "type": "BID", + "ts": 1700000183000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 11, + "teamId": "team-9" + } + }, + { + "id": "e184", + "type": "BID", + "ts": 1700000184000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 18, + "teamId": "team-3" + } + }, + { + "id": "e185", + "type": "SOLD", + "ts": 1700000185000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Drake Kincaid RB17", + "position": "RB", + "nflTeam": "CHI", + "teamId": "team-6", + "teamName": "Team 6", + "price": 22 + } + }, + { + "id": "e186", + "type": "NOMINATION", + "ts": 1700000186000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Marvin Wilson RB18", + "position": "RB", + "nflTeam": "CHI", + "openingBid": 1, + "nominatingTeamId": "team-4" + } + }, + { + "id": "e187", + "type": "BID", + "ts": 1700000187000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 9, + "teamId": "team-1" + } + }, + { + "id": "e188", + "type": "BID", + "ts": 1700000188000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 15, + "teamId": "team-6" + } + }, + { + "id": "e189", + "type": "SOLD", + "ts": 1700000189000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Marvin Wilson RB18", + "position": "RB", + "nflTeam": "CHI", + "teamId": "team-4", + "teamName": "Team 4", + "price": 18 + } + }, + { + "id": "e190", + "type": "NOMINATION", + "ts": 1700000190000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Blake Samuel WR21", + "position": "WR", + "nflTeam": "DET", + "openingBid": 1, + "nominatingTeamId": "team-5" + } + }, + { + "id": "e191", + "type": "BID", + "ts": 1700000191000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 10, + "teamId": "team-7" + } + }, + { + "id": "e192", + "type": "BID", + "ts": 1700000192000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 17, + "teamId": "team-7" + } + }, + { + "id": "e193", + "type": "SOLD", + "ts": 1700000193000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Blake Samuel WR21", + "position": "WR", + "nflTeam": "DET", + "teamId": "team-5", + "teamName": "Team 5", + "price": 21 + } + }, + { + "id": "e194", + "type": "NOMINATION", + "ts": 1700000194000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Chris Rice QB3", + "position": "QB", + "nflTeam": "NYG", + "openingBid": 1, + "nominatingTeamId": "team-3" + } + }, + { + "id": "e195", + "type": "BID", + "ts": 1700000195000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 8, + "teamId": "team-9" + } + }, + { + "id": "e196", + "type": "BID", + "ts": 1700000196000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 13, + "teamId": "team-2" + } + }, + { + "id": "e197", + "type": "SOLD", + "ts": 1700000197000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Chris Rice QB3", + "position": "QB", + "nflTeam": "NYG", + "teamId": "team-10", + "teamName": "Team 10", + "price": 17 + } + }, + { + "id": "e198", + "type": "NOMINATION", + "ts": 1700000198000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Jordan McBride WR22", + "position": "WR", + "nflTeam": "HOU", + "openingBid": 1, + "nominatingTeamId": "team-3" + } + }, + { + "id": "e199", + "type": "BID", + "ts": 1700000199000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 8, + "teamId": "team-9" + } + }, + { + "id": "e200", + "type": "BID", + "ts": 1700000200000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 13, + "teamId": "team-10" + } + }, + { + "id": "e201", + "type": "SOLD", + "ts": 1700000201000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Jordan McBride WR22", + "position": "WR", + "nflTeam": "HOU", + "teamId": "team-7", + "teamName": "Team 7", + "price": 17 + } + }, + { + "id": "e202", + "type": "NOMINATION", + "ts": 1700000202000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Tyrone Corum WR23", + "position": "WR", + "nflTeam": "ATL", + "openingBid": 1, + "nominatingTeamId": "team-8" + } + }, + { + "id": "e203", + "type": "BID", + "ts": 1700000203000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 9, + "teamId": "team-3" + } + }, + { + "id": "e204", + "type": "BID", + "ts": 1700000204000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 15, + "teamId": "team-6" + } + }, + { + "id": "e205", + "type": "SOLD", + "ts": 1700000205000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Tyrone Corum WR23", + "position": "WR", + "nflTeam": "ATL", + "teamId": "team-6", + "teamName": "Team 6", + "price": 19 + } + }, + { + "id": "e206", + "type": "NOMINATION", + "ts": 1700000206000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Quentin Corum TE4", + "position": "TE", + "nflTeam": "CHI", + "openingBid": 1, + "nominatingTeamId": "team-5" + } + }, + { + "id": "e207", + "type": "BID", + "ts": 1700000207000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 11, + "teamId": "team-9" + } + }, + { + "id": "e208", + "type": "BID", + "ts": 1700000208000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 18, + "teamId": "team-8" + } + }, + { + "id": "e209", + "type": "SOLD", + "ts": 1700000209000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Quentin Corum TE4", + "position": "TE", + "nflTeam": "CHI", + "teamId": "team-7", + "teamName": "Team 7", + "price": 23 + } + }, + { + "id": "e210", + "type": "NOMINATION", + "ts": 1700000210000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Rome Tracy WR20", + "position": "WR", + "nflTeam": "CHI", + "openingBid": 1, + "nominatingTeamId": "team-2" + } + }, + { + "id": "e211", + "type": "BID", + "ts": 1700000211000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 8, + "teamId": "team-5" + } + }, + { + "id": "e212", + "type": "BID", + "ts": 1700000212000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 13, + "teamId": "team-1" + } + }, + { + "id": "e213", + "type": "SOLD", + "ts": 1700000213000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Rome Tracy WR20", + "position": "WR", + "nflTeam": "CHI", + "teamId": "team-3", + "teamName": "Team 3", + "price": 17 + } + }, + { + "id": "e214", + "type": "NOMINATION", + "ts": 1700000214000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Chris Jefferson QB4", + "position": "QB", + "nflTeam": "HOU", + "openingBid": 1, + "nominatingTeamId": "team-1" + } + }, + { + "id": "e215", + "type": "BID", + "ts": 1700000215000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 9, + "teamId": "team-1" + } + }, + { + "id": "e216", + "type": "BID", + "ts": 1700000216000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 15, + "teamId": "team-2" + } + }, + { + "id": "e217", + "type": "SOLD", + "ts": 1700000217000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Chris Jefferson QB4", + "position": "QB", + "nflTeam": "HOU", + "teamId": "team-6", + "teamName": "Team 6", + "price": 18 + } + }, + { + "id": "e218", + "type": "NOMINATION", + "ts": 1700000218000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Malik Kupp RB19", + "position": "RB", + "nflTeam": "DET", + "openingBid": 1, + "nominatingTeamId": "team-6" + } + }, + { + "id": "e219", + "type": "BID", + "ts": 1700000219000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 10, + "teamId": "team-10" + } + }, + { + "id": "e220", + "type": "BID", + "ts": 1700000220000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 16, + "teamId": "team-3" + } + }, + { + "id": "e221", + "type": "SOLD", + "ts": 1700000221000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Malik Kupp RB19", + "position": "RB", + "nflTeam": "DET", + "teamId": "team-4", + "teamName": "Team 4", + "price": 20 + } + }, + { + "id": "e222", + "type": "NOMINATION", + "ts": 1700000222000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Bijan Nacua RB24", + "position": "RB", + "nflTeam": "ARI", + "openingBid": 1, + "nominatingTeamId": "team-2" + } + }, + { + "id": "e223", + "type": "BID", + "ts": 1700000223000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 7, + "teamId": "team-6" + } + }, + { + "id": "e224", + "type": "BID", + "ts": 1700000224000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 11, + "teamId": "team-4" + } + }, + { + "id": "e225", + "type": "SOLD", + "ts": 1700000225000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Bijan Nacua RB24", + "position": "RB", + "nflTeam": "ARI", + "teamId": "team-4", + "teamName": "Team 4", + "price": 14 + } + }, + { + "id": "e226", + "type": "NOMINATION", + "ts": 1700000226000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Khalil McBride WR25", + "position": "WR", + "nflTeam": "ATL", + "openingBid": 1, + "nominatingTeamId": "team-6" + } + }, + { + "id": "e227", + "type": "BID", + "ts": 1700000227000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 8, + "teamId": "team-4" + } + }, + { + "id": "e228", + "type": "BID", + "ts": 1700000228000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 13, + "teamId": "team-12" + } + }, + { + "id": "e229", + "type": "SOLD", + "ts": 1700000229000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Khalil McBride WR25", + "position": "WR", + "nflTeam": "ATL", + "teamId": "team-5", + "teamName": "Team 5", + "price": 16 + } + }, + { + "id": "e230", + "type": "NOMINATION", + "ts": 1700000230000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Amon-Ra Collins QB5", + "position": "QB", + "nflTeam": "DAL", + "openingBid": 1, + "nominatingTeamId": "team-7" + } + }, + { + "id": "e231", + "type": "BID", + "ts": 1700000231000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 8, + "teamId": "team-9" + } + }, + { + "id": "e232", + "type": "BID", + "ts": 1700000232000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 13, + "teamId": "team-7" + } + }, + { + "id": "e233", + "type": "SOLD", + "ts": 1700000233000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Amon-Ra Collins QB5", + "position": "QB", + "nflTeam": "DAL", + "teamId": "team-5", + "teamName": "Team 5", + "price": 16 + } + }, + { + "id": "e234", + "type": "NOMINATION", + "ts": 1700000234000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Jaxon Samuel WR27", + "position": "WR", + "nflTeam": "NO", + "openingBid": 1, + "nominatingTeamId": "team-11" + } + }, + { + "id": "e235", + "type": "BID", + "ts": 1700000235000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 8, + "teamId": "team-6" + } + }, + { + "id": "e236", + "type": "BID", + "ts": 1700000236000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 13, + "teamId": "team-8" + } + }, + { + "id": "e237", + "type": "SOLD", + "ts": 1700000237000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Jaxon Samuel WR27", + "position": "WR", + "nflTeam": "NO", + "teamId": "team-5", + "teamName": "Team 5", + "price": 17 + } + }, + { + "id": "e238", + "type": "NOMINATION", + "ts": 1700000238000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Deebo McBride RB20", + "position": "RB", + "nflTeam": "ARI", + "openingBid": 1, + "nominatingTeamId": "team-10" + } + }, + { + "id": "e239", + "type": "BID", + "ts": 1700000239000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 8, + "teamId": "team-5" + } + }, + { + "id": "e240", + "type": "BID", + "ts": 1700000240000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 13, + "teamId": "team-9" + } + }, + { + "id": "e241", + "type": "SOLD", + "ts": 1700000241000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Deebo McBride RB20", + "position": "RB", + "nflTeam": "ARI", + "teamId": "team-2", + "teamName": "Team 2", + "price": 17 + } + }, + { + "id": "e242", + "type": "NOMINATION", + "ts": 1700000242000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Puka Odunze QB6", + "position": "QB", + "nflTeam": "BAL", + "openingBid": 1, + "nominatingTeamId": "team-10" + } + }, + { + "id": "e243", + "type": "BID", + "ts": 1700000243000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 5, + "teamId": "team-7" + } + }, + { + "id": "e244", + "type": "BID", + "ts": 1700000244000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 8, + "teamId": "team-4" + } + }, + { + "id": "e245", + "type": "SOLD", + "ts": 1700000245000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Puka Odunze QB6", + "position": "QB", + "nflTeam": "BAL", + "teamId": "team-10", + "teamName": "Team 10", + "price": 10 + } + }, + { + "id": "e246", + "type": "NOMINATION", + "ts": 1700000246000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Tyrone Pacheco TE5", + "position": "TE", + "nflTeam": "SF", + "openingBid": 1, + "nominatingTeamId": "team-3" + } + }, + { + "id": "e247", + "type": "BID", + "ts": 1700000247000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 10, + "teamId": "team-7" + } + }, + { + "id": "e248", + "type": "BID", + "ts": 1700000248000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 16, + "teamId": "team-9" + } + }, + { + "id": "e249", + "type": "SOLD", + "ts": 1700000249000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Tyrone Pacheco TE5", + "position": "TE", + "nflTeam": "SF", + "teamId": "team-9", + "teamName": "Team 9", + "price": 20 + } + }, + { + "id": "e250", + "type": "NOMINATION", + "ts": 1700000250000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Zay London TE7", + "position": "TE", + "nflTeam": "ATL", + "openingBid": 1, + "nominatingTeamId": "team-11" + } + }, + { + "id": "e251", + "type": "BID", + "ts": 1700000251000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 5, + "teamId": "team-10" + } + }, + { + "id": "e252", + "type": "BID", + "ts": 1700000252000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 8, + "teamId": "team-9" + } + }, + { + "id": "e253", + "type": "SOLD", + "ts": 1700000253000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Zay London TE7", + "position": "TE", + "nflTeam": "ATL", + "teamId": "team-10", + "teamName": "Team 10", + "price": 10 + } + }, + { + "id": "e254", + "type": "NOMINATION", + "ts": 1700000254000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Micah Kupp RB21", + "position": "RB", + "nflTeam": "LAR", + "openingBid": 1, + "nominatingTeamId": "team-4" + } + }, + { + "id": "e255", + "type": "BID", + "ts": 1700000255000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 8, + "teamId": "team-9" + } + }, + { + "id": "e256", + "type": "BID", + "ts": 1700000256000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 13, + "teamId": "team-8" + } + }, + { + "id": "e257", + "type": "SOLD", + "ts": 1700000257000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Micah Kupp RB21", + "position": "RB", + "nflTeam": "LAR", + "teamId": "team-6", + "teamName": "Team 6", + "price": 16 + } + }, + { + "id": "e258", + "type": "NOMINATION", + "ts": 1700000258000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Justin Nacua QB7", + "position": "QB", + "nflTeam": "PHI", + "openingBid": 1, + "nominatingTeamId": "team-9" + } + }, + { + "id": "e259", + "type": "BID", + "ts": 1700000259000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 6, + "teamId": "team-3" + } + }, + { + "id": "e260", + "type": "BID", + "ts": 1700000260000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 10, + "teamId": "team-10" + } + }, + { + "id": "e261", + "type": "SOLD", + "ts": 1700000261000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Justin Nacua QB7", + "position": "QB", + "nflTeam": "PHI", + "teamId": "team-3", + "teamName": "Team 3", + "price": 13 + } + }, + { + "id": "e262", + "type": "NOMINATION", + "ts": 1700000262000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Kyren Shakir RB22", + "position": "RB", + "nflTeam": "SF", + "openingBid": 1, + "nominatingTeamId": "team-5" + } + }, + { + "id": "e263", + "type": "BID", + "ts": 1700000263000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 8, + "teamId": "team-7" + } + }, + { + "id": "e264", + "type": "BID", + "ts": 1700000264000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 13, + "teamId": "team-10" + } + }, + { + "id": "e265", + "type": "SOLD", + "ts": 1700000265000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Kyren Shakir RB22", + "position": "RB", + "nflTeam": "SF", + "teamId": "team-5", + "teamName": "Team 5", + "price": 16 + } + }, + { + "id": "e266", + "type": "NOMINATION", + "ts": 1700000266000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Zay Brown WR28", + "position": "WR", + "nflTeam": "CHI", + "openingBid": 1, + "nominatingTeamId": "team-12" + } + }, + { + "id": "e267", + "type": "BID", + "ts": 1700000267000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 6, + "teamId": "team-10" + } + }, + { + "id": "e268", + "type": "BID", + "ts": 1700000268000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 10, + "teamId": "team-5" + } + }, + { + "id": "e269", + "type": "SOLD", + "ts": 1700000269000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Zay Brown WR28", + "position": "WR", + "nflTeam": "CHI", + "teamId": "team-1", + "teamName": "Team 1", + "price": 12 + } + }, + { + "id": "e270", + "type": "NOMINATION", + "ts": 1700000270000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Trey Dell RB23", + "position": "RB", + "nflTeam": "ARI", + "openingBid": 1, + "nominatingTeamId": "team-5" + } + }, + { + "id": "e271", + "type": "BID", + "ts": 1700000271000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 9, + "teamId": "team-7" + } + }, + { + "id": "e272", + "type": "BID", + "ts": 1700000272000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 15, + "teamId": "team-11" + } + }, + { + "id": "e273", + "type": "SOLD", + "ts": 1700000273000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Trey Dell RB23", + "position": "RB", + "nflTeam": "ARI", + "teamId": "team-10", + "teamName": "Team 10", + "price": 18 + } + }, + { + "id": "e274", + "type": "NOMINATION", + "ts": 1700000274000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Amon-Ra London WR35", + "position": "WR", + "nflTeam": "CIN", + "openingBid": 1, + "nominatingTeamId": "team-5" + } + }, + { + "id": "e275", + "type": "BID", + "ts": 1700000275000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 6, + "teamId": "team-2" + } + }, + { + "id": "e276", + "type": "BID", + "ts": 1700000276000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 10, + "teamId": "team-11" + } + }, + { + "id": "e277", + "type": "SOLD", + "ts": 1700000277000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Amon-Ra London WR35", + "position": "WR", + "nflTeam": "CIN", + "teamId": "team-4", + "teamName": "Team 4", + "price": 13 + } + }, + { + "id": "e278", + "type": "NOMINATION", + "ts": 1700000278000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Jahmyr Tracy RB26", + "position": "RB", + "nflTeam": "BAL", + "openingBid": 1, + "nominatingTeamId": "team-11" + } + }, + { + "id": "e279", + "type": "BID", + "ts": 1700000279000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 6, + "teamId": "team-10" + } + }, + { + "id": "e280", + "type": "BID", + "ts": 1700000280000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 10, + "teamId": "team-7" + } + }, + { + "id": "e281", + "type": "SOLD", + "ts": 1700000281000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Jahmyr Tracy RB26", + "position": "RB", + "nflTeam": "BAL", + "teamId": "team-4", + "teamName": "Team 4", + "price": 13 + } + }, + { + "id": "e282", + "type": "NOMINATION", + "ts": 1700000282000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Zay Parsons WR37", + "position": "WR", + "nflTeam": "LAR", + "openingBid": 1, + "nominatingTeamId": "team-9" + } + }, + { + "id": "e283", + "type": "BID", + "ts": 1700000283000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 5, + "teamId": "team-3" + } + }, + { + "id": "e284", + "type": "BID", + "ts": 1700000284000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 8, + "teamId": "team-8" + } + }, + { + "id": "e285", + "type": "SOLD", + "ts": 1700000285000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Zay Parsons WR37", + "position": "WR", + "nflTeam": "LAR", + "teamId": "team-5", + "teamName": "Team 5", + "price": 11 + } + }, + { + "id": "e286", + "type": "NOMINATION", + "ts": 1700000286000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Chris Bowers TE6", + "position": "TE", + "nflTeam": "DET", + "openingBid": 1, + "nominatingTeamId": "team-1" + } + }, + { + "id": "e287", + "type": "BID", + "ts": 1700000287000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 6, + "teamId": "team-2" + } + }, + { + "id": "e288", + "type": "BID", + "ts": 1700000288000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 10, + "teamId": "team-10" + } + }, + { + "id": "e289", + "type": "SOLD", + "ts": 1700000289000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Chris Bowers TE6", + "position": "TE", + "nflTeam": "DET", + "teamId": "team-4", + "teamName": "Team 4", + "price": 13 + } + }, + { + "id": "e290", + "type": "NOMINATION", + "ts": 1700000290000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Tyrone McBride RB31", + "position": "RB", + "nflTeam": "BAL", + "openingBid": 1, + "nominatingTeamId": "team-8" + } + }, + { + "id": "e291", + "type": "BID", + "ts": 1700000291000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 5, + "teamId": "team-3" + } + }, + { + "id": "e292", + "type": "BID", + "ts": 1700000292000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 8, + "teamId": "team-5" + } + }, + { + "id": "e293", + "type": "SOLD", + "ts": 1700000293000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Tyrone McBride RB31", + "position": "RB", + "nflTeam": "BAL", + "teamId": "team-2", + "teamName": "Team 2", + "price": 11 + } + }, + { + "id": "e294", + "type": "NOMINATION", + "ts": 1700000294000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Micah Hall RB25", + "position": "RB", + "nflTeam": "NYG", + "openingBid": 1, + "nominatingTeamId": "team-1" + } + }, + { + "id": "e295", + "type": "BID", + "ts": 1700000295000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 7, + "teamId": "team-3" + } + }, + { + "id": "e296", + "type": "BID", + "ts": 1700000296000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 12, + "teamId": "team-7" + } + }, + { + "id": "e297", + "type": "SOLD", + "ts": 1700000297000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Micah Hall RB25", + "position": "RB", + "nflTeam": "NYG", + "teamId": "team-10", + "teamName": "Team 10", + "price": 15 + } + }, + { + "id": "e298", + "type": "NOMINATION", + "ts": 1700000298000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Malik Odunze WR30", + "position": "WR", + "nflTeam": "CHI", + "openingBid": 1, + "nominatingTeamId": "team-2" + } + }, + { + "id": "e299", + "type": "BID", + "ts": 1700000299000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 5, + "teamId": "team-7" + } + }, + { + "id": "e300", + "type": "BID", + "ts": 1700000300000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 8, + "teamId": "team-7" + } + }, + { + "id": "e301", + "type": "SOLD", + "ts": 1700000301000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Malik Odunze WR30", + "position": "WR", + "nflTeam": "CHI", + "teamId": "team-5", + "teamName": "Team 5", + "price": 10 + } + }, + { + "id": "e302", + "type": "NOMINATION", + "ts": 1700000302000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Marvin London RB34", + "position": "RB", + "nflTeam": "BUF", + "openingBid": 1, + "nominatingTeamId": "team-8" + } + }, + { + "id": "e303", + "type": "BID", + "ts": 1700000303000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 4, + "teamId": "team-3" + } + }, + { + "id": "e304", + "type": "BID", + "ts": 1700000304000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 6, + "teamId": "team-11" + } + }, + { + "id": "e305", + "type": "SOLD", + "ts": 1700000305000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Marvin London RB34", + "position": "RB", + "nflTeam": "BUF", + "teamId": "team-9", + "teamName": "Team 9", + "price": 8 + } + }, + { + "id": "e306", + "type": "NOMINATION", + "ts": 1700000306000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Marvin Kincaid WR29", + "position": "WR", + "nflTeam": "NYJ", + "openingBid": 1, + "nominatingTeamId": "team-4" + } + }, + { + "id": "e307", + "type": "BID", + "ts": 1700000307000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 6, + "teamId": "team-12" + } + }, + { + "id": "e308", + "type": "BID", + "ts": 1700000308000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 10, + "teamId": "team-4" + } + }, + { + "id": "e309", + "type": "SOLD", + "ts": 1700000309000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Marvin Kincaid WR29", + "position": "WR", + "nflTeam": "NYJ", + "teamId": "team-10", + "teamName": "Team 10", + "price": 12 + } + }, + { + "id": "e310", + "type": "NOMINATION", + "ts": 1700000310000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Rome Corum WR31", + "position": "WR", + "nflTeam": "DET", + "openingBid": 1, + "nominatingTeamId": "team-6" + } + }, + { + "id": "e311", + "type": "BID", + "ts": 1700000311000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 5, + "teamId": "team-11" + } + }, + { + "id": "e312", + "type": "BID", + "ts": 1700000312000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 8, + "teamId": "team-1" + } + }, + { + "id": "e313", + "type": "SOLD", + "ts": 1700000313000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Rome Corum WR31", + "position": "WR", + "nflTeam": "DET", + "teamId": "team-1", + "teamName": "Team 1", + "price": 10 + } + }, + { + "id": "e314", + "type": "NOMINATION", + "ts": 1700000314000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Nico McBride RB27", + "position": "RB", + "nflTeam": "SEA", + "openingBid": 1, + "nominatingTeamId": "team-1" + } + }, + { + "id": "e315", + "type": "BID", + "ts": 1700000315000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 6, + "teamId": "team-9" + } + }, + { + "id": "e316", + "type": "BID", + "ts": 1700000316000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 10, + "teamId": "team-8" + } + }, + { + "id": "e317", + "type": "SOLD", + "ts": 1700000317000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Nico McBride RB27", + "position": "RB", + "nflTeam": "SEA", + "teamId": "team-9", + "teamName": "Team 9", + "price": 12 + } + }, + { + "id": "e318", + "type": "NOMINATION", + "ts": 1700000318000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Rashee Samuel WR32", + "position": "WR", + "nflTeam": "KC", + "openingBid": 1, + "nominatingTeamId": "team-5" + } + }, + { + "id": "e319", + "type": "BID", + "ts": 1700000319000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 6, + "teamId": "team-7" + } + }, + { + "id": "e320", + "type": "BID", + "ts": 1700000320000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 10, + "teamId": "team-1" + } + }, + { + "id": "e321", + "type": "SOLD", + "ts": 1700000321000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Rashee Samuel WR32", + "position": "WR", + "nflTeam": "KC", + "teamId": "team-10", + "teamName": "Team 10", + "price": 13 + } + }, + { + "id": "e322", + "type": "NOMINATION", + "ts": 1700000322000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Rashee Gibbs WR33", + "position": "WR", + "nflTeam": "NO", + "openingBid": 1, + "nominatingTeamId": "team-3" + } + }, + { + "id": "e323", + "type": "BID", + "ts": 1700000323000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 4, + "teamId": "team-10" + } + }, + { + "id": "e324", + "type": "BID", + "ts": 1700000324000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 7, + "teamId": "team-6" + } + }, + { + "id": "e325", + "type": "SOLD", + "ts": 1700000325000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Rashee Gibbs WR33", + "position": "WR", + "nflTeam": "NO", + "teamId": "team-5", + "teamName": "Team 5", + "price": 9 + } + }, + { + "id": "e326", + "type": "NOMINATION", + "ts": 1700000326000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Breece Johnston WR34", + "position": "WR", + "nflTeam": "PHI", + "openingBid": 1, + "nominatingTeamId": "team-1" + } + }, + { + "id": "e327", + "type": "BID", + "ts": 1700000327000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 4, + "teamId": "team-7" + } + }, + { + "id": "e328", + "type": "BID", + "ts": 1700000328000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 7, + "teamId": "team-8" + } + }, + { + "id": "e329", + "type": "SOLD", + "ts": 1700000329000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Breece Johnston WR34", + "position": "WR", + "nflTeam": "PHI", + "teamId": "team-10", + "teamName": "Team 10", + "price": 9 + } + }, + { + "id": "e330", + "type": "NOMINATION", + "ts": 1700000330000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Deebo Smith-Njigba QB8", + "position": "QB", + "nflTeam": "SEA", + "openingBid": 1, + "nominatingTeamId": "team-9" + } + }, + { + "id": "e331", + "type": "BID", + "ts": 1700000331000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 4, + "teamId": "team-5" + } + }, + { + "id": "e332", + "type": "BID", + "ts": 1700000332000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 7, + "teamId": "team-6" + } + }, + { + "id": "e333", + "type": "SOLD", + "ts": 1700000333000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Deebo Smith-Njigba QB8", + "position": "QB", + "nflTeam": "SEA", + "teamId": "team-9", + "teamName": "Team 9", + "price": 9 + } + }, + { + "id": "e334", + "type": "NOMINATION", + "ts": 1700000334000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Micah Nacua RB28", + "position": "RB", + "nflTeam": "PHI", + "openingBid": 1, + "nominatingTeamId": "team-10" + } + }, + { + "id": "e335", + "type": "BID", + "ts": 1700000335000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 5, + "teamId": "team-6" + } + }, + { + "id": "e336", + "type": "BID", + "ts": 1700000336000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 8, + "teamId": "team-5" + } + }, + { + "id": "e337", + "type": "SOLD", + "ts": 1700000337000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Micah Nacua RB28", + "position": "RB", + "nflTeam": "PHI", + "teamId": "team-9", + "teamName": "Team 9", + "price": 10 + } + }, + { + "id": "e338", + "type": "NOMINATION", + "ts": 1700000338000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Micah Hall RB32", + "position": "RB", + "nflTeam": "NYJ", + "openingBid": 1, + "nominatingTeamId": "team-1" + } + }, + { + "id": "e339", + "type": "BID", + "ts": 1700000339000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 5, + "teamId": "team-9" + } + }, + { + "id": "e340", + "type": "BID", + "ts": 1700000340000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 8, + "teamId": "team-3" + } + }, + { + "id": "e341", + "type": "SOLD", + "ts": 1700000341000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Micah Hall RB32", + "position": "RB", + "nflTeam": "NYJ", + "teamId": "team-9", + "teamName": "Team 9", + "price": 10 + } + }, + { + "id": "e342", + "type": "NOMINATION", + "ts": 1700000342000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Amon-Ra Dell TE10", + "position": "TE", + "nflTeam": "MIN", + "openingBid": 1, + "nominatingTeamId": "team-8" + } + }, + { + "id": "e343", + "type": "BID", + "ts": 1700000343000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 3, + "teamId": "team-2" + } + }, + { + "id": "e344", + "type": "BID", + "ts": 1700000344000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 5, + "teamId": "team-8" + } + }, + { + "id": "e345", + "type": "SOLD", + "ts": 1700000345000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Amon-Ra Dell TE10", + "position": "TE", + "nflTeam": "MIN", + "teamId": "team-2", + "teamName": "Team 2", + "price": 7 + } + }, + { + "id": "e346", + "type": "NOMINATION", + "ts": 1700000346000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Rome LaPorta WR38", + "position": "WR", + "nflTeam": "DAL", + "openingBid": 1, + "nominatingTeamId": "team-9" + } + }, + { + "id": "e347", + "type": "BID", + "ts": 1700000347000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 4, + "teamId": "team-9" + } + }, + { + "id": "e348", + "type": "BID", + "ts": 1700000348000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 7, + "teamId": "team-3" + } + }, + { + "id": "e349", + "type": "SOLD", + "ts": 1700000349000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Rome LaPorta WR38", + "position": "WR", + "nflTeam": "DAL", + "teamId": "team-10", + "teamName": "Team 10", + "price": 9 + } + }, + { + "id": "e350", + "type": "NOMINATION", + "ts": 1700000350000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Isiah Shakir RB29", + "position": "RB", + "nflTeam": "NYG", + "openingBid": 1, + "nominatingTeamId": "team-9" + } + }, + { + "id": "e351", + "type": "BID", + "ts": 1700000351000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 4, + "teamId": "team-12" + } + }, + { + "id": "e352", + "type": "BID", + "ts": 1700000352000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 7, + "teamId": "team-8" + } + }, + { + "id": "e353", + "type": "SOLD", + "ts": 1700000353000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Isiah Shakir RB29", + "position": "RB", + "nflTeam": "NYG", + "teamId": "team-5", + "teamName": "Team 5", + "price": 9 + } + }, + { + "id": "e354", + "type": "NOMINATION", + "ts": 1700000354000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Trey Johnston WR36", + "position": "WR", + "nflTeam": "SF", + "openingBid": 1, + "nominatingTeamId": "team-10" + } + }, + { + "id": "e355", + "type": "BID", + "ts": 1700000355000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 6, + "teamId": "team-3" + } + }, + { + "id": "e356", + "type": "BID", + "ts": 1700000356000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 10, + "teamId": "team-12" + } + }, + { + "id": "e357", + "type": "SOLD", + "ts": 1700000357000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Trey Johnston WR36", + "position": "WR", + "nflTeam": "SF", + "teamId": "team-2", + "teamName": "Team 2", + "price": 12 + } + }, + { + "id": "e358", + "type": "NOMINATION", + "ts": 1700000358000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Brock Hall TE8", + "position": "TE", + "nflTeam": "LAR", + "openingBid": 1, + "nominatingTeamId": "team-2" + } + }, + { + "id": "e359", + "type": "BID", + "ts": 1700000359000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 5, + "teamId": "team-3" + } + }, + { + "id": "e360", + "type": "BID", + "ts": 1700000360000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 8, + "teamId": "team-6" + } + }, + { + "id": "e361", + "type": "SOLD", + "ts": 1700000361000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Brock Hall TE8", + "position": "TE", + "nflTeam": "LAR", + "teamId": "team-11", + "teamName": "Team 11", + "price": 10 + } + }, + { + "id": "e362", + "type": "NOMINATION", + "ts": 1700000362000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Cooper Pacheco RB30", + "position": "RB", + "nflTeam": "KC", + "openingBid": 1, + "nominatingTeamId": "team-11" + } + }, + { + "id": "e363", + "type": "BID", + "ts": 1700000363000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 4, + "teamId": "team-11" + } + }, + { + "id": "e364", + "type": "BID", + "ts": 1700000364000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 6, + "teamId": "team-9" + } + }, + { + "id": "e365", + "type": "SOLD", + "ts": 1700000365000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Cooper Pacheco RB30", + "position": "RB", + "nflTeam": "KC", + "teamId": "team-10", + "teamName": "Team 10", + "price": 8 + } + }, + { + "id": "e366", + "type": "NOMINATION", + "ts": 1700000366000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Khalil Nacua QB9", + "position": "QB", + "nflTeam": "DET", + "openingBid": 1, + "nominatingTeamId": "team-11" + } + }, + { + "id": "e367", + "type": "BID", + "ts": 1700000367000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 4, + "teamId": "team-1" + } + }, + { + "id": "e368", + "type": "BID", + "ts": 1700000368000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 7, + "teamId": "team-9" + } + }, + { + "id": "e369", + "type": "SOLD", + "ts": 1700000369000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Khalil Nacua QB9", + "position": "QB", + "nflTeam": "DET", + "teamId": "team-5", + "teamName": "Team 5", + "price": 9 + } + }, + { + "id": "e370", + "type": "NOMINATION", + "ts": 1700000370000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Justin Lamb RB33", + "position": "RB", + "nflTeam": "DET", + "openingBid": 1, + "nominatingTeamId": "team-4" + } + }, + { + "id": "e371", + "type": "BID", + "ts": 1700000371000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 3, + "teamId": "team-2" + } + }, + { + "id": "e372", + "type": "BID", + "ts": 1700000372000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 5, + "teamId": "team-6" + } + }, + { + "id": "e373", + "type": "SOLD", + "ts": 1700000373000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Justin Lamb RB33", + "position": "RB", + "nflTeam": "DET", + "teamId": "team-5", + "teamName": "Team 5", + "price": 7 + } + }, + { + "id": "e374", + "type": "NOMINATION", + "ts": 1700000374000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Puka Gibbs WR39", + "position": "WR", + "nflTeam": "ATL", + "openingBid": 1, + "nominatingTeamId": "team-6" + } + }, + { + "id": "e375", + "type": "BID", + "ts": 1700000375000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 5, + "teamId": "team-4" + } + }, + { + "id": "e376", + "type": "BID", + "ts": 1700000376000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 8, + "teamId": "team-6" + } + }, + { + "id": "e377", + "type": "SOLD", + "ts": 1700000377000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Puka Gibbs WR39", + "position": "WR", + "nflTeam": "ATL", + "teamId": "team-11", + "teamName": "Team 11", + "price": 10 + } + }, + { + "id": "e378", + "type": "NOMINATION", + "ts": 1700000378000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Amon-Ra Wilson WR40", + "position": "WR", + "nflTeam": "DAL", + "openingBid": 1, + "nominatingTeamId": "team-10" + } + }, + { + "id": "e379", + "type": "BID", + "ts": 1700000379000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 3, + "teamId": "team-1" + } + }, + { + "id": "e380", + "type": "BID", + "ts": 1700000380000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 5, + "teamId": "team-3" + } + }, + { + "id": "e381", + "type": "SOLD", + "ts": 1700000381000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Amon-Ra Wilson WR40", + "position": "WR", + "nflTeam": "DAL", + "teamId": "team-6", + "teamName": "Team 6", + "price": 7 + } + }, + { + "id": "e382", + "type": "NOMINATION", + "ts": 1700000382000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Trey Pacheco TE9", + "position": "TE", + "nflTeam": "PHI", + "openingBid": 1, + "nominatingTeamId": "team-10" + } + }, + { + "id": "e383", + "type": "BID", + "ts": 1700000383000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 4, + "teamId": "team-10" + } + }, + { + "id": "e384", + "type": "BID", + "ts": 1700000384000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 7, + "teamId": "team-9" + } + }, + { + "id": "e385", + "type": "SOLD", + "ts": 1700000385000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Trey Pacheco TE9", + "position": "TE", + "nflTeam": "PHI", + "teamId": "team-2", + "teamName": "Team 2", + "price": 9 + } + }, + { + "id": "e386", + "type": "NOMINATION", + "ts": 1700000386000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Sam McBride WR45", + "position": "WR", + "nflTeam": "DET", + "openingBid": 1, + "nominatingTeamId": "team-2" + } + }, + { + "id": "e387", + "type": "BID", + "ts": 1700000387000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 4, + "teamId": "team-3" + } + }, + { + "id": "e388", + "type": "BID", + "ts": 1700000388000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 6, + "teamId": "team-3" + } + }, + { + "id": "e389", + "type": "SOLD", + "ts": 1700000389000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Sam McBride WR45", + "position": "WR", + "nflTeam": "DET", + "teamId": "team-11", + "teamName": "Team 11", + "price": 8 + } + }, + { + "id": "e390", + "type": "NOMINATION", + "ts": 1700000390000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Tyrone Flowers RB38", + "position": "RB", + "nflTeam": "ATL", + "openingBid": 1, + "nominatingTeamId": "team-8" + } + }, + { + "id": "e391", + "type": "BID", + "ts": 1700000391000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 4, + "teamId": "team-8" + } + }, + { + "id": "e392", + "type": "BID", + "ts": 1700000392000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 6, + "teamId": "team-1" + } + }, + { + "id": "e393", + "type": "SOLD", + "ts": 1700000393000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Tyrone Flowers RB38", + "position": "RB", + "nflTeam": "ATL", + "teamId": "team-6", + "teamName": "Team 6", + "price": 8 + } + }, + { + "id": "e394", + "type": "NOMINATION", + "ts": 1700000394000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Khalil Kincaid QB10", + "position": "QB", + "nflTeam": "PHI", + "openingBid": 1, + "nominatingTeamId": "team-4" + } + }, + { + "id": "e395", + "type": "BID", + "ts": 1700000395000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 4, + "teamId": "team-6" + } + }, + { + "id": "e396", + "type": "BID", + "ts": 1700000396000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 6, + "teamId": "team-3" + } + }, + { + "id": "e397", + "type": "SOLD", + "ts": 1700000397000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Khalil Kincaid QB10", + "position": "QB", + "nflTeam": "PHI", + "teamId": "team-11", + "teamName": "Team 11", + "price": 8 + } + }, + { + "id": "e398", + "type": "NOMINATION", + "ts": 1700000398000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Cooper Johnston RB35", + "position": "RB", + "nflTeam": "DAL", + "openingBid": 1, + "nominatingTeamId": "team-10" + } + }, + { + "id": "e399", + "type": "BID", + "ts": 1700000399000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 4, + "teamId": "team-6" + } + }, + { + "id": "e400", + "type": "BID", + "ts": 1700000400000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 6, + "teamId": "team-9" + } + }, + { + "id": "e401", + "type": "SOLD", + "ts": 1700000401000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Cooper Johnston RB35", + "position": "RB", + "nflTeam": "DAL", + "teamId": "team-10", + "teamName": "Team 10", + "price": 8 + } + }, + { + "id": "e402", + "type": "NOMINATION", + "ts": 1700000402000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Drake Tracy WR44", + "position": "WR", + "nflTeam": "LAR", + "openingBid": 1, + "nominatingTeamId": "team-6" + } + }, + { + "id": "e403", + "type": "BID", + "ts": 1700000403000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 3, + "teamId": "team-12" + } + }, + { + "id": "e404", + "type": "BID", + "ts": 1700000404000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 5, + "teamId": "team-3" + } + }, + { + "id": "e405", + "type": "SOLD", + "ts": 1700000405000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Drake Tracy WR44", + "position": "WR", + "nflTeam": "LAR", + "teamId": "team-11", + "teamName": "Team 11", + "price": 7 + } + }, + { + "id": "e406", + "type": "NOMINATION", + "ts": 1700000406000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Nico Johnston TE12", + "position": "TE", + "nflTeam": "CIN", + "openingBid": 1, + "nominatingTeamId": "team-4" + } + }, + { + "id": "e407", + "type": "BID", + "ts": 1700000407000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 3, + "teamId": "team-1" + } + }, + { + "id": "e408", + "type": "BID", + "ts": 1700000408000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 5, + "teamId": "team-8" + } + }, + { + "id": "e409", + "type": "SOLD", + "ts": 1700000409000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Nico Johnston TE12", + "position": "TE", + "nflTeam": "CIN", + "teamId": "team-2", + "teamName": "Team 2", + "price": 6 + } + }, + { + "id": "e410", + "type": "NOMINATION", + "ts": 1700000410000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Tyrone Samuel WR47", + "position": "WR", + "nflTeam": "CHI", + "openingBid": 1, + "nominatingTeamId": "team-8" + } + }, + { + "id": "e411", + "type": "BID", + "ts": 1700000411000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 3, + "teamId": "team-11" + } + }, + { + "id": "e412", + "type": "BID", + "ts": 1700000412000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 5, + "teamId": "team-3" + } + }, + { + "id": "e413", + "type": "SOLD", + "ts": 1700000413000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Tyrone Samuel WR47", + "position": "WR", + "nflTeam": "CHI", + "teamId": "team-5", + "teamName": "Team 5", + "price": 6 + } + }, + { + "id": "e414", + "type": "NOMINATION", + "ts": 1700000414000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Brock Lamb RB37", + "position": "RB", + "nflTeam": "NYJ", + "openingBid": 1, + "nominatingTeamId": "team-1" + } + }, + { + "id": "e415", + "type": "BID", + "ts": 1700000415000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 3, + "teamId": "team-11" + } + }, + { + "id": "e416", + "type": "BID", + "ts": 1700000416000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 5, + "teamId": "team-2" + } + }, + { + "id": "e417", + "type": "SOLD", + "ts": 1700000417000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Brock Lamb RB37", + "position": "RB", + "nflTeam": "NYJ", + "teamId": "team-11", + "teamName": "Team 11", + "price": 7 + } + }, + { + "id": "e418", + "type": "NOMINATION", + "ts": 1700000418000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Brock Dell WR41", + "position": "WR", + "nflTeam": "MIN", + "openingBid": 1, + "nominatingTeamId": "team-1" + } + }, + { + "id": "e419", + "type": "BID", + "ts": 1700000419000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 3, + "teamId": "team-11" + } + }, + { + "id": "e420", + "type": "BID", + "ts": 1700000420000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 5, + "teamId": "team-10" + } + }, + { + "id": "e421", + "type": "SOLD", + "ts": 1700000421000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Brock Dell WR41", + "position": "WR", + "nflTeam": "MIN", + "teamId": "team-7", + "teamName": "Team 7", + "price": 6 + } + }, + { + "id": "e422", + "type": "NOMINATION", + "ts": 1700000422000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Micah Nabers TE11", + "position": "TE", + "nflTeam": "CIN", + "openingBid": 1, + "nominatingTeamId": "team-10" + } + }, + { + "id": "e423", + "type": "BID", + "ts": 1700000423000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 3, + "teamId": "team-2" + } + }, + { + "id": "e424", + "type": "BID", + "ts": 1700000424000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 5, + "teamId": "team-2" + } + }, + { + "id": "e425", + "type": "SOLD", + "ts": 1700000425000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Micah Nabers TE11", + "position": "TE", + "nflTeam": "CIN", + "teamId": "team-2", + "teamName": "Team 2", + "price": 6 + } + }, + { + "id": "e426", + "type": "NOMINATION", + "ts": 1700000426000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Jordan Samuel QB11", + "position": "QB", + "nflTeam": "LAR", + "openingBid": 1, + "nominatingTeamId": "team-6" + } + }, + { + "id": "e427", + "type": "BID", + "ts": 1700000427000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 3, + "teamId": "team-12" + } + }, + { + "id": "e428", + "type": "BID", + "ts": 1700000428000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 5, + "teamId": "team-4" + } + }, + { + "id": "e429", + "type": "SOLD", + "ts": 1700000429000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Jordan Samuel QB11", + "position": "QB", + "nflTeam": "LAR", + "teamId": "team-2", + "teamName": "Team 2", + "price": 7 + } + }, + { + "id": "e430", + "type": "NOMINATION", + "ts": 1700000430000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Jordan Hall RB36", + "position": "RB", + "nflTeam": "CIN", + "openingBid": 1, + "nominatingTeamId": "team-9" + } + }, + { + "id": "e431", + "type": "BID", + "ts": 1700000431000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 3, + "teamId": "team-7" + } + }, + { + "id": "e432", + "type": "BID", + "ts": 1700000432000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 5, + "teamId": "team-6" + } + }, + { + "id": "e433", + "type": "SOLD", + "ts": 1700000433000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Jordan Hall RB36", + "position": "RB", + "nflTeam": "CIN", + "teamId": "team-11", + "teamName": "Team 11", + "price": 7 + } + }, + { + "id": "e434", + "type": "NOMINATION", + "ts": 1700000434000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Bijan Jefferson RB39", + "position": "RB", + "nflTeam": "LV", + "openingBid": 1, + "nominatingTeamId": "team-8" + } + }, + { + "id": "e435", + "type": "BID", + "ts": 1700000435000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 3, + "teamId": "team-8" + } + }, + { + "id": "e436", + "type": "BID", + "ts": 1700000436000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 5, + "teamId": "team-4" + } + }, + { + "id": "e437", + "type": "SOLD", + "ts": 1700000437000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Bijan Jefferson RB39", + "position": "RB", + "nflTeam": "LV", + "teamId": "team-2", + "teamName": "Team 2", + "price": 7 + } + }, + { + "id": "e438", + "type": "NOMINATION", + "ts": 1700000438000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Micah Williams RB40", + "position": "RB", + "nflTeam": "CIN", + "openingBid": 1, + "nominatingTeamId": "team-2" + } + }, + { + "id": "e439", + "type": "BID", + "ts": 1700000439000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 4, + "teamId": "team-9" + } + }, + { + "id": "e440", + "type": "BID", + "ts": 1700000440000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 6, + "teamId": "team-6" + } + }, + { + "id": "e441", + "type": "SOLD", + "ts": 1700000441000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Micah Williams RB40", + "position": "RB", + "nflTeam": "CIN", + "teamId": "team-11", + "teamName": "Team 11", + "price": 8 + } + }, + { + "id": "e442", + "type": "NOMINATION", + "ts": 1700000442000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Malik Parsons RB46", + "position": "RB", + "nflTeam": "ARI", + "openingBid": 1, + "nominatingTeamId": "team-1" + } + }, + { + "id": "e443", + "type": "BID", + "ts": 1700000443000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 1, + "teamId": "team-4" + } + }, + { + "id": "e444", + "type": "BID", + "ts": 1700000444000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 2, + "teamId": "team-7" + } + }, + { + "id": "e445", + "type": "SOLD", + "ts": 1700000445000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Malik Parsons RB46", + "position": "RB", + "nflTeam": "ARI", + "teamId": "team-10", + "teamName": "Team 10", + "price": 3 + } + }, + { + "id": "e446", + "type": "NOMINATION", + "ts": 1700000446000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Puka Shakir WR42", + "position": "WR", + "nflTeam": "SF", + "openingBid": 1, + "nominatingTeamId": "team-7" + } + }, + { + "id": "e447", + "type": "BID", + "ts": 1700000447000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 3, + "teamId": "team-12" + } + }, + { + "id": "e448", + "type": "BID", + "ts": 1700000448000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 5, + "teamId": "team-2" + } + }, + { + "id": "e449", + "type": "SOLD", + "ts": 1700000449000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Puka Shakir WR42", + "position": "WR", + "nflTeam": "SF", + "teamId": "team-3", + "teamName": "Team 3", + "price": 6 + } + }, + { + "id": "e450", + "type": "NOMINATION", + "ts": 1700000450000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Tank Tracy WR49", + "position": "WR", + "nflTeam": "ARI", + "openingBid": 1, + "nominatingTeamId": "team-9" + } + }, + { + "id": "e451", + "type": "BID", + "ts": 1700000451000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 3, + "teamId": "team-3" + } + }, + { + "id": "e452", + "type": "BID", + "ts": 1700000452000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 5, + "teamId": "team-9" + } + }, + { + "id": "e453", + "type": "SOLD", + "ts": 1700000453000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Tank Tracy WR49", + "position": "WR", + "nflTeam": "ARI", + "teamId": "team-2", + "teamName": "Team 2", + "price": 6 + } + }, + { + "id": "e454", + "type": "NOMINATION", + "ts": 1700000454000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Rome Brown WR43", + "position": "WR", + "nflTeam": "SEA", + "openingBid": 1, + "nominatingTeamId": "team-3" + } + }, + { + "id": "e455", + "type": "BID", + "ts": 1700000455000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 3, + "teamId": "team-1" + } + }, + { + "id": "e456", + "type": "BID", + "ts": 1700000456000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 5, + "teamId": "team-3" + } + }, + { + "id": "e457", + "type": "SOLD", + "ts": 1700000457000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Rome Brown WR43", + "position": "WR", + "nflTeam": "SEA", + "teamId": "team-11", + "teamName": "Team 11", + "price": 6 + } + }, + { + "id": "e458", + "type": "NOMINATION", + "ts": 1700000458000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Bijan Kincaid QB12", + "position": "QB", + "nflTeam": "NO", + "openingBid": 1, + "nominatingTeamId": "team-6" + } + }, + { + "id": "e459", + "type": "BID", + "ts": 1700000459000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 3, + "teamId": "team-7" + } + }, + { + "id": "e460", + "type": "BID", + "ts": 1700000460000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 5, + "teamId": "team-12" + } + }, + { + "id": "e461", + "type": "SOLD", + "ts": 1700000461000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Bijan Kincaid QB12", + "position": "QB", + "nflTeam": "NO", + "teamId": "team-11", + "teamName": "Team 11", + "price": 6 + } + }, + { + "id": "e462", + "type": "NOMINATION", + "ts": 1700000462000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Marvin Gibbs RB48", + "position": "RB", + "nflTeam": "NYJ", + "openingBid": 1, + "nominatingTeamId": "team-4" + } + }, + { + "id": "e463", + "type": "BID", + "ts": 1700000463000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 1, + "teamId": "team-11" + } + }, + { + "id": "e464", + "type": "BID", + "ts": 1700000464000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 2, + "teamId": "team-8" + } + }, + { + "id": "e465", + "type": "SOLD", + "ts": 1700000465000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Marvin Gibbs RB48", + "position": "RB", + "nflTeam": "NYJ", + "teamId": "team-12", + "teamName": "Team 12", + "price": 3 + } + }, + { + "id": "e466", + "type": "NOMINATION", + "ts": 1700000466000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Tank Harrison RB41", + "position": "RB", + "nflTeam": "SEA", + "openingBid": 1, + "nominatingTeamId": "team-4" + } + }, + { + "id": "e467", + "type": "BID", + "ts": 1700000467000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 3, + "teamId": "team-2" + } + }, + { + "id": "e468", + "type": "BID", + "ts": 1700000468000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 5, + "teamId": "team-8" + } + }, + { + "id": "e469", + "type": "SOLD", + "ts": 1700000469000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Tank Harrison RB41", + "position": "RB", + "nflTeam": "SEA", + "teamId": "team-11", + "teamName": "Team 11", + "price": 6 + } + }, + { + "id": "e470", + "type": "NOMINATION", + "ts": 1700000470000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Malik Tracy RB47", + "position": "RB", + "nflTeam": "CHI", + "openingBid": 1, + "nominatingTeamId": "team-7" + } + }, + { + "id": "e471", + "type": "BID", + "ts": 1700000471000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 1, + "teamId": "team-7" + } + }, + { + "id": "e472", + "type": "BID", + "ts": 1700000472000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 2, + "teamId": "team-11" + } + }, + { + "id": "e473", + "type": "SOLD", + "ts": 1700000473000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Malik Tracy RB47", + "position": "RB", + "nflTeam": "CHI", + "teamId": "team-2", + "teamName": "Team 2", + "price": 3 + } + }, + { + "id": "e474", + "type": "NOMINATION", + "ts": 1700000474000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Tank Parsons RB42", + "position": "RB", + "nflTeam": "NO", + "openingBid": 1, + "nominatingTeamId": "team-6" + } + }, + { + "id": "e475", + "type": "BID", + "ts": 1700000475000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 3, + "teamId": "team-11" + } + }, + { + "id": "e476", + "type": "BID", + "ts": 1700000476000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 5, + "teamId": "team-1" + } + }, + { + "id": "e477", + "type": "SOLD", + "ts": 1700000477000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Tank Parsons RB42", + "position": "RB", + "nflTeam": "NO", + "teamId": "team-11", + "teamName": "Team 11", + "price": 6 + } + }, + { + "id": "e478", + "type": "NOMINATION", + "ts": 1700000478000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Garrett Pacheco RB44", + "position": "RB", + "nflTeam": "NO", + "openingBid": 1, + "nominatingTeamId": "team-7" + } + }, + { + "id": "e479", + "type": "BID", + "ts": 1700000479000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 2, + "teamId": "team-6" + } + }, + { + "id": "e480", + "type": "BID", + "ts": 1700000480000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 3, + "teamId": "team-8" + } + }, + { + "id": "e481", + "type": "BID", + "ts": 1700000481000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 4, + "teamId": "team-3" + } + }, + { + "id": "e482", + "type": "SOLD", + "ts": 1700000482000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Garrett Pacheco RB44", + "position": "RB", + "nflTeam": "NO", + "teamId": "team-9", + "teamName": "Team 9", + "price": 5 + } + }, + { + "id": "e483", + "type": "NOMINATION", + "ts": 1700000483000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Garrett Harrison WR46", + "position": "WR", + "nflTeam": "KC", + "openingBid": 1, + "nominatingTeamId": "team-2" + } + }, + { + "id": "e484", + "type": "BID", + "ts": 1700000484000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 2, + "teamId": "team-12" + } + }, + { + "id": "e485", + "type": "BID", + "ts": 1700000485000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 3, + "teamId": "team-8" + } + }, + { + "id": "e486", + "type": "SOLD", + "ts": 1700000486000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Garrett Harrison WR46", + "position": "WR", + "nflTeam": "KC", + "teamId": "team-6", + "teamName": "Team 6", + "price": 4 + } + }, + { + "id": "e487", + "type": "NOMINATION", + "ts": 1700000487000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "CeeDee Jefferson WR48", + "position": "WR", + "nflTeam": "ARI", + "openingBid": 1, + "nominatingTeamId": "team-8" + } + }, + { + "id": "e488", + "type": "BID", + "ts": 1700000488000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 1, + "teamId": "team-9" + } + }, + { + "id": "e489", + "type": "BID", + "ts": 1700000489000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 2, + "teamId": "team-12" + } + }, + { + "id": "e490", + "type": "SOLD", + "ts": 1700000490000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "CeeDee Jefferson WR48", + "position": "WR", + "nflTeam": "ARI", + "teamId": "team-1", + "teamName": "Team 1", + "price": 3 + } + }, + { + "id": "e491", + "type": "NOMINATION", + "ts": 1700000491000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Breece Nacua TE13", + "position": "TE", + "nflTeam": "DAL", + "openingBid": 1, + "nominatingTeamId": "team-7" + } + }, + { + "id": "e492", + "type": "BID", + "ts": 1700000492000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 3, + "teamId": "team-7" + } + }, + { + "id": "e493", + "type": "BID", + "ts": 1700000493000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 5, + "teamId": "team-4" + } + }, + { + "id": "e494", + "type": "SOLD", + "ts": 1700000494000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Breece Nacua TE13", + "position": "TE", + "nflTeam": "DAL", + "teamId": "team-5", + "teamName": "Team 5", + "price": 6 + } + }, + { + "id": "e495", + "type": "NOMINATION", + "ts": 1700000495000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Khalil Collins QB13", + "position": "QB", + "nflTeam": "KC", + "openingBid": 1, + "nominatingTeamId": "team-8" + } + }, + { + "id": "e496", + "type": "BID", + "ts": 1700000496000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 1, + "teamId": "team-12" + } + }, + { + "id": "e497", + "type": "SOLD", + "ts": 1700000497000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Khalil Collins QB13", + "position": "QB", + "nflTeam": "KC", + "teamId": "team-4", + "teamName": "Team 4", + "price": 2 + } + }, + { + "id": "e498", + "type": "NOMINATION", + "ts": 1700000498000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Nico Hall RB43", + "position": "RB", + "nflTeam": "NO", + "openingBid": 1, + "nominatingTeamId": "team-4" + } + }, + { + "id": "e499", + "type": "BID", + "ts": 1700000499000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 1, + "teamId": "team-8" + } + }, + { + "id": "e500", + "type": "SOLD", + "ts": 1700000500000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Nico Hall RB43", + "position": "RB", + "nflTeam": "NO", + "teamId": "team-8", + "teamName": "Team 8", + "price": 2 + } + }, + { + "id": "e501", + "type": "NOMINATION", + "ts": 1700000501000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "CeeDee Rice RB49", + "position": "RB", + "nflTeam": "ARI", + "openingBid": 1, + "nominatingTeamId": "team-8" + } + }, + { + "id": "e502", + "type": "BID", + "ts": 1700000502000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 1, + "teamId": "team-3" + } + }, + { + "id": "e503", + "type": "SOLD", + "ts": 1700000503000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "CeeDee Rice RB49", + "position": "RB", + "nflTeam": "ARI", + "teamId": "team-12", + "teamName": "Team 12", + "price": 2 + } + }, + { + "id": "e504", + "type": "NOMINATION", + "ts": 1700000504000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Malik Odunze WR50", + "position": "WR", + "nflTeam": "KC", + "openingBid": 1, + "nominatingTeamId": "team-9" + } + }, + { + "id": "e505", + "type": "SOLD", + "ts": 1700000505000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Malik Odunze WR50", + "position": "WR", + "nflTeam": "KC", + "teamId": "team-12", + "teamName": "Team 12", + "price": 1 + } + }, + { + "id": "e506", + "type": "NOMINATION", + "ts": 1700000506000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Jahmyr Johnston WR51", + "position": "WR", + "nflTeam": "BUF", + "openingBid": 1, + "nominatingTeamId": "team-12" + } + }, + { + "id": "e507", + "type": "SOLD", + "ts": 1700000507000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Jahmyr Johnston WR51", + "position": "WR", + "nflTeam": "BUF", + "teamId": "team-7", + "teamName": "Team 7", + "price": 1 + } + }, + { + "id": "e508", + "type": "NOMINATION", + "ts": 1700000508000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Kyren Tracy WR52", + "position": "WR", + "nflTeam": "PHI", + "openingBid": 1, + "nominatingTeamId": "team-8" + } + }, + { + "id": "e509", + "type": "SOLD", + "ts": 1700000509000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Kyren Tracy WR52", + "position": "WR", + "nflTeam": "PHI", + "teamId": "team-8", + "teamName": "Team 8", + "price": 1 + } + }, + { + "id": "e510", + "type": "NOMINATION", + "ts": 1700000510000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Malik Brown TE14", + "position": "TE", + "nflTeam": "HOU", + "openingBid": 1, + "nominatingTeamId": "team-9" + } + }, + { + "id": "e511", + "type": "SOLD", + "ts": 1700000511000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Malik Brown TE14", + "position": "TE", + "nflTeam": "HOU", + "teamId": "team-7", + "teamName": "Team 7", + "price": 1 + } + }, + { + "id": "e512", + "type": "NOMINATION", + "ts": 1700000512000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "CeeDee London QB14", + "position": "QB", + "nflTeam": "HOU", + "openingBid": 1, + "nominatingTeamId": "team-3" + } + }, + { + "id": "e513", + "type": "SOLD", + "ts": 1700000513000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "CeeDee London QB14", + "position": "QB", + "nflTeam": "HOU", + "teamId": "team-8", + "teamName": "Team 8", + "price": 1 + } + }, + { + "id": "e514", + "type": "NOMINATION", + "ts": 1700000514000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Rashee Robinson QB15", + "position": "QB", + "nflTeam": "SEA", + "openingBid": 1, + "nominatingTeamId": "team-8" + } + }, + { + "id": "e515", + "type": "SOLD", + "ts": 1700000515000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Rashee Robinson QB15", + "position": "QB", + "nflTeam": "SEA", + "teamId": "team-3", + "teamName": "Team 3", + "price": 1 + } + }, + { + "id": "e516", + "type": "NOMINATION", + "ts": 1700000516000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Tank Pacheco WR57", + "position": "WR", + "nflTeam": "CHI", + "openingBid": 1, + "nominatingTeamId": "team-4" + } + }, + { + "id": "e517", + "type": "SOLD", + "ts": 1700000517000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Tank Pacheco WR57", + "position": "WR", + "nflTeam": "CHI", + "teamId": "team-3", + "teamName": "Team 3", + "price": 1 + } + }, + { + "id": "e518", + "type": "NOMINATION", + "ts": 1700000518000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Trey Brown RB45", + "position": "RB", + "nflTeam": "NYJ", + "openingBid": 1, + "nominatingTeamId": "team-4" + } + }, + { + "id": "e519", + "type": "SOLD", + "ts": 1700000519000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Trey Brown RB45", + "position": "RB", + "nflTeam": "NYJ", + "teamId": "team-3", + "teamName": "Team 3", + "price": 1 + } + }, + { + "id": "e520", + "type": "NOMINATION", + "ts": 1700000520000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Tyrone Pacheco RB50", + "position": "RB", + "nflTeam": "NYJ", + "openingBid": 1, + "nominatingTeamId": "team-12" + } + }, + { + "id": "e521", + "type": "SOLD", + "ts": 1700000521000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Tyrone Pacheco RB50", + "position": "RB", + "nflTeam": "NYJ", + "teamId": "team-3", + "teamName": "Team 3", + "price": 1 + } + }, + { + "id": "e522", + "type": "NOMINATION", + "ts": 1700000522000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Cooper Wilson WR53", + "position": "WR", + "nflTeam": "NO", + "openingBid": 1, + "nominatingTeamId": "team-8" + } + }, + { + "id": "e523", + "type": "SOLD", + "ts": 1700000523000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Cooper Wilson WR53", + "position": "WR", + "nflTeam": "NO", + "teamId": "team-2", + "teamName": "Team 2", + "price": 1 + } + }, + { + "id": "e524", + "type": "NOMINATION", + "ts": 1700000524000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Kyren Williams RB51", + "position": "RB", + "nflTeam": "DET", + "openingBid": 1, + "nominatingTeamId": "team-9" + } + }, + { + "id": "e525", + "type": "SOLD", + "ts": 1700000525000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Kyren Williams RB51", + "position": "RB", + "nflTeam": "DET", + "teamId": "team-1", + "teamName": "Team 1", + "price": 1 + } + }, + { + "id": "e526", + "type": "NOMINATION", + "ts": 1700000526000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Trey London QB18", + "position": "QB", + "nflTeam": "TB", + "openingBid": 1, + "nominatingTeamId": "team-4" + } + }, + { + "id": "e527", + "type": "SOLD", + "ts": 1700000527000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Trey London QB18", + "position": "QB", + "nflTeam": "TB", + "teamId": "team-3", + "teamName": "Team 3", + "price": 1 + } + }, + { + "id": "e528", + "type": "NOMINATION", + "ts": 1700000528000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "CeeDee Flowers WR54", + "position": "WR", + "nflTeam": "NYJ", + "openingBid": 1, + "nominatingTeamId": "team-1" + } + }, + { + "id": "e529", + "type": "SOLD", + "ts": 1700000529000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "CeeDee Flowers WR54", + "position": "WR", + "nflTeam": "NYJ", + "teamId": "team-8", + "teamName": "Team 8", + "price": 1 + } + }, + { + "id": "e530", + "type": "NOMINATION", + "ts": 1700000530000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Malik Rice WR55", + "position": "WR", + "nflTeam": "NYJ", + "openingBid": 1, + "nominatingTeamId": "team-1" + } + }, + { + "id": "e531", + "type": "SOLD", + "ts": 1700000531000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Malik Rice WR55", + "position": "WR", + "nflTeam": "NYJ", + "teamId": "team-1", + "teamName": "Team 1", + "price": 1 + } + }, + { + "id": "e532", + "type": "NOMINATION", + "ts": 1700000532000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Dalton Addison WR56", + "position": "WR", + "nflTeam": "BAL", + "openingBid": 1, + "nominatingTeamId": "team-12" + } + }, + { + "id": "e533", + "type": "SOLD", + "ts": 1700000533000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Dalton Addison WR56", + "position": "WR", + "nflTeam": "BAL", + "teamId": "team-9", + "teamName": "Team 9", + "price": 1 + } + }, + { + "id": "e534", + "type": "NOMINATION", + "ts": 1700000534000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Jordan Kupp WR59", + "position": "WR", + "nflTeam": "DET", + "openingBid": 1, + "nominatingTeamId": "team-4" + } + }, + { + "id": "e535", + "type": "SOLD", + "ts": 1700000535000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Jordan Kupp WR59", + "position": "WR", + "nflTeam": "DET", + "teamId": "team-8", + "teamName": "Team 8", + "price": 1 + } + }, + { + "id": "e536", + "type": "NOMINATION", + "ts": 1700000536000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Tyrone Dell WR58", + "position": "WR", + "nflTeam": "CIN", + "openingBid": 1, + "nominatingTeamId": "team-7" + } + }, + { + "id": "e537", + "type": "SOLD", + "ts": 1700000537000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Tyrone Dell WR58", + "position": "WR", + "nflTeam": "CIN", + "teamId": "team-9", + "teamName": "Team 9", + "price": 1 + } + }, + { + "id": "e538", + "type": "NOMINATION", + "ts": 1700000538000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Sam Shakir RB52", + "position": "RB", + "nflTeam": "CHI", + "openingBid": 1, + "nominatingTeamId": "team-12" + } + }, + { + "id": "e539", + "type": "SOLD", + "ts": 1700000539000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Sam Shakir RB52", + "position": "RB", + "nflTeam": "CHI", + "teamId": "team-3", + "teamName": "Team 3", + "price": 1 + } + }, + { + "id": "e540", + "type": "NOMINATION", + "ts": 1700000540000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Bijan Addison TE15", + "position": "TE", + "nflTeam": "ATL", + "openingBid": 1, + "nominatingTeamId": "team-7" + } + }, + { + "id": "e541", + "type": "SOLD", + "ts": 1700000541000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Bijan Addison TE15", + "position": "TE", + "nflTeam": "ATL", + "teamId": "team-1", + "teamName": "Team 1", + "price": 1 + } + }, + { + "id": "e542", + "type": "NOMINATION", + "ts": 1700000542000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Micah Collins TE16", + "position": "TE", + "nflTeam": "MIN", + "openingBid": 1, + "nominatingTeamId": "team-6" + } + }, + { + "id": "e543", + "type": "SOLD", + "ts": 1700000543000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Micah Collins TE16", + "position": "TE", + "nflTeam": "MIN", + "teamId": "team-1", + "teamName": "Team 1", + "price": 1 + } + }, + { + "id": "e544", + "type": "NOMINATION", + "ts": 1700000544000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Tyrone Pacheco QB16", + "position": "QB", + "nflTeam": "BAL", + "openingBid": 1, + "nominatingTeamId": "team-1" + } + }, + { + "id": "e545", + "type": "SOLD", + "ts": 1700000545000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Tyrone Pacheco QB16", + "position": "QB", + "nflTeam": "BAL", + "teamId": "team-6", + "teamName": "Team 6", + "price": 1 + } + }, + { + "id": "e546", + "type": "NOMINATION", + "ts": 1700000546000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Khalil Jefferson QB17", + "position": "QB", + "nflTeam": "BUF", + "openingBid": 1, + "nominatingTeamId": "team-8" + } + }, + { + "id": "e547", + "type": "SOLD", + "ts": 1700000547000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Khalil Jefferson QB17", + "position": "QB", + "nflTeam": "BUF", + "teamId": "team-8", + "teamName": "Team 8", + "price": 1 + } + }, + { + "id": "e548", + "type": "NOMINATION", + "ts": 1700000548000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Chris London QB20", + "position": "QB", + "nflTeam": "NO", + "openingBid": 1, + "nominatingTeamId": "team-3" + } + }, + { + "id": "e549", + "type": "SOLD", + "ts": 1700000549000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Chris London QB20", + "position": "QB", + "nflTeam": "NO", + "teamId": "team-3", + "teamName": "Team 3", + "price": 1 + } + }, + { + "id": "e550", + "type": "NOMINATION", + "ts": 1700000550000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Cooper Johnston RB53", + "position": "RB", + "nflTeam": "CIN", + "openingBid": 1, + "nominatingTeamId": "team-7" + } + }, + { + "id": "e551", + "type": "SOLD", + "ts": 1700000551000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Cooper Johnston RB53", + "position": "RB", + "nflTeam": "CIN", + "teamId": "team-1", + "teamName": "Team 1", + "price": 1 + } + }, + { + "id": "e552", + "type": "NOMINATION", + "ts": 1700000552000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Kyren Samuel RB55", + "position": "RB", + "nflTeam": "DAL", + "openingBid": 1, + "nominatingTeamId": "team-8" + } + }, + { + "id": "e553", + "type": "SOLD", + "ts": 1700000553000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Kyren Samuel RB55", + "position": "RB", + "nflTeam": "DAL", + "teamId": "team-8", + "teamName": "Team 8", + "price": 1 + } + }, + { + "id": "e554", + "type": "NOMINATION", + "ts": 1700000554000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Isiah Jefferson WR60", + "position": "WR", + "nflTeam": "NO", + "openingBid": 1, + "nominatingTeamId": "team-12" + } + }, + { + "id": "e555", + "type": "SOLD", + "ts": 1700000555000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Isiah Jefferson WR60", + "position": "WR", + "nflTeam": "NO", + "teamId": "team-6", + "teamName": "Team 6", + "price": 1 + } + }, + { + "id": "e556", + "type": "NOMINATION", + "ts": 1700000556000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Micah Parsons WR61", + "position": "WR", + "nflTeam": "DET", + "openingBid": 1, + "nominatingTeamId": "team-6" + } + }, + { + "id": "e557", + "type": "SOLD", + "ts": 1700000557000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Micah Parsons WR61", + "position": "WR", + "nflTeam": "DET", + "teamId": "team-6", + "teamName": "Team 6", + "price": 1 + } + }, + { + "id": "e558", + "type": "NOMINATION", + "ts": 1700000558000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Rome Flowers WR62", + "position": "WR", + "nflTeam": "DET", + "openingBid": 1, + "nominatingTeamId": "team-12" + } + }, + { + "id": "e559", + "type": "SOLD", + "ts": 1700000559000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Rome Flowers WR62", + "position": "WR", + "nflTeam": "DET", + "teamId": "team-12", + "teamName": "Team 12", + "price": 1 + } + }, + { + "id": "e560", + "type": "NOMINATION", + "ts": 1700000560000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Amon-Ra Kupp RB56", + "position": "RB", + "nflTeam": "ARI", + "openingBid": 1, + "nominatingTeamId": "team-9" + } + }, + { + "id": "e561", + "type": "SOLD", + "ts": 1700000561000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Amon-Ra Kupp RB56", + "position": "RB", + "nflTeam": "ARI", + "teamId": "team-9", + "teamName": "Team 9", + "price": 1 + } + }, + { + "id": "e562", + "type": "NOMINATION", + "ts": 1700000562000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Jahmyr Rice WR63", + "position": "WR", + "nflTeam": "MIN", + "openingBid": 1, + "nominatingTeamId": "team-7" + } + }, + { + "id": "e563", + "type": "SOLD", + "ts": 1700000563000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Jahmyr Rice WR63", + "position": "WR", + "nflTeam": "MIN", + "teamId": "team-4", + "teamName": "Team 4", + "price": 1 + } + }, + { + "id": "e564", + "type": "NOMINATION", + "ts": 1700000564000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Tyrone Robinson TE19", + "position": "TE", + "nflTeam": "CIN", + "openingBid": 1, + "nominatingTeamId": "team-4" + } + }, + { + "id": "e565", + "type": "SOLD", + "ts": 1700000565000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Tyrone Robinson TE19", + "position": "TE", + "nflTeam": "CIN", + "teamId": "team-12", + "teamName": "Team 12", + "price": 1 + } + }, + { + "id": "e566", + "type": "NOMINATION", + "ts": 1700000566000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Jahmyr Nabers WR64", + "position": "WR", + "nflTeam": "LAR", + "openingBid": 1, + "nominatingTeamId": "team-12" + } + }, + { + "id": "e567", + "type": "SOLD", + "ts": 1700000567000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Jahmyr Nabers WR64", + "position": "WR", + "nflTeam": "LAR", + "teamId": "team-4", + "teamName": "Team 4", + "price": 1 + } + }, + { + "id": "e568", + "type": "NOMINATION", + "ts": 1700000568000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Bijan Williams TE17", + "position": "TE", + "nflTeam": "ARI", + "openingBid": 1, + "nominatingTeamId": "team-7" + } + }, + { + "id": "e569", + "type": "SOLD", + "ts": 1700000569000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Bijan Williams TE17", + "position": "TE", + "nflTeam": "ARI", + "teamId": "team-6", + "teamName": "Team 6", + "price": 1 + } + }, + { + "id": "e570", + "type": "NOMINATION", + "ts": 1700000570000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Nico Odunze TE20", + "position": "TE", + "nflTeam": "MIN", + "openingBid": 1, + "nominatingTeamId": "team-8" + } + }, + { + "id": "e571", + "type": "SOLD", + "ts": 1700000571000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Nico Odunze TE20", + "position": "TE", + "nflTeam": "MIN", + "teamId": "team-4", + "teamName": "Team 4", + "price": 1 + } + }, + { + "id": "e572", + "type": "NOMINATION", + "ts": 1700000572000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Amon-Ra Collins RB60", + "position": "RB", + "nflTeam": "ARI", + "openingBid": 1, + "nominatingTeamId": "team-4" + } + }, + { + "id": "e573", + "type": "SOLD", + "ts": 1700000573000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Amon-Ra Collins RB60", + "position": "RB", + "nflTeam": "ARI", + "teamId": "team-12", + "teamName": "Team 12", + "price": 1 + } + }, + { + "id": "e574", + "type": "NOMINATION", + "ts": 1700000574000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Trey Hall DST1", + "position": "DST", + "nflTeam": "ATL", + "openingBid": 1, + "nominatingTeamId": "team-4" + } + }, + { + "id": "e575", + "type": "BID", + "ts": 1700000575000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 1, + "teamId": "team-7" + } + }, + { + "id": "e576", + "type": "SOLD", + "ts": 1700000576000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Trey Hall DST1", + "position": "DST", + "nflTeam": "ATL", + "teamId": "team-11", + "teamName": "Team 11", + "price": 2 + } + }, + { + "id": "e577", + "type": "NOMINATION", + "ts": 1700000577000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Dalton Tracy QB19", + "position": "QB", + "nflTeam": "DET", + "openingBid": 1, + "nominatingTeamId": "team-4" + } + }, + { + "id": "e578", + "type": "SOLD", + "ts": 1700000578000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Dalton Tracy QB19", + "position": "QB", + "nflTeam": "DET", + "teamId": "team-12", + "teamName": "Team 12", + "price": 1 + } + }, + { + "id": "e579", + "type": "NOMINATION", + "ts": 1700000579000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Puka Samuel RB54", + "position": "RB", + "nflTeam": "BAL", + "openingBid": 1, + "nominatingTeamId": "team-7" + } + }, + { + "id": "e580", + "type": "SOLD", + "ts": 1700000580000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Puka Samuel RB54", + "position": "RB", + "nflTeam": "BAL", + "teamId": "team-4", + "teamName": "Team 4", + "price": 1 + } + }, + { + "id": "e581", + "type": "NOMINATION", + "ts": 1700000581000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Kyren Rice RB57", + "position": "RB", + "nflTeam": "DAL", + "openingBid": 1, + "nominatingTeamId": "team-7" + } + }, + { + "id": "e582", + "type": "SOLD", + "ts": 1700000582000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Kyren Rice RB57", + "position": "RB", + "nflTeam": "DAL", + "teamId": "team-7", + "teamName": "Team 7", + "price": 1 + } + }, + { + "id": "e583", + "type": "NOMINATION", + "ts": 1700000583000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Brock Samuel RB58", + "position": "RB", + "nflTeam": "NYG", + "openingBid": 1, + "nominatingTeamId": "team-12" + } + }, + { + "id": "e584", + "type": "SOLD", + "ts": 1700000584000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Brock Samuel RB58", + "position": "RB", + "nflTeam": "NYG", + "teamId": "team-12", + "teamName": "Team 12", + "price": 1 + } + }, + { + "id": "e585", + "type": "NOMINATION", + "ts": 1700000585000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Rashee McBride RB59", + "position": "RB", + "nflTeam": "KC", + "openingBid": 1, + "nominatingTeamId": "team-7" + } + }, + { + "id": "e586", + "type": "SOLD", + "ts": 1700000586000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Rashee McBride RB59", + "position": "RB", + "nflTeam": "KC", + "teamId": "team-7", + "teamName": "Team 7", + "price": 1 + } + }, + { + "id": "e587", + "type": "NOMINATION", + "ts": 1700000587000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Bijan Olave WR65", + "position": "WR", + "nflTeam": "CHI", + "openingBid": 1, + "nominatingTeamId": "team-7" + } + }, + { + "id": "e588", + "type": "SOLD", + "ts": 1700000588000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Bijan Olave WR65", + "position": "WR", + "nflTeam": "CHI", + "teamId": "team-7", + "teamName": "Team 7", + "price": 1 + } + }, + { + "id": "e589", + "type": "NOMINATION", + "ts": 1700000589000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Jordan Smith-Njigba DST2", + "position": "DST", + "nflTeam": "CIN", + "openingBid": 1, + "nominatingTeamId": "team-2" + } + }, + { + "id": "e590", + "type": "BID", + "ts": 1700000590000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 1, + "teamId": "team-5" + } + }, + { + "id": "e591", + "type": "SOLD", + "ts": 1700000591000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Jordan Smith-Njigba DST2", + "position": "DST", + "nflTeam": "CIN", + "teamId": "team-5", + "teamName": "Team 5", + "price": 2 + } + }, + { + "id": "e592", + "type": "NOMINATION", + "ts": 1700000592000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Chris Flowers K3", + "position": "K", + "nflTeam": "ATL", + "openingBid": 1, + "nominatingTeamId": "team-11" + } + }, + { + "id": "e593", + "type": "SOLD", + "ts": 1700000593000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Chris Flowers K3", + "position": "K", + "nflTeam": "ATL", + "teamId": "team-3", + "teamName": "Team 3", + "price": 1 + } + }, + { + "id": "e594", + "type": "NOMINATION", + "ts": 1700000594000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Sam Nacua TE18", + "position": "TE", + "nflTeam": "SF", + "openingBid": 1, + "nominatingTeamId": "team-3" + } + }, + { + "id": "e595", + "type": "SOLD", + "ts": 1700000595000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Sam Nacua TE18", + "position": "TE", + "nflTeam": "SF", + "teamId": "team-3", + "teamName": "Team 3", + "price": 1 + } + }, + { + "id": "e596", + "type": "NOMINATION", + "ts": 1700000596000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Garrett Hall TE21", + "position": "TE", + "nflTeam": "LV", + "openingBid": 1, + "nominatingTeamId": "team-8" + } + }, + { + "id": "e597", + "type": "SOLD", + "ts": 1700000597000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Garrett Hall TE21", + "position": "TE", + "nflTeam": "LV", + "teamId": "team-8", + "teamName": "Team 8", + "price": 1 + } + }, + { + "id": "e598", + "type": "NOMINATION", + "ts": 1700000598000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Tyrone Brown K1", + "position": "K", + "nflTeam": "BUF", + "openingBid": 1, + "nominatingTeamId": "team-9" + } + }, + { + "id": "e599", + "type": "BID", + "ts": 1700000599000, + "source": "ws", + "confidence": 1, + "payload": { + "amount": 1, + "teamId": "team-4" + } + }, + { + "id": "e600", + "type": "SOLD", + "ts": 1700000600000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Tyrone Brown K1", + "position": "K", + "nflTeam": "BUF", + "teamId": "team-11", + "teamName": "Team 11", + "price": 2 + } + }, + { + "id": "e601", + "type": "NOMINATION", + "ts": 1700000601000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Jaxon Lamb K2", + "position": "K", + "nflTeam": "DET", + "openingBid": 1, + "nominatingTeamId": "team-2" + } + }, + { + "id": "e602", + "type": "SOLD", + "ts": 1700000602000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Jaxon Lamb K2", + "position": "K", + "nflTeam": "DET", + "teamId": "team-5", + "teamName": "Team 5", + "price": 1 + } + }, + { + "id": "e603", + "type": "NOMINATION", + "ts": 1700000603000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Micah Olave DST3", + "position": "DST", + "nflTeam": "LV", + "openingBid": 1, + "nominatingTeamId": "team-4" + } + }, + { + "id": "e604", + "type": "SOLD", + "ts": 1700000604000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Micah Olave DST3", + "position": "DST", + "nflTeam": "LV", + "teamId": "team-8", + "teamName": "Team 8", + "price": 1 + } + }, + { + "id": "e605", + "type": "NOMINATION", + "ts": 1700000605000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Bijan Odunze QB21", + "position": "QB", + "nflTeam": "KC", + "openingBid": 1, + "nominatingTeamId": "team-1" + } + }, + { + "id": "e606", + "type": "SOLD", + "ts": 1700000606000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Bijan Odunze QB21", + "position": "QB", + "nflTeam": "KC", + "teamId": "team-1", + "teamName": "Team 1", + "price": 1 + } + }, + { + "id": "e607", + "type": "NOMINATION", + "ts": 1700000607000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Malik Rice QB22", + "position": "QB", + "nflTeam": "SF", + "openingBid": 1, + "nominatingTeamId": "team-7" + } + }, + { + "id": "e608", + "type": "SOLD", + "ts": 1700000608000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Malik Rice QB22", + "position": "QB", + "nflTeam": "SF", + "teamId": "team-7", + "teamName": "Team 7", + "price": 1 + } + }, + { + "id": "e609", + "type": "NOMINATION", + "ts": 1700000609000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Tank Jefferson K4", + "position": "K", + "nflTeam": "LAR", + "openingBid": 1, + "nominatingTeamId": "team-4" + } + }, + { + "id": "e610", + "type": "SOLD", + "ts": 1700000610000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Tank Jefferson K4", + "position": "K", + "nflTeam": "LAR", + "teamId": "team-12", + "teamName": "Team 12", + "price": 1 + } + }, + { + "id": "e611", + "type": "NOMINATION", + "ts": 1700000611000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Nico Addison K5", + "position": "K", + "nflTeam": "KC", + "openingBid": 1, + "nominatingTeamId": "team-1" + } + }, + { + "id": "e612", + "type": "SOLD", + "ts": 1700000612000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Nico Addison K5", + "position": "K", + "nflTeam": "KC", + "teamId": "team-9", + "teamName": "Team 9", + "price": 1 + } + }, + { + "id": "e613", + "type": "NOMINATION", + "ts": 1700000613000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Micah Samuel K6", + "position": "K", + "nflTeam": "CIN", + "openingBid": 1, + "nominatingTeamId": "team-8" + } + }, + { + "id": "e614", + "type": "SOLD", + "ts": 1700000614000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Micah Samuel K6", + "position": "K", + "nflTeam": "CIN", + "teamId": "team-1", + "teamName": "Team 1", + "price": 1 + } + }, + { + "id": "e615", + "type": "NOMINATION", + "ts": 1700000615000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Zay Pacheco K8", + "position": "K", + "nflTeam": "NYJ", + "openingBid": 1, + "nominatingTeamId": "team-4" + } + }, + { + "id": "e616", + "type": "SOLD", + "ts": 1700000616000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Zay Pacheco K8", + "position": "K", + "nflTeam": "NYJ", + "teamId": "team-2", + "teamName": "Team 2", + "price": 1 + } + }, + { + "id": "e617", + "type": "NOMINATION", + "ts": 1700000617000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Tyrone Odunze K7", + "position": "K", + "nflTeam": "SF", + "openingBid": 1, + "nominatingTeamId": "team-6" + } + }, + { + "id": "e618", + "type": "SOLD", + "ts": 1700000618000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Tyrone Odunze K7", + "position": "K", + "nflTeam": "SF", + "teamId": "team-4", + "teamName": "Team 4", + "price": 1 + } + }, + { + "id": "e619", + "type": "NOMINATION", + "ts": 1700000619000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Kyren Robinson DST14", + "position": "DST", + "nflTeam": "PHI", + "openingBid": 1, + "nominatingTeamId": "team-10" + } + }, + { + "id": "e620", + "type": "SOLD", + "ts": 1700000620000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Kyren Robinson DST14", + "position": "DST", + "nflTeam": "PHI", + "teamId": "team-7", + "teamName": "Team 7", + "price": 1 + } + }, + { + "id": "e621", + "type": "NOMINATION", + "ts": 1700000621000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Zay Corum DST10", + "position": "DST", + "nflTeam": "MIN", + "openingBid": 1, + "nominatingTeamId": "team-2" + } + }, + { + "id": "e622", + "type": "SOLD", + "ts": 1700000622000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Zay Corum DST10", + "position": "DST", + "nflTeam": "MIN", + "teamId": "team-2", + "teamName": "Team 2", + "price": 1 + } + }, + { + "id": "e623", + "type": "NOMINATION", + "ts": 1700000623000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Amon-Ra Odunze K9", + "position": "K", + "nflTeam": "CHI", + "openingBid": 1, + "nominatingTeamId": "team-10" + } + }, + { + "id": "e624", + "type": "SOLD", + "ts": 1700000624000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Amon-Ra Odunze K9", + "position": "K", + "nflTeam": "CHI", + "teamId": "team-10", + "teamName": "Team 10", + "price": 1 + } + }, + { + "id": "e625", + "type": "NOMINATION", + "ts": 1700000625000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Zay Hall K10", + "position": "K", + "nflTeam": "CIN", + "openingBid": 1, + "nominatingTeamId": "team-7" + } + }, + { + "id": "e626", + "type": "SOLD", + "ts": 1700000626000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Zay Hall K10", + "position": "K", + "nflTeam": "CIN", + "teamId": "team-6", + "teamName": "Team 6", + "price": 1 + } + }, + { + "id": "e627", + "type": "NOMINATION", + "ts": 1700000627000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Brock LaPorta K11", + "position": "K", + "nflTeam": "TB", + "openingBid": 1, + "nominatingTeamId": "team-7" + } + }, + { + "id": "e628", + "type": "SOLD", + "ts": 1700000628000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Brock LaPorta K11", + "position": "K", + "nflTeam": "TB", + "teamId": "team-7", + "teamName": "Team 7", + "price": 1 + } + }, + { + "id": "e629", + "type": "NOMINATION", + "ts": 1700000629000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Cooper Harrison K12", + "position": "K", + "nflTeam": "BUF", + "openingBid": 1, + "nominatingTeamId": "team-8" + } + }, + { + "id": "e630", + "type": "SOLD", + "ts": 1700000630000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Cooper Harrison K12", + "position": "K", + "nflTeam": "BUF", + "teamId": "team-8", + "teamName": "Team 8", + "price": 1 + } + }, + { + "id": "e631", + "type": "NOMINATION", + "ts": 1700000631000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Chris Brown DST13", + "position": "DST", + "nflTeam": "CHI", + "openingBid": 1, + "nominatingTeamId": "team-12" + } + }, + { + "id": "e632", + "type": "SOLD", + "ts": 1700000632000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Chris Brown DST13", + "position": "DST", + "nflTeam": "CHI", + "teamId": "team-1", + "teamName": "Team 1", + "price": 1 + } + }, + { + "id": "e633", + "type": "NOMINATION", + "ts": 1700000633000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Jahmyr Addison DST4", + "position": "DST", + "nflTeam": "BUF", + "openingBid": 1, + "nominatingTeamId": "team-10" + } + }, + { + "id": "e634", + "type": "SOLD", + "ts": 1700000634000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Jahmyr Addison DST4", + "position": "DST", + "nflTeam": "BUF", + "teamId": "team-4", + "teamName": "Team 4", + "price": 1 + } + }, + { + "id": "e635", + "type": "NOMINATION", + "ts": 1700000635000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Tank Flowers DST5", + "position": "DST", + "nflTeam": "ATL", + "openingBid": 1, + "nominatingTeamId": "team-6" + } + }, + { + "id": "e636", + "type": "SOLD", + "ts": 1700000636000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Tank Flowers DST5", + "position": "DST", + "nflTeam": "ATL", + "teamId": "team-10", + "teamName": "Team 10", + "price": 1 + } + }, + { + "id": "e637", + "type": "NOMINATION", + "ts": 1700000637000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Justin Smith-Njigba DST6", + "position": "DST", + "nflTeam": "SEA", + "openingBid": 1, + "nominatingTeamId": "team-6" + } + }, + { + "id": "e638", + "type": "SOLD", + "ts": 1700000638000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Justin Smith-Njigba DST6", + "position": "DST", + "nflTeam": "SEA", + "teamId": "team-6", + "teamName": "Team 6", + "price": 1 + } + }, + { + "id": "e639", + "type": "NOMINATION", + "ts": 1700000639000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Jordan Lamb DST9", + "position": "DST", + "nflTeam": "TB", + "openingBid": 1, + "nominatingTeamId": "team-3" + } + }, + { + "id": "e640", + "type": "SOLD", + "ts": 1700000640000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Jordan Lamb DST9", + "position": "DST", + "nflTeam": "TB", + "teamId": "team-12", + "teamName": "Team 12", + "price": 1 + } + }, + { + "id": "e641", + "type": "NOMINATION", + "ts": 1700000641000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Garrett Corum DST7", + "position": "DST", + "nflTeam": "SEA", + "openingBid": 1, + "nominatingTeamId": "team-3" + } + }, + { + "id": "e642", + "type": "SOLD", + "ts": 1700000642000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Garrett Corum DST7", + "position": "DST", + "nflTeam": "SEA", + "teamId": "team-3", + "teamName": "Team 3", + "price": 1 + } + }, + { + "id": "e643", + "type": "NOMINATION", + "ts": 1700000643000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Rome Kupp DST8", + "position": "DST", + "nflTeam": "TB", + "openingBid": 1, + "nominatingTeamId": "team-9" + } + }, + { + "id": "e644", + "type": "SOLD", + "ts": 1700000644000, + "source": "ws", + "confidence": 1, + "payload": { + "playerName": "Rome Kupp DST8", + "position": "DST", + "nflTeam": "TB", + "teamId": "team-9", + "teamName": "Team 9", + "price": 1 + } + }, + { + "id": "e645", + "type": "DRAFT_COMPLETE", + "ts": 1700000645000, + "source": "ws", + "confidence": 1, + "payload": {} + } + ] +} \ No newline at end of file diff --git a/fantasy-auction-tracker/fixtures/sample-targets.json b/fantasy-auction-tracker/fixtures/sample-targets.json new file mode 100644 index 0000000..1ee24fc --- /dev/null +++ b/fantasy-auction-tracker/fixtures/sample-targets.json @@ -0,0 +1,110 @@ +[ + { + "name": "Jordan Hall WR7", + "position": "WR", + "tier": 1, + "maxPrice": 43 + }, + { + "name": "Zay Parsons RB7", + "position": "RB", + "tier": 1, + "maxPrice": 40 + }, + { + "name": "Rome Collins RB8", + "position": "RB", + "tier": 1, + "maxPrice": 38 + }, + { + "name": "Garrett Corum WR9", + "position": "WR", + "tier": 1, + "maxPrice": 34 + }, + { + "name": "Garrett Johnston RB10", + "position": "RB", + "tier": 1, + "maxPrice": 32 + }, + { + "name": "Blake Shakir WR12", + "position": "WR", + "tier": 1, + "maxPrice": 31 + }, + { + "name": "Bijan Kincaid WR11", + "position": "WR", + "tier": 2, + "maxPrice": 30 + }, + { + "name": "Sam Kupp QB1", + "position": "QB", + "tier": 2, + "maxPrice": 29 + }, + { + "name": "Marvin Odunze WR14", + "position": "WR", + "tier": 2, + "maxPrice": 28 + }, + { + "name": "Rome London RB15", + "position": "RB", + "tier": 2, + "maxPrice": 25 + }, + { + "name": "Justin Samuel WR17", + "position": "WR", + "tier": 2, + "maxPrice": 24 + }, + { + "name": "Blake Nacua QB2", + "position": "QB", + "tier": 2, + "maxPrice": 24 + }, + { + "name": "Marvin Bowers WR15", + "position": "WR", + "tier": 3, + "maxPrice": 24 + }, + { + "name": "Justin Nacua WR16", + "position": "WR", + "tier": 3, + "maxPrice": 23 + }, + { + "name": "Khalil Harrison WR19", + "position": "WR", + "tier": 3, + "maxPrice": 21 + }, + { + "name": "Marvin Wilson RB18", + "position": "RB", + "tier": 3, + "maxPrice": 20 + }, + { + "name": "Chris Rice QB3", + "position": "QB", + "tier": 3, + "maxPrice": 19 + }, + { + "name": "Quentin Corum TE4", + "position": "TE", + "tier": 3, + "maxPrice": 19 + } +] \ No newline at end of file diff --git a/fantasy-auction-tracker/fixtures/sample-values.csv b/fantasy-auction-tracker/fixtures/sample-values.csv new file mode 100644 index 0000000..14223f9 --- /dev/null +++ b/fantasy-auction-tracker/fixtures/sample-values.csv @@ -0,0 +1,209 @@ +Rank,Player,Team,Position,Auction Value +1,Nico Parsons RB1,SF,RB,$54.2 +2,Amon-Ra Wilson WR1,PHI,WR,$52.4 +3,Kyren Robinson WR2,PHI,WR,$52.4 +4,Deebo Pacheco RB2,BUF,RB,$49.8 +5,Breece Odunze RB3,CIN,RB,$48.9 +6,Blake Rice WR4,TB,WR,$47.2 +7,Micah McBride WR3,NO,WR,$46.3 +8,Cooper Hall RB4,NO,RB,$44.6 +9,Chris Gibbs WR5,SF,WR,$44.6 +10,Jordan Shakir RB6,DAL,RB,$43.7 +11,Jahmyr Parsons WR6,LV,WR,$43.7 +12,Jordan Hall WR7,TB,WR,$42.8 +13,Quentin Rice RB5,DAL,RB,$41.1 +14,Zay Parsons RB7,CIN,RB,$40.2 +15,Tank Gibbs WR8,BAL,WR,$40.2 +16,Rome Collins RB8,NO,RB,$37.6 +17,Zay Hall WR10,ARI,WR,$35.9 +18,Garrett Corum WR9,TB,WR,$34.1 +19,Trey LaPorta RB9,PHI,RB,$32.4 +20,Garrett Johnston RB10,NYG,RB,$32.4 +21,Cooper Dell RB11,SEA,RB,$30.6 +22,Blake Shakir WR12,TB,WR,$30.6 +23,Deebo Tracy WR13,LAR,WR,$30.6 +24,Bijan Kincaid WR11,BAL,WR,$29.8 +25,Kyren Corum TE1,BUF,TE,$29.8 +26,Sam Kupp QB1,ARI,QB,$28.9 +27,Jaxon Parsons RB12,PHI,RB,$28.9 +28,Marvin Odunze WR14,NYG,WR,$28 +29,CeeDee Brown RB13,LAR,RB,$25.4 +30,Rome London RB15,LV,RB,$25.4 +31,Deebo Bowers RB16,TB,RB,$24.5 +32,Justin Samuel WR17,NO,WR,$24.5 +33,Kyren Rice TE2,BUF,TE,$24.5 +34,Blake Nacua QB2,LAR,QB,$23.7 +35,Breece Nabers RB14,KC,RB,$23.7 +36,Marvin Bowers WR15,ATL,WR,$23.7 +37,Trey Lamb WR18,PHI,WR,$23.7 +38,Justin Nacua WR16,SEA,WR,$22.8 +39,Chris Nacua TE3,HOU,TE,$21.9 +40,Khalil Harrison WR19,DAL,WR,$21 +41,Drake Kincaid RB17,CHI,RB,$20.2 +42,Marvin Wilson RB18,CHI,RB,$20.2 +43,Blake Samuel WR21,DET,WR,$20.2 +44,Chris Rice QB3,NYG,QB,$19.3 +45,Tyrone Corum WR23,ATL,WR,$19.3 +46,Quentin Corum TE4,CHI,TE,$19.3 +47,Rome Tracy WR20,CHI,WR,$18.4 +48,Jordan McBride WR22,HOU,WR,$17.6 +49,Chris Jefferson QB4,HOU,QB,$16.7 +50,Malik Kupp RB19,DET,RB,$16.7 +51,Bijan Nacua WR24,PHI,WR,$16.7 +52,Khalil McBride WR25,ATL,WR,$16.7 +53,Marvin Johnston WR26,CIN,WR,$16.7 +54,Amon-Ra Collins QB5,DAL,QB,$15.8 +55,Deebo McBride RB20,ARI,RB,$15.8 +56,Tyrone Pacheco TE5,SF,TE,$15.8 +57,Micah Kupp RB21,LAR,RB,$14.9 +58,Kyren Shakir RB22,SF,RB,$14.9 +59,Zay Brown WR28,CHI,WR,$14.9 +60,Trey Dell RB23,ARI,RB,$14.1 +61,Bijan Nacua RB24,ARI,RB,$14.1 +62,Jaxon Samuel WR27,NO,WR,$13.2 +63,Chris Bowers TE6,DET,TE,$13.2 +64,Puka Odunze QB6,BAL,QB,$12.3 +65,Justin Nacua QB7,PHI,QB,$12.3 +66,Micah Hall RB25,NYG,RB,$12.3 +67,Marvin Kincaid WR29,NYJ,WR,$12.3 +68,Malik Odunze WR30,CHI,WR,$12.3 +69,Rome Corum WR31,DET,WR,$12.3 +70,Jahmyr Tracy RB26,BAL,RB,$11.5 +71,Nico McBride RB27,SEA,RB,$11.5 +72,Rashee Samuel WR32,KC,WR,$11.5 +73,Rashee Gibbs WR33,NO,WR,$11.5 +74,Zay London TE7,ATL,TE,$11.5 +75,Breece Johnston WR34,PHI,WR,$10.6 +76,Deebo Smith-Njigba QB8,SEA,QB,$9.7 +77,Micah Nacua RB28,PHI,RB,$9.7 +78,Isiah Shakir RB29,NYG,RB,$9.7 +79,Tyrone McBride RB31,BAL,RB,$9.7 +80,Amon-Ra London WR35,CIN,WR,$9.7 +81,Trey Johnston WR36,SF,WR,$9.7 +82,Brock Hall TE8,LAR,TE,$9.7 +83,Cooper Pacheco RB30,KC,RB,$8.8 +84,Micah Hall RB32,NYJ,RB,$8.8 +85,Zay Parsons WR37,LAR,WR,$8.8 +86,Khalil Nacua QB9,DET,QB,$8 +87,Justin Lamb RB33,DET,RB,$8 +88,Marvin London RB34,BUF,RB,$8 +89,Rome LaPorta WR38,DAL,WR,$8 +90,Puka Gibbs WR39,ATL,WR,$8 +91,Amon-Ra Wilson WR40,DAL,WR,$8 +92,Trey Pacheco TE9,PHI,TE,$8 +93,Amon-Ra Dell TE10,MIN,TE,$8 +94,Khalil Kincaid QB10,PHI,QB,$7.1 +95,Cooper Johnston RB35,DAL,RB,$7.1 +96,Brock Lamb RB37,NYJ,RB,$7.1 +97,Brock Dell WR41,MIN,WR,$7.1 +98,Micah Nabers TE11,CIN,TE,$7.1 +99,Jordan Samuel QB11,LAR,QB,$6.2 +100,Jordan Hall RB36,CIN,RB,$6.2 +101,Tyrone Flowers RB38,ATL,RB,$6.2 +102,Bijan Jefferson RB39,LV,RB,$6.2 +103,Micah Williams RB40,CIN,RB,$6.2 +104,Puka Shakir WR42,SF,WR,$6.2 +105,Rome Brown WR43,SEA,WR,$6.2 +106,Drake Tracy WR44,LAR,WR,$6.2 +107,Sam McBride WR45,DET,WR,$6.2 +108,Nico Johnston TE12,CIN,TE,$6.2 +109,Bijan Kincaid QB12,NO,QB,$5.4 +110,Tank Harrison RB41,SEA,RB,$5.4 +111,Tank Parsons RB42,NO,RB,$5.4 +112,Garrett Harrison WR46,KC,WR,$5.4 +113,Tyrone Samuel WR47,CHI,WR,$5.4 +114,CeeDee Jefferson WR48,ARI,WR,$5.4 +115,Tank Tracy WR49,ARI,WR,$5.4 +116,Breece Nacua TE13,DAL,TE,$5.4 +117,Khalil Collins QB13,KC,QB,$4.5 +118,Nico Hall RB43,NO,RB,$4.5 +119,Garrett Pacheco RB44,NO,RB,$4.5 +120,Malik Odunze WR50,KC,WR,$4.5 +121,Jahmyr Johnston WR51,BUF,WR,$4.5 +122,Kyren Tracy WR52,PHI,WR,$4.5 +123,Malik Brown TE14,HOU,TE,$4.5 +124,CeeDee London QB14,HOU,QB,$3.6 +125,Rashee Robinson QB15,SEA,QB,$3.6 +126,Trey Brown RB45,NYJ,RB,$3.6 +127,Malik Parsons RB46,ARI,RB,$3.6 +128,Malik Tracy RB47,CHI,RB,$3.6 +129,Marvin Gibbs RB48,NYJ,RB,$3.6 +130,CeeDee Rice RB49,ARI,RB,$3.6 +131,Tyrone Pacheco RB50,NYJ,RB,$3.6 +132,Cooper Wilson WR53,NO,WR,$3.6 +133,CeeDee Flowers WR54,NYJ,WR,$3.6 +134,Malik Rice WR55,NYJ,WR,$3.6 +135,Dalton Addison WR56,BAL,WR,$3.6 +136,Tank Pacheco WR57,CHI,WR,$3.6 +137,Tyrone Dell WR58,CIN,WR,$3.6 +138,Bijan Addison TE15,ATL,TE,$3.6 +139,Micah Collins TE16,MIN,TE,$3.6 +140,Tyrone Pacheco QB16,BAL,QB,$2.7 +141,Khalil Jefferson QB17,BUF,QB,$2.7 +142,Trey London QB18,TB,QB,$2.7 +143,Kyren Williams RB51,DET,RB,$2.7 +144,Sam Shakir RB52,CHI,RB,$2.7 +145,Cooper Johnston RB53,CIN,RB,$2.7 +146,Kyren Samuel RB55,DAL,RB,$2.7 +147,Jordan Kupp WR59,DET,WR,$2.7 +148,Isiah Jefferson WR60,NO,WR,$2.7 +149,Micah Parsons WR61,DET,WR,$2.7 +150,Rome Flowers WR62,DET,WR,$2.7 +151,Jahmyr Rice WR63,MIN,WR,$2.7 +152,Jahmyr Nabers WR64,LAR,WR,$2.7 +153,Bijan Williams TE17,ARI,TE,$2.7 +154,Trey Hall DST1,ATL,DST,$2.7 +155,Dalton Tracy QB19,DET,QB,$1.9 +156,Chris London QB20,NO,QB,$1.9 +157,Puka Samuel RB54,BAL,RB,$1.9 +158,Amon-Ra Kupp RB56,ARI,RB,$1.9 +159,Kyren Rice RB57,DAL,RB,$1.9 +160,Brock Samuel RB58,NYG,RB,$1.9 +161,Rashee McBride RB59,KC,RB,$1.9 +162,Amon-Ra Collins RB60,ARI,RB,$1.9 +163,Bijan Olave WR65,CHI,WR,$1.9 +164,Blake Pacheco WR66,NO,WR,$1.9 +165,CeeDee London WR67,SF,WR,$1.9 +166,Quentin Dell WR68,LAR,WR,$1.9 +167,Jahmyr Lamb WR69,LAR,WR,$1.9 +168,Quentin Hall WR70,ARI,WR,$1.9 +169,Quentin Kincaid WR71,HOU,WR,$1.9 +170,Kyren Smith-Njigba WR72,LAR,WR,$1.9 +171,Sam Nacua TE18,SF,TE,$1.9 +172,Tyrone Robinson TE19,CIN,TE,$1.9 +173,Nico Odunze TE20,MIN,TE,$1.9 +174,Garrett Hall TE21,LV,TE,$1.9 +175,Tyrone Brown K1,BUF,K,$1.9 +176,Jordan Smith-Njigba DST2,CIN,DST,$1.9 +177,Micah Olave DST3,LV,DST,$1.9 +178,Bijan Odunze QB21,KC,QB,$1 +179,Malik Rice QB22,SF,QB,$1 +180,Amon-Ra Pacheco QB23,MIN,QB,$1 +181,Jaxon Gibbs QB24,CHI,QB,$1 +182,Malik Olave TE22,PHI,TE,$1 +183,Quentin LaPorta TE23,BUF,TE,$1 +184,Quentin Flowers TE24,NYJ,TE,$1 +185,Jaxon Lamb K2,DET,K,$1 +186,Chris Flowers K3,ATL,K,$1 +187,Tank Jefferson K4,LAR,K,$1 +188,Nico Addison K5,KC,K,$1 +189,Micah Samuel K6,CIN,K,$1 +190,Tyrone Odunze K7,SF,K,$1 +191,Zay Pacheco K8,NYJ,K,$1 +192,Amon-Ra Odunze K9,CHI,K,$1 +193,Zay Hall K10,CIN,K,$0 +194,Brock LaPorta K11,TB,K,$0 +195,Cooper Harrison K12,BUF,K,$0 +196,Malik Collins K13,TB,K,$0 +197,Puka Johnston K14,KC,K,$0 +198,Jahmyr Addison DST4,BUF,DST,$0 +199,Tank Flowers DST5,ATL,DST,$0 +200,Justin Smith-Njigba DST6,SEA,DST,$0 +201,Garrett Corum DST7,SEA,DST,$0 +202,Rome Kupp DST8,TB,DST,$0 +203,Jordan Lamb DST9,TB,DST,$0 +204,Zay Corum DST10,MIN,DST,$0 +205,Trey Flowers DST11,MIN,DST,$0 +206,Drake Lamb DST12,LV,DST,$0 +207,Chris Brown DST13,CHI,DST,$0 +208,Kyren Robinson DST14,PHI,DST,$0 \ No newline at end of file diff --git a/fantasy-auction-tracker/manifest.json b/fantasy-auction-tracker/manifest.json new file mode 100644 index 0000000..cdd89d4 --- /dev/null +++ b/fantasy-auction-tracker/manifest.json @@ -0,0 +1,56 @@ +{ + "manifest_version": 3, + "name": "Auction Draft Tracker", + "version": "0.1.0", + "description": "Real-time tracking and analytics for fantasy football auction drafts: prices, budgets, roster needs, inflation and bid guidance.", + + "browser_specific_settings": { + "gecko": { + "id": "auction-tracker@localhost", + "strict_min_version": "128.0" + } + }, + + "permissions": ["storage", "activeTab"], + + "host_permissions": [ + "*://*.fantasy.nfl.com/*", + "*://*.cbssports.com/*" + ], + + "background": { + "scripts": ["src/background/background.js"], + "type": "module" + }, + + "sidebar_action": { + "default_title": "Auction Tracker", + "default_panel": "src/sidebar/sidebar.html" + }, + + "content_scripts": [ + { + "matches": [ + "*://*.fantasy.nfl.com/*", + "*://*.cbssports.com/*" + ], + "js": ["src/content/content.js"], + "run_at": "document_idle", + "all_frames": false + } + ], + + "web_accessible_resources": [ + { + "resources": [ + "src/content/inject.js", + "src/adapters/*.js", + "src/core/*.js" + ], + "matches": [ + "*://*.fantasy.nfl.com/*", + "*://*.cbssports.com/*" + ] + } + ] +} diff --git a/fantasy-auction-tracker/package-lock.json b/fantasy-auction-tracker/package-lock.json new file mode 100644 index 0000000..c7c6ca2 --- /dev/null +++ b/fantasy-auction-tracker/package-lock.json @@ -0,0 +1,300 @@ +{ + "name": "fantasy-auction-tracker", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "fantasy-auction-tracker", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "linkedom": "^0.18.13" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/boolbase": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz", + "integrity": "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==", + "license": "ISC", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-select": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-7.0.0.tgz", + "integrity": "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^2.0.0", + "css-what": "^8.0.0", + "domhandler": "^6.0.1", + "domutils": "^4.0.2", + "nth-check": "^3.0.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-8.0.0.tgz", + "integrity": "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssom": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", + "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", + "license": "MIT" + }, + "node_modules/dom-serializer": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz", + "integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==", + "license": "MIT", + "dependencies": { + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0", + "entities": "^8.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz", + "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/domhandler": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz", + "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^3.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz", + "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^3.0.0", + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/html-escaper": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", + "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==", + "license": "MIT" + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/htmlparser2/node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/htmlparser2/node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/htmlparser2/node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/htmlparser2/node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/linkedom": { + "version": "0.18.13", + "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.13.tgz", + "integrity": "sha512-ES/o9qotMpzpN2MHs+Iq/JcVoOj8Fa5wiQYrTdFpvAnwXL0g66XHHUc9WUMk6nAlBtGsFQ24ne+SYnvnaQ2FSw==", + "license": "ISC", + "dependencies": { + "css-select": "^7.0.0", + "cssom": "^0.5.0", + "html-escaper": "^3.0.3", + "htmlparser2": "^10.1.0", + "uhyphen": "^0.2.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "canvas": ">= 2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/nth-check": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-3.0.1.tgz", + "integrity": "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^2.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/uhyphen": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz", + "integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==", + "license": "ISC" + } + } +} diff --git a/fantasy-auction-tracker/package.json b/fantasy-auction-tracker/package.json new file mode 100644 index 0000000..7b798a9 --- /dev/null +++ b/fantasy-auction-tracker/package.json @@ -0,0 +1,19 @@ +{ + "name": "fantasy-auction-tracker", + "version": "0.1.0", + "private": true, + "description": "Firefox add-on that tracks a fantasy football auction draft in real time and layers analytics on top.", + "type": "module", + "scripts": { + "test": "node --test test/*.test.js", + "replay": "node tools/replay.js", + "package": "cd . && zip -r -FS ../auction-tracker.zip manifest.json src -x '*.DS_Store'" + }, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "devDependencies": { + "linkedom": "^0.18.13" + } +} diff --git a/fantasy-auction-tracker/src/adapters/base.js b/fantasy-auction-tracker/src/adapters/base.js new file mode 100644 index 0000000..0e27441 --- /dev/null +++ b/fantasy-auction-tracker/src/adapters/base.js @@ -0,0 +1,102 @@ +/** + * Adapter contract. + * + * An adapter's only job is to turn "whatever this draft site does" into the + * event vocabulary in core/events.js. It must never compute analytics, and it + * must never assume it saw everything -- a missed bid is survivable, a missed + * or wrong SOLD is not, so sales carry an explicit confidence. + * + * Detection strategy, best first: + * 'ws' - intercepted WebSocket frames. Structured, exact, instant. + * 'dom' - MutationObserver over the rendered draft board. Reliable enough, + * breaks whenever the site reskins. + * 'ocr' - last resort for canvas-rendered rooms. Always low confidence. + */ + +export const Strategy = { WS: 'ws', DOM: 'dom', OCR: 'ocr' }; + +/** Confidence floors by strategy; adapters may lower but should not raise. */ +export const STRATEGY_CONFIDENCE = { + [Strategy.WS]: 1.0, + [Strategy.DOM]: 0.85, + [Strategy.OCR]: 0.55, +}; + +export class Adapter { + /** + * @param {(event: object) => void} emit - hand a built event to the pipeline + * @param {object} [options] + */ + constructor(emit, options = {}) { + this.emit = emit; + this.options = options; + this.strategy = null; + this.disposers = []; + } + + /** Human-readable id, e.g. 'nfl'. Overridden by subclasses. */ + static get id() { return 'base'; } + + /** Does this adapter handle the given location? */ + static matches(_url) { return false; } + + /** Begin observing. Subclasses override. */ + async start() { throw new Error('not implemented'); } + + stop() { + for (const dispose of this.disposers.splice(0)) { + try { dispose(); } catch { /* teardown must not throw */ } + } + } + + track(dispose) { this.disposers.push(dispose); } + + confidenceFor(strategy, penalty = 0) { + return Math.max(0, (STRATEGY_CONFIDENCE[strategy] ?? 0.5) - penalty); + } +} + +/** Pull the first integer out of a string like "$47" or "Sold for 47". */ +export function parseMoney(text) { + if (text == null) return null; + const m = /-?\d+(?:\.\d+)?/.exec(String(text).replace(/,/g, '')); + return m ? Number(m[0]) : null; +} + +/** Debounce noisy DOM callbacks; auctions fire dozens of mutations per bid. */ +export function debounce(fn, ms = 120) { + let timer = null; + const wrapped = (...args) => { + clearTimeout(timer); + timer = setTimeout(() => fn(...args), ms); + }; + wrapped.cancel = () => clearTimeout(timer); + return wrapped; +} + +/** + * Observe a subtree and call back on any change. Returns a disposer. + * Waits for the node to exist, since draft rooms mount asynchronously. + */ +export function observe(root, callback, options = {}) { + const observer = new MutationObserver(callback); + observer.observe(root, { + childList: true, subtree: true, characterData: true, ...options, + }); + return () => observer.disconnect(); +} + +/** Poll for a selector to appear, resolving with the node or null on timeout. */ +export function waitFor(selector, { timeout = 30000, interval = 250, root = document } = {}) { + return new Promise((resolve) => { + const existing = root.querySelector(selector); + if (existing) return resolve(existing); + const started = Date.now(); + const timer = setInterval(() => { + const node = root.querySelector(selector); + if (node) { clearInterval(timer); resolve(node); } + else if (Date.now() - started > timeout) { clearInterval(timer); resolve(null); } + }, interval); + return undefined; + }); +} diff --git a/fantasy-auction-tracker/src/adapters/generic-dom.js b/fantasy-auction-tracker/src/adapters/generic-dom.js new file mode 100644 index 0000000..bc4069a --- /dev/null +++ b/fantasy-auction-tracker/src/adapters/generic-dom.js @@ -0,0 +1,164 @@ +/** + * Configuration-driven DOM adapter. + * + * Rather than hand-writing a class per site, most draft rooms can be described + * by a selector map. That means retargeting to a new platform -- or repairing + * one after a site redesign -- is a data change, not a code change, which + * matters when the site can change the week before your draft. + * + * A profile looks like: + * + * { + * id: 'example', + * match: /example\.com\/draft/, + * selectors: { + * nomination: '.auction-player', // container for the live player + * nomName: '.player-name', + * nomPosition: '.player-pos', + * nomTeam: '.player-nfl-team', + * highBid: '.current-bid', + * highBidder: '.high-bidder', + * resultRow: '.draft-results tr', // one row per completed sale + * rowPlayer: '.name', + * rowPosition: '.pos', + * rowPrice: '.price', + * rowTeam: '.owner', + * teamRow: '.team-budgets .team', + * teamName: '.team-name', + * teamBudget: '.budget-left', + * }, + * } + */ + +import { EventType, makeEvent } from '../core/events.js'; +import { Adapter, Strategy, parseMoney, debounce, observe, waitFor } from './base.js'; + +const text = (root, selector) => { + if (!selector) return null; + const node = root.querySelector(selector); + return node ? node.textContent.trim().replace(/\s+/g, ' ') : null; +}; + +export class GenericDomAdapter extends Adapter { + constructor(emit, options = {}) { + super(emit, options); + this.profile = options.profile; + this.strategy = Strategy.DOM; + this.lastNomination = null; + this.lastBid = null; + this.seenRows = new Set(); + } + + static get id() { return 'generic-dom'; } + + async start() { + const { selectors } = this.profile; + const anchor = await waitFor(selectors.root ?? 'body'); + if (!anchor) { + this.emit(makeEvent(EventType.CORRECTION, { + targetId: 'n/a', + patch: {}, + note: 'draft room never mounted; adapter idle', + }, { source: 'dom' })); + return; + } + + const scan = debounce(() => this.scan(), this.options.debounceMs ?? 120); + this.track(observe(document.body, scan)); + this.track(() => scan.cancel()); + this.scan(); + } + + scan() { + try { + this.scanNomination(); + this.scanResults(); + this.scanTeams(); + } catch (err) { + // A selector drift must not kill the observer -- degrade, don't die. + console.warn('[auction-tracker] scan failed', err); + } + } + + scanNomination() { + const s = this.profile.selectors; + const root = s.nomination ? document.querySelector(s.nomination) : null; + if (!root) { + if (this.lastNomination) { + this.lastNomination = null; + this.lastBid = null; + } + return; + } + + const name = text(root, s.nomName); + const position = text(root, s.nomPosition); + if (!name || !position) return; + + const signature = `${name}|${position}`; + if (signature !== this.lastNomination) { + this.lastNomination = signature; + this.lastBid = null; + this.emit(makeEvent(EventType.NOMINATION, { + playerName: name, + position, + nflTeam: text(root, s.nomTeam), + openingBid: parseMoney(text(root, s.highBid)) ?? undefined, + nominatingTeamId: text(root, s.highBidder) ?? undefined, + }, { source: 'dom', confidence: this.confidenceFor(Strategy.DOM) })); + } + + const amount = parseMoney(text(root, s.highBid)); + const bidder = text(root, s.highBidder); + if (amount != null && bidder && `${amount}|${bidder}` !== this.lastBid) { + this.lastBid = `${amount}|${bidder}`; + this.emit(makeEvent(EventType.BID, { + amount, teamId: bidder, + }, { source: 'dom', confidence: this.confidenceFor(Strategy.DOM) })); + } + } + + scanResults() { + const s = this.profile.selectors; + if (!s.resultRow) return; + + for (const row of document.querySelectorAll(s.resultRow)) { + const name = text(row, s.rowPlayer); + const price = parseMoney(text(row, s.rowPrice)); + const teamId = text(row, s.rowTeam); + if (!name || price == null || !teamId) continue; + + const signature = `${name}|${teamId}|${price}`; + if (this.seenRows.has(signature)) continue; + this.seenRows.add(signature); + + // A results row lacking a position is still a sale worth recording, but + // it cannot be matched to a valuation, so drop confidence to flag it. + const position = text(row, s.rowPosition); + this.emit(makeEvent(EventType.SOLD, { + playerName: name, + position: position ?? 'UNK', + nflTeam: text(row, s.rowNflTeam), + teamId, + teamName: teamId, + price, + }, { + source: 'dom', + confidence: this.confidenceFor(Strategy.DOM, position ? 0 : 0.2), + })); + } + } + + scanTeams() { + const s = this.profile.selectors; + if (!s.teamRow) return; + for (const row of document.querySelectorAll(s.teamRow)) { + const name = text(row, s.teamName); + if (!name) continue; + this.emit(makeEvent(EventType.TEAM_REGISTERED, { + teamId: name, + teamName: name, + }, { source: 'dom', confidence: this.confidenceFor(Strategy.DOM) })); + } + } +} diff --git a/fantasy-auction-tracker/src/adapters/profiles.js b/fantasy-auction-tracker/src/adapters/profiles.js new file mode 100644 index 0000000..b2fb4b4 --- /dev/null +++ b/fantasy-auction-tracker/src/adapters/profiles.js @@ -0,0 +1,99 @@ +/** + * Site profiles for the generic DOM adapter. + * + * IMPORTANT -- these selectors are PROVISIONAL. They were written without + * access to a live auction draft room, and every fantasy platform reskins its + * draft app between seasons. Do not trust them on draft day. + * + * Calibrate before you rely on any of this: + * 1. open the draft room (a mock draft is fine, and is the only safe way to + * test this) + * 2. run `tools/calibrate.js` in the page console -- it dumps candidate + * selectors for player names, prices and team rows + * 3. paste the corrected selectors here, or set them at runtime from the + * sidebar's Advanced panel, which writes an override to storage + * + * `wsHints` lists substrings that identify the draft WebSocket, and the JSON + * paths the tap should look for. Getting these right is worth far more than + * perfecting the selectors -- the WS layer is exact. + */ + +export const PROFILES = [ + { + id: 'nfl', + label: 'NFL.com Fantasy', + match: /(^|\.)fantasy\.nfl\.com$/, + urlHint: /draft/i, + selectors: { + root: '#draftBoard, .draftContainer, body', + nomination: '.currentPlayer, .nominatedPlayer, .auctionNomination', + nomName: '.playerName, .playerNameFull, a.playerName', + nomPosition: '.playerPosition, em', + nomTeam: '.playerTeam', + highBid: '.currentBid, .bidAmount, .auctionCurrentBid', + highBidder: '.highBidder, .currentBidder, .bidTeam', + resultRow: '#draftResults tr, .draftResultsTable tr', + rowPlayer: '.playerName, td.player a', + rowPosition: '.playerPosition, em', + rowPrice: '.auctionPrice, td.price', + rowTeam: '.teamName, td.team', + teamRow: '.auctionBudgets tr, .teamBudgetRow', + teamName: '.teamName', + teamBudget: '.budgetRemaining, .remaining', + }, + wsHints: { + urlContains: ['fantasy.nfl.com', '/draft', 'socket'], + messageKeys: ['playerId', 'auctionAmount', 'teamId', 'eventType'], + }, + }, + { + id: 'cbs', + label: 'CBS Sports Fantasy', + match: /(^|\.)cbssports\.com$/, + urlHint: /draft|auction/i, + selectors: { + root: '.draft-room, #draftRoom, body', + nomination: '.auction-nomination, .nominated-player, .current-player', + nomName: '.player-name, .playerLink', + nomPosition: '.player-position, .position', + nomTeam: '.player-team, .proTeam', + highBid: '.current-bid, .high-bid, .bid-amount', + highBidder: '.high-bidder, .bidding-team', + resultRow: '.draft-results tr, .results-row', + rowPlayer: '.player-name', + rowPosition: '.player-position, .position', + rowPrice: '.bid-amount, .price, .salary', + rowTeam: '.team-name, .owner', + teamRow: '.team-budgets .team-row, .budget-row', + teamName: '.team-name', + teamBudget: '.budget-remaining, .remaining-salary', + }, + wsHints: { + urlContains: ['cbssports.com', 'draft', 'socket', 'stream'], + messageKeys: ['playerId', 'amount', 'teamId', 'type'], + }, + }, + { + // Fallback: matches nothing automatically. Point it at any site by setting + // selectors from the sidebar's Advanced panel. + id: 'custom', + label: 'Custom (user-configured)', + match: /$^/, + selectors: {}, + wsHints: { urlContains: [], messageKeys: [] }, + }, +]; + +export function profileFor(url, overrides = {}) { + let host; + try { host = new URL(url).hostname; } catch { return null; } + + const base = PROFILES.find((p) => p.match.test(host)); + if (!base) return overrides.selectors ? { ...PROFILES.at(-1), ...overrides } : null; + + return { + ...base, + selectors: { ...base.selectors, ...(overrides.selectors ?? {}) }, + wsHints: { ...base.wsHints, ...(overrides.wsHints ?? {}) }, + }; +} diff --git a/fantasy-auction-tracker/src/adapters/ws.js b/fantasy-auction-tracker/src/adapters/ws.js new file mode 100644 index 0000000..33e1508 --- /dev/null +++ b/fantasy-auction-tracker/src/adapters/ws.js @@ -0,0 +1,231 @@ +/** + * WebSocket adapter. + * + * Consumes frames mirrored out of the page by content/inject.js. Because the + * on-the-wire schema differs per platform (and is undocumented), this works in + * two modes: + * + * RECORD - no mapping configured yet. Frames are stored raw so you can run + * a mock draft, export the capture, and derive an exact mapping. + * This is the intended first run against any new platform. + * MAP - a mapping is configured. Frames are translated into draft events + * with full confidence. + * + * The heuristic sniffer in `guessMapping` is a convenience for building that + * mapping from a capture; it is not trusted to run unattended, because a + * wrongly-guessed price field is worse than no data at all. + */ + +import { EventType, makeEvent } from '../core/events.js'; +import { Adapter, Strategy } from './base.js'; + +/** Read a dotted path, tolerating arrays: 'payload.player.name'. */ +export function get(obj, path) { + if (!path) return undefined; + return path.split('.').reduce((acc, key) => { + if (acc == null) return undefined; + return Array.isArray(acc) && /^\d+$/.test(key) ? acc[Number(key)] : acc[key]; + }, obj); +} + +/** + * A mapping describes how to read one platform's frames. + * + * { + * typePath: 'type', + * types: { nomination: ['NOMINATE'], bid: ['BID'], sold: ['SOLD','WON'] }, + * paths: { + * playerName: 'player.fullName', + * position: 'player.position', + * nflTeam: 'player.proTeam', + * teamId: 'teamId', + * amount: 'amount', + * }, + * } + */ +export class WebSocketAdapter extends Adapter { + constructor(emit, options = {}) { + super(emit, options); + this.strategy = Strategy.WS; + this.mapping = options.mapping ?? null; + this.hints = options.wsHints ?? { urlContains: [] }; + /** Raw capture, used in RECORD mode and for post-draft debugging. */ + this.capture = []; + this.maxCapture = options.maxCapture ?? 5000; + this.onFrame = options.onFrame ?? null; + } + + static get id() { return 'ws'; } + + get mode() { return this.mapping ? 'MAP' : 'RECORD'; } + + /** Is this socket plausibly the draft feed rather than ads/telemetry? */ + relevant(url) { + const hints = this.hints.urlContains ?? []; + if (!hints.length) return true; + return hints.some((h) => url.includes(h)); + } + + handleFrame({ url, body }) { + if (!this.relevant(url)) return; + + let parsed; + try { parsed = JSON.parse(body); } catch { return; } + + if (this.capture.length < this.maxCapture) { + this.capture.push({ url, body }); + } + this.onFrame?.({ url, parsed }); + + if (!this.mapping) return; // RECORD mode: observe only. + + // Frames often batch several updates. + const items = Array.isArray(parsed) ? parsed : [parsed]; + for (const item of items) this.translate(item); + } + + translate(msg) { + const { typePath, types, paths } = this.mapping; + const rawType = String(get(msg, typePath) ?? '').toUpperCase(); + if (!rawType) return; + + const matches = (names) => (names ?? []).some((n) => rawType.includes(n.toUpperCase())); + const conf = this.confidenceFor(Strategy.WS); + + const playerName = get(msg, paths.playerName); + const position = get(msg, paths.position); + const teamId = get(msg, paths.teamId); + const amount = Number(get(msg, paths.amount)); + + if (matches(types.sold)) { + if (!playerName || !teamId || !Number.isFinite(amount)) return; + this.emit(makeEvent(EventType.SOLD, { + playerName: String(playerName), + position: String(position ?? 'UNK'), + nflTeam: get(msg, paths.nflTeam) ?? null, + teamId: String(teamId), + teamName: String(get(msg, paths.teamName) ?? teamId), + price: amount, + }, { source: 'ws', confidence: conf })); + return; + } + + if (matches(types.nomination)) { + if (!playerName) return; + this.emit(makeEvent(EventType.NOMINATION, { + playerName: String(playerName), + position: String(position ?? 'UNK'), + nflTeam: get(msg, paths.nflTeam) ?? null, + openingBid: Number.isFinite(amount) ? amount : undefined, + nominatingTeamId: teamId != null ? String(teamId) : undefined, + }, { source: 'ws', confidence: conf })); + return; + } + + if (matches(types.bid)) { + if (!Number.isFinite(amount) || teamId == null) return; + this.emit(makeEvent(EventType.BID, { + amount, teamId: String(teamId), + }, { source: 'ws', confidence: conf })); + } + } + + exportCapture() { + return { version: 1, frames: this.capture }; + } + + async start() { + // Frames are pushed in by content.js; nothing to poll. + } +} + +/** + * Suggest a mapping from a recorded capture. + * + * Scores candidate paths by how well they behave like the field in question: + * a price field is a small positive integer that changes often; a player name + * is a two-word string; a type field is a short repeated enum. Output is a + * starting point for a human to confirm, never something to ship unreviewed. + */ +export function guessMapping(frames) { + const paths = new Map(); // path -> { values:Set, samples:[] } + + const walk = (obj, prefix = '') => { + if (obj == null || typeof obj !== 'object') return; + for (const [key, value] of Object.entries(obj)) { + const path = prefix ? `${prefix}.${key}` : key; + if (value && typeof value === 'object') { + walk(value, path); + } else { + if (!paths.has(path)) paths.set(path, { values: new Set(), samples: [] }); + const entry = paths.get(path); + entry.values.add(value); + if (entry.samples.length < 20) entry.samples.push(value); + } + } + }; + + for (const frame of frames) { + try { walk(JSON.parse(frame.body ?? frame)); } catch { /* skip */ } + } + + const score = (predicate) => [...paths.entries()] + .map(([path, entry]) => ({ path, entry, score: predicate(entry, path) })) + .filter((c) => c.score > 0) + .sort((a, b) => b.score - a.score); + + const isEnum = (e, path) => { + const strings = [...e.values].filter((v) => typeof v === 'string'); + if (strings.length !== e.values.size || e.values.size === 0) return 0; + const short = strings.every((s) => s.length <= 32 && !s.includes(' ')); + const repeated = e.samples.length > e.values.size; + return (short ? 2 : 0) + (repeated ? 2 : 0) + (/type|event|action|kind/i.test(path) ? 3 : 0); + }; + + const isName = (e, path) => { + const strings = [...e.values].filter((v) => typeof v === 'string'); + if (!strings.length) return 0; + const nameish = strings.filter((s) => /^[A-Z][a-z'’.-]+ [A-Z]/.test(s)).length / strings.length; + return nameish * 5 + (/name|player|full/i.test(path) ? 3 : 0); + }; + + const isMoney = (e, path) => { + const nums = [...e.values].filter((v) => typeof v === 'number'); + if (nums.length !== e.values.size || !nums.length) return 0; + const plausible = nums.filter((n) => Number.isInteger(n) && n >= 0 && n <= 400).length / nums.length; + return plausible * 4 + (/amount|bid|price|cost|value|salary/i.test(path) ? 3 : 0); + }; + + const isPosition = (e) => { + const strings = [...e.values].filter((v) => typeof v === 'string'); + if (!strings.length) return 0; + const posish = strings.filter((s) => /^(QB|RB|WR|TE|K|DEF|DST|D\/ST)$/i.test(s.trim())).length; + return (posish / strings.length) * 10; + }; + + const isTeamId = (e, path) => (/team|owner|franchise|roster/i.test(path) && e.values.size <= 32 ? 4 : 0); + + const top = (list) => list[0]?.path ?? null; + + return { + typePath: top(score(isEnum)), + types: { + nomination: ['NOMINAT'], + bid: ['BID'], + sold: ['SOLD', 'WON', 'COMPLETE'], + }, + paths: { + playerName: top(score(isName)), + position: top(score(isPosition)), + teamId: top(score(isTeamId)), + amount: top(score(isMoney)), + }, + candidates: { + type: score(isEnum).slice(0, 5).map((c) => c.path), + playerName: score(isName).slice(0, 5).map((c) => c.path), + position: score(isPosition).slice(0, 5).map((c) => c.path), + teamId: score(isTeamId).slice(0, 5).map((c) => c.path), + amount: score(isMoney).slice(0, 5).map((c) => c.path), + }, + }; +} diff --git a/fantasy-auction-tracker/src/background/background.js b/fantasy-auction-tracker/src/background/background.js new file mode 100644 index 0000000..07aebd9 --- /dev/null +++ b/fantasy-auction-tracker/src/background/background.js @@ -0,0 +1,188 @@ +/** + * Background event page. + * + * Owns the single source of truth: the draft store and the loaded valuations. + * The content script feeds it events; the sidebar reads snapshots from it. + * Keeping state here (not in the sidebar or the page) means a draft-room + * refresh, a sidebar close, or a tab crash loses nothing. + */ + +import { DraftStore } from '../core/store.js'; +import { EventType } from '../core/events.js'; +import { snapshot } from '../core/analytics.js'; +import { loadValuations, rescaleToLeague } from '../core/valuations.js'; +import { buildTargets, seedFromValuations } from '../core/targets.js'; +import { DEFAULT_CONFIG } from '../core/config.js'; + +const STORAGE_KEY = 'draft:current'; + +let store = new DraftStore({ config: DEFAULT_CONFIG }); +let valuations = []; +let targets = []; +let autoAdjustTargets = false; +let status = { profileId: null, tap: false, lastEventAt: null, source: null }; + +/** Persist on a timer rather than per-event; a hot auction fires in bursts. */ +let saveTimer = null; +function schedulePersist() { + clearTimeout(saveTimer); + saveTimer = setTimeout(async () => { + try { + await browser.storage.local.set({ [STORAGE_KEY]: store.serialize() }); + } catch (err) { + console.error('[auction-tracker] persist failed', err); + } + }, 500); +} + +async function restore() { + const saved = await browser.storage.local.get([ + STORAGE_KEY, 'valuationsCsv', 'config', 'targets', 'autoAdjustTargets', + ]); + const config = { ...DEFAULT_CONFIG, ...(saved.config ?? {}) }; + + if (saved[STORAGE_KEY]?.log?.length) { + store = DraftStore.deserialize({ config, log: saved[STORAGE_KEY].log }); + } else { + store = new DraftStore({ config }); + } + + if (saved.valuationsCsv) applyValuations(saved.valuationsCsv, config); + // Targets are the user's own work, so they are restored verbatim rather + // than re-seeded from the CSV, which would silently discard hand edits. + targets = buildTargets(saved.targets ?? []); + autoAdjustTargets = saved.autoAdjustTargets ?? false; + store.subscribe(() => schedulePersist()); +} + +function applyValuations(csv, config = store.baseConfig) { + const { players, problems } = loadValuations(csv); + valuations = rescaleToLeague(players, config); + store.emit(EventType.VALUATIONS_LOADED, { source: 'csv', count: players.length }); + return { count: players.length, problems }; +} + +function currentSnapshot() { + return { + ...snapshot(store.state, valuations, { + aggressiveness: store.baseConfig.aggressiveness ?? 0.5, + targets, + autoAdjustTargets, + }), + config: store.baseConfig, + valuationCount: valuations.length, + logLength: store.log.length, + status, + }; +} + +browser.runtime.onMessage.addListener((msg, sender) => { + switch (msg?.kind) { + case 'draft-event': { + const result = store.append(msg.event); + if (result.ok) { + status.lastEventAt = Date.now(); + status.source = msg.event.source; + broadcast(); + } + return Promise.resolve(result); + } + + case 'ws-seen': + status.tap = true; + return Promise.resolve({ ok: true }); + + case 'adapter-status': + status.profileId = msg.status; + status.tabId = sender?.tab?.id ?? null; + broadcast(); + return Promise.resolve({ ok: true }); + + case 'get-snapshot': + return Promise.resolve(currentSnapshot()); + + case 'get-log': + return Promise.resolve(store.serialize()); + + case 'load-valuations': { + const result = applyValuations(msg.csv); + browser.storage.local.set({ valuationsCsv: msg.csv }); + broadcast(); + return Promise.resolve(result); + } + + case 'set-config': { + const config = { ...store.baseConfig, ...msg.config }; + store.baseConfig = config; + store.emit(EventType.LEAGUE_CONFIGURED, config, { source: 'manual' }); + // Values are scaled to league size, so a config change invalidates them. + browser.storage.local.get('valuationsCsv').then(({ valuationsCsv }) => { + if (valuationsCsv) applyValuations(valuationsCsv, config); + browser.storage.local.set({ config }); + broadcast(); + }); + return Promise.resolve({ ok: true }); + } + + case 'manual-event': { + const result = store.emit(msg.type, msg.payload, { source: 'manual' }); + broadcast(); + return Promise.resolve(result); + } + + case 'retract': { + const result = store.retract(msg.targetId, msg.reason); + broadcast(); + return Promise.resolve(result); + } + + case 'correct': { + const result = store.correct(msg.targetId, msg.patch); + broadcast(); + return Promise.resolve(result); + } + + case 'set-targets': { + targets = buildTargets(msg.targets ?? []); + browser.storage.local.set({ targets }); + broadcast(); + return Promise.resolve({ ok: true, count: targets.length }); + } + + case 'seed-targets': { + // Merge rather than replace: seeding is meant to fill an empty board or + // top it up, never to wipe prices you have already tuned. + const seeded = seedFromValuations(valuations, msg.options ?? {}); + const existing = new Map(targets.map((t) => [t.key, t])); + targets = buildTargets([...seeded, ...existing.values()]); + browser.storage.local.set({ targets }); + broadcast(); + return Promise.resolve({ ok: true, count: targets.length }); + } + + case 'set-auto-adjust': { + autoAdjustTargets = Boolean(msg.value); + browser.storage.local.set({ autoAdjustTargets }); + broadcast(); + return Promise.resolve({ ok: true }); + } + + case 'reset': + store = new DraftStore({ config: store.baseConfig }); + store.subscribe(() => schedulePersist()); + browser.storage.local.remove(STORAGE_KEY); + broadcast(); + return Promise.resolve({ ok: true }); + + default: + return undefined; + } +}); + +/** Push a fresh snapshot to any open sidebar. */ +function broadcast() { + browser.runtime.sendMessage({ kind: 'snapshot', snapshot: currentSnapshot() }) + .catch(() => { /* no sidebar open */ }); +} + +restore().catch((err) => console.error('[auction-tracker] restore failed', err)); diff --git a/fantasy-auction-tracker/src/content/content.js b/fantasy-auction-tracker/src/content/content.js new file mode 100644 index 0000000..9c1957f --- /dev/null +++ b/fantasy-auction-tracker/src/content/content.js @@ -0,0 +1,100 @@ +/** + * Content script. + * + * Runs in the isolated world on the draft page. Responsibilities: + * - inject the page-world WebSocket tap + * - relay tapped frames to the WS adapter + * - run the DOM adapter as a parallel fallback + * - forward every produced event to the background page + * + * Both adapters run at once on purpose. The store deduplicates, so the DOM + * layer silently covers anything the WS mapping misses instead of leaving a + * hole you only notice after the draft. + */ + +(async () => { + const CHANNEL = '__auction_tracker__'; + const url = (path) => browser.runtime.getURL(path); + + const [{ profileFor }, { GenericDomAdapter }, { WebSocketAdapter }] = await Promise.all([ + import(url('src/adapters/profiles.js')), + import(url('src/adapters/generic-dom.js')), + import(url('src/adapters/ws.js')), + ]); + + const { overrides = {}, mapping = null, enabled = true } = + await browser.storage.local.get(['overrides', 'mapping', 'enabled']); + + if (enabled === false) return; + + const profile = profileFor(window.location.href, overrides); + if (!profile) { + browser.runtime.sendMessage({ + kind: 'adapter-status', + status: 'no-profile', + href: window.location.href, + }); + return; + } + + const send = (event) => { + browser.runtime.sendMessage({ kind: 'draft-event', event }).catch(() => { + // Background may be asleep between events; the next send wakes it. + }); + }; + + // --- WebSocket layer ----------------------------------------------------- + const wsAdapter = new WebSocketAdapter(send, { + mapping, + wsHints: profile.wsHints, + onFrame: ({ url: frameUrl }) => { + browser.runtime.sendMessage({ kind: 'ws-seen', url: frameUrl }).catch(() => {}); + }, + }); + await wsAdapter.start(); + + window.addEventListener('message', (ev) => { + if (ev.source !== window) return; + const data = ev.data; + if (!data || data.channel !== CHANNEL) return; + + if (data.kind === 'ws-message' || data.kind === 'http-response') { + wsAdapter.handleFrame(data.detail); + } else if (data.kind === 'tap-ready') { + browser.runtime.sendMessage({ kind: 'adapter-status', status: 'tap-ready' }).catch(() => {}); + } + }); + + // Inject into the page world. A + + diff --git a/fantasy-auction-tracker/src/sidebar/sidebar.js b/fantasy-auction-tracker/src/sidebar/sidebar.js new file mode 100644 index 0000000..b534e63 --- /dev/null +++ b/fantasy-auction-tracker/src/sidebar/sidebar.js @@ -0,0 +1,509 @@ +/** + * Sidebar UI. + * + * Pure view layer: it renders snapshots pushed by the background page and + * sends user intent back. No analytics live here, so the numbers on screen are + * always the same numbers the tests cover. + */ + +import { EventType } from '../core/events.js'; + +const $ = (sel) => document.querySelector(sel); +const el = (tag, props = {}, children = []) => { + const node = Object.assign(document.createElement(tag), props); + for (const child of [].concat(children)) { + node.append(child instanceof Node ? child : document.createTextNode(String(child))); + } + return node; +}; +const money = (n) => (n == null || Number.isNaN(n) ? '—' : `$${Math.round(n)}`); + +let latest = null; + +// --- tabs ------------------------------------------------------------------- +for (const tab of document.querySelectorAll('.tab')) { + tab.addEventListener('click', () => { + for (const t of document.querySelectorAll('.tab')) t.classList.toggle('active', t === tab); + for (const p of document.querySelectorAll('.panel')) { + p.classList.toggle('active', p.id === `panel-${tab.dataset.panel}`); + } + }); +} + +// --- render ----------------------------------------------------------------- +function renderStatus(snap) { + const node = $('#status'); + const age = snap.status.lastEventAt ? Date.now() - snap.status.lastEventAt : null; + if (!snap.logLength) { + node.textContent = 'waiting for draft'; + node.className = 'status'; + } else if (snap.status.source === 'ws') { + node.textContent = 'live (websocket)'; + node.className = 'status live'; + } else { + node.textContent = `live (${snap.status.source ?? 'dom'})`; + node.className = 'status degraded'; + } + node.title = age != null ? `last event ${Math.round(age / 1000)}s ago` : 'no events yet'; +} + +function renderAdvice(snap) { + const box = $('#advice'); + box.textContent = ''; + const a = snap.advice; + + if (!a) { + box.append(el('p', { className: 'empty' }, 'Nothing on the block.')); + return; + } + + const explain = { + bid: 'Below your inflation-adjusted value — this is profit.', + stretch: 'Above par but inside your cliff allowance. Only if you want the player.', + pass: 'At or past your ceiling. Let it go.', + unvalued: 'No valuation for this player. Bid on your own read.', + 'no-slot': 'No open roster slot accepts this position.', + }[a.verdict]; + + box.append( + el('div', {}, [ + el('span', { className: 'player' }, a.player), + ' ', + el('span', { className: 'pos' }, a.position), + ' ', + el('span', { className: `verdict ${a.verdict}` }, a.verdict), + ]), + el('div', { className: 'numbers' }, [ + el('div', {}, [el('span', { className: 'k' }, 'On board'), el('span', { className: 'v' }, money(a.currentBid))]), + el('div', {}, [el('span', { className: 'k' }, 'Walk away'), el('span', { className: 'v' }, money(a.walkAway))]), + el('div', {}, [el('span', { className: 'k' }, 'Ceiling'), el('span', { className: 'v' }, money(a.ceiling))]), + ]), + a.isTarget + ? el('p', { className: 'hint target-line' }, + `On your board — tier ${a.targetTier}, your price ${money(a.targetPrice)}` + + (a.targetVsMarket != null && a.targetVsMarket !== 0 + ? ` (${a.targetVsMarket > 0 ? '+' : ''}${a.targetVsMarket} vs market)` : '') + + (a.targetNote ? ` — ${a.targetNote}` : '')) + : el('p', { className: 'hint' }, 'Not on your target board.'), + el('p', { className: 'hint' }, explain ?? ''), + el('p', { className: 'hint' }, + `par ${money(a.parValue)} × inflation ${a.inflation} · cliff ${money(a.tierCliff)} to ${a.nextBest ?? 'nobody'} · ` + + `${a.threats} rival${a.threats === 1 ? '' : 's'} can outbid` + + (a.topRival ? ` (max ${money(a.topRival.maxBid)}, ${a.topRival.teamName})` : '') + + (a.myMaxBid != null ? ` · your max ${money(a.myMaxBid)}` : '')), + ); +} + +function renderHistory(snap) { + const body = $('#history tbody'); + body.textContent = ''; + for (const sale of snap.history) { + const par = sale.parValue; + const delta = par != null ? sale.price - par : null; + body.append(el('tr', {}, [ + el('td', { title: sale.name }, sale.name), + el('td', {}, sale.position ?? '—'), + el('td', { title: sale.teamId }, sale.teamId), + el('td', { className: 'num' }, money(sale.price)), + el('td', { className: `num ${delta > 0 ? 'over' : 'under'}` }, + delta == null ? '—' : `${delta > 0 ? '+' : ''}${Math.round(delta)}`), + ])); + } +} + +function renderTeams(snap) { + const body = $('#teams tbody'); + body.textContent = ''; + const sorted = [...snap.teams].sort((a, b) => b.maxBid - a.maxBid); + for (const t of sorted) { + const needs = Object.entries(t.needs) + .filter(([slot]) => slot !== 'BN') + .map(([slot, n]) => (n > 1 ? `${slot}×${n}` : slot)) + .join(' '); + body.append(el('tr', { + className: [ + t.teamId === snap.config.myTeamId ? 'me' : '', + t.maxBid <= 1 ? 'broke' : '', + ].filter(Boolean).join(' '), + }, [ + el('td', { title: t.teamName }, t.teamName), + el('td', { className: 'num' }, money(t.remaining)), + el('td', { className: 'num' }, String(t.openSlots)), + el('td', { className: 'num' }, money(t.maxBid)), + el('td', { title: needs }, needs || '—'), + ])); + } +} + +function renderMarket(snap) { + const inf = snap.inflation; + const box = $('#inflation'); + box.textContent = ''; + const rate = inf.discretionary; + const reading = rate > 1.05 + ? 'Money is chasing fewer players — expect to overpay from here.' + : rate < 0.95 + ? 'Value is outrunning money — bargains ahead, stay patient.' + : 'Market is near par.'; + box.append( + el('div', { className: 'numbers' }, [ + el('div', {}, [el('span', { className: 'k' }, 'Inflation'), el('span', { className: 'v' }, rate.toFixed(2))]), + el('div', {}, [el('span', { className: 'k' }, '$ left'), el('span', { className: 'v' }, money(inf.remainingMoney))]), + el('div', {}, [el('span', { className: 'k' }, 'Spots left'), el('span', { className: 'v' }, String(inf.openSpots))]), + ]), + el('p', { className: 'hint' }, reading), + el('p', { className: 'hint' }, + `${money(Math.abs(inf.surplusSpent))} ${inf.surplusSpent >= 0 ? 'over' : 'under'} par spent so far.`), + ); + + const scarcityBody = $('#scarcity tbody'); + scarcityBody.textContent = ''; + for (const [pos, s] of Object.entries(snap.scarcity)) { + if (!s.available && !s.openStarterSlots) continue; + scarcityBody.append(el('tr', {}, [ + el('td', {}, pos), + el('td', { className: 'num' }, String(s.startable)), + el('td', { className: 'num' }, String(s.openStarterSlots)), + el('td', { className: `num ${s.ratio < 1 ? 'over' : ''}` }, + Number.isFinite(s.ratio) ? s.ratio.toFixed(2) : '∞'), + el('td', { title: s.topAvailable.map((p) => p.name).join(', ') }, + s.topAvailable[0]?.name ?? '—'), + ])); + } + + const pressureBody = $('#pressure tbody'); + pressureBody.textContent = ''; + for (const t of snap.pressure) { + pressureBody.append(el('tr', {}, [ + el('td', {}, t.teamName), + el('td', { className: 'num' }, money(t.remaining)), + el('td', { className: 'num' }, String(t.openSlots)), + el('td', { className: `num ${t.locked > 0.8 ? 'over' : ''}` }, `${Math.round(t.locked * 100)}%`), + ])); + } +} + +function renderAlerts(snap) { + const box = $('#alerts'); + box.textContent = ''; + + for (const item of snap.needsReview.slice(-5)) { + const p = item.payload; + box.append(el('div', { className: 'alert review' }, [ + `Unsure: ${p.playerName ?? item.type} ` + + (p.price != null ? `for ${money(p.price)} to ${p.teamId} ` : ''), + el('button', { + onclick: () => send({ kind: 'correct', targetId: item.id, patch: {} }), + }, 'Confirm'), + el('button', { + onclick: () => send({ kind: 'retract', targetId: item.id, reason: 'rejected in review' }), + }, 'Discard'), + ])); + } + + for (const w of snap.warnings.slice(-5)) { + box.append(el('div', { className: 'alert' }, w.message)); + } + + if (!snap.valuationCount) { + box.append(el('div', { className: 'alert' }, + 'No valuations loaded — inflation and bid advice are off. Import a CSV in Setup.')); + } +} + + +// --- targets ---------------------------------------------------------------- +/** Local working copy, so an in-progress edit is not clobbered by a snapshot. */ +let editing = null; + +function saveTargets(rows) { + send({ kind: 'set-targets', targets: rows }); +} + +/** Current board as plain editable entries. */ +function boardEntries(snap) { + return snap.targetBoard.map((t) => ({ + name: t.name, position: t.position, tier: t.tier, maxPrice: t.maxPrice, note: t.note, + })); +} + +function renderPlan(snap) { + const box = $('#plan'); + box.textContent = ''; + const p = snap.plan; + + if (!snap.targetBoard.length) { + box.append(el('p', { className: 'empty' }, + 'No targets yet. "Seed from CSV" builds a starting board from your valuations.')); + return; + } + + box.append( + el('div', { className: 'numbers' }, [ + el('div', {}, [el('span', { className: 'k' }, 'Plan cost'), el('span', { className: 'v' }, money(p.plannedCost))]), + el('div', {}, [el('span', { className: 'k' }, 'You have'), el('span', { className: 'v' }, money(p.budget))]), + el('div', {}, [ + el('span', { className: 'k' }, p.feasible ? 'Headroom' : 'Short by'), + el('span', { className: `v ${p.feasible ? 'under' : 'over'}` }, + money(p.feasible ? p.headroom : p.shortfall)), + ]), + ]), + el('p', { className: 'hint' }, p.feasible + ? `${p.plan.length} target${p.plan.length === 1 ? '' : 's'} fit your ${p.openSlots} open slot${p.openSlots === 1 ? '' : 's'}, ` + + `with ${money(p.reserve)} held back at minimum bid.` + : `Your remaining targets cost ${money(p.required)} but you have ${money(p.budget)}. ` + + 'Drop a tier, cut a price, or plan to find value late.'), + ); + + if (p.unslotted.length) { + box.append(el('p', { className: 'hint over' }, + `No roster slot left for: ${p.unslotted.map((t) => t.name).join(', ')}`)); + } +} + +function renderTiers(snap) { + const box = $('#tiers'); + box.textContent = ''; + if (!snap.targetBoard.length) return; + + const byTier = new Map(); + for (const t of snap.targetBoard) { + if (!byTier.has(t.tier)) byTier.set(t.tier, []); + byTier.get(t.tier).push(t); + } + const pressure = Object.fromEntries(snap.tiers.map((row) => [row.tier, row])); + + for (const tier of [...byTier.keys()].sort((a, b) => a - b)) { + const rows = byTier.get(tier).sort((a, b) => (b.maxPrice ?? 0) - (a.maxPrice ?? 0)); + const info = pressure[tier] ?? {}; + + const heading = el('h2', {}, `Tier ${tier}`); + heading.append(el('span', { + className: `tierbadge ${info.exhausted ? 'gone' : info.critical ? 'critical' : ''}`, + }, info.exhausted ? 'gone' : `${info.open}/${info.total} left`)); + box.append(heading); + + const table = el('table', { className: 'grid targets' }); + const tbody = el('tbody'); + + for (const t of rows) { + const priceInput = el('input', { + type: 'number', min: '0', className: 'price', + value: t.maxPrice ?? '', + disabled: t.status !== 'open', + }); + // Commit on change, not on every keystroke, so a half-typed "4" of "45" + // never briefly becomes the live ceiling on the Live tab. + priceInput.addEventListener('change', () => { + const entries = boardEntries(snap); + const row = entries.find((e) => e.name === t.name && e.position === t.position); + if (row) row.maxPrice = priceInput.value === '' ? null : Number(priceInput.value); + saveTargets(entries); + }); + + const tierInput = el('input', { + type: 'number', min: '1', className: 'tier', value: t.tier, + disabled: t.status !== 'open', + }); + tierInput.addEventListener('change', () => { + const entries = boardEntries(snap); + const row = entries.find((e) => e.name === t.name && e.position === t.position); + if (row) row.tier = Number(tierInput.value) || 1; + saveTargets(entries); + }); + + const status = t.status === 'won' + ? el('span', { className: 'tag won', title: `you paid ${money(t.soldFor)}` }, `won ${money(t.soldFor)}`) + : t.status === 'lost' + ? el('span', { + className: 'tag lost', + title: `${t.wonBy} paid ${money(t.soldFor)}` + + (t.overshoot != null ? ` (${t.overshoot >= 0 ? '+' : ''}${t.overshoot} vs your target)` : ''), + }, `lost ${money(t.soldFor)}`) + : el('button', { className: 'drop', title: 'remove from board' }, '×'); + + if (t.status === 'open') { + status.addEventListener('click', () => { + saveTargets(boardEntries(snap).filter((e) => !(e.name === t.name && e.position === t.position))); + }); + } + + // When auto-adjust is on, the effective price differs from what you + // typed; show it so the override is visible rather than surprising. + const effective = snap.autoAdjustTargets && t.effective != null && t.effective !== t.maxPrice + ? el('span', { className: 'eff', title: 'inflation-adjusted' }, `→${money(t.effective)}`) + : null; + + tbody.append(el('tr', { className: t.status }, [ + el('td', { title: t.name }, t.name), + el('td', {}, el('span', { className: 'pos' }, t.position ?? '—')), + el('td', { className: 'num' }, tierInput), + el('td', { className: 'num' }, effective ? [priceInput, effective] : priceInput), + el('td', { className: 'num', title: 'par value from your CSV' }, + t.parValue != null ? money(t.parValue) : '—'), + el('td', {}, status), + ])); + } + + table.append( + el('thead', {}, el('tr', {}, [ + el('th', {}, 'Player'), el('th', {}, 'Pos'), el('th', { className: 'num' }, 'Tier'), + el('th', { className: 'num' }, 'Your $'), el('th', { className: 'num' }, 'Par'), el('th', {}, ''), + ])), + tbody, + ); + box.append(table); + } +} + +function render(snap) { + latest = snap; + renderStatus(snap); + renderAdvice(snap); + renderPlan(snap); + renderTiers(snap); + $('#auto-adjust').checked = snap.autoAdjustTargets; + renderHistory(snap); + renderTeams(snap); + renderMarket(snap); + renderAlerts(snap); +} + +// --- messaging -------------------------------------------------------------- +const send = (msg) => browser.runtime.sendMessage(msg); + +browser.runtime.onMessage.addListener((msg) => { + if (msg?.kind === 'snapshot') render(msg.snapshot); + return undefined; +}); + +// --- setup handlers --------------------------------------------------------- +const form = $('#config-form'); + +form.addEventListener('submit', (ev) => { + ev.preventDefault(); + const data = new FormData(form); + const slotText = String(data.get('rosterSlots') ?? '').trim(); + + // Accepts "QB,RB,RB,BN x6" as well as a bare comma list. + let rosterSlots; + if (slotText) { + rosterSlots = slotText.split(',').flatMap((chunk) => { + const m = /^\s*([A-Za-z]+)\s*(?:x\s*(\d+))?\s*$/.exec(chunk); + if (!m) return []; + return Array.from({ length: Number(m[2] ?? 1) }, () => m[1].toUpperCase()); + }); + } + + send({ + kind: 'set-config', + config: { + numTeams: Number(data.get('numTeams')), + budget: Number(data.get('budget')), + minBid: Number(data.get('minBid')), + myTeamId: String(data.get('myTeamId') ?? '').trim() || null, + aggressiveness: Number(data.get('aggressiveness')), + ...(rosterSlots?.length ? { rosterSlots } : {}), + }, + }); +}); + +form.aggressiveness.addEventListener('input', (ev) => { + $('#aggr-value').textContent = ev.target.value; +}); + + +$('#add-target').addEventListener('click', () => { + const name = prompt('Player name?'); + if (!name) return; + const position = prompt('Position? (QB/RB/WR/TE/K/DST)'); + if (!position) return; + const tier = Number(prompt('Tier? (1 = top)', '1')) || 1; + const raw = prompt('Your max price? (blank = use par value)', ''); + const maxPrice = raw === '' || raw == null ? null : Number(raw); + + send({ + kind: 'set-targets', + targets: [...boardEntries(latest), { name, position, tier, maxPrice }], + }); +}); + +$('#seed-targets').addEventListener('click', async () => { + const result = await send({ kind: 'seed-targets', options: { topN: 60, tierCount: 6 } }); + if (!result.count) { + alert('No valuations loaded yet — import a CSV in Setup first.'); + } +}); + +$('#auto-adjust').addEventListener('change', (ev) => { + send({ kind: 'set-auto-adjust', value: ev.target.checked }); +}); + +$('#csv-file').addEventListener('change', async (ev) => { + const file = ev.target.files?.[0]; + if (!file) return; + const csv = await file.text(); + const result = await send({ kind: 'load-valuations', csv }); + $('#csv-status').textContent = `${result.count} players loaded` + + (result.problems.length ? ` — ${result.problems.join('; ')}` : ''); +}); + +function download(name, data) { + const url = URL.createObjectURL(new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })); + const a = el('a', { href: url, download: name }); + document.body.append(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +} + +$('#export-log').addEventListener('click', async () => { + download('draft-log.json', await send({ kind: 'get-log' })); +}); + +$('#export-capture').addEventListener('click', async () => { + const [tab] = await browser.tabs.query({ active: true, currentWindow: true }); + if (!tab) return; + const capture = await browser.tabs.sendMessage(tab.id, { kind: 'export-capture' }); + download('ws-capture.json', capture); +}); + +$('#reset').addEventListener('click', () => { + if (confirm('Discard the current draft log? This cannot be undone.')) { + send({ kind: 'reset' }); + } +}); + +$('#manual-form').addEventListener('submit', (ev) => { + ev.preventDefault(); + const data = new FormData(ev.target); + send({ + kind: 'manual-event', + type: EventType.SOLD, + payload: { + playerName: String(data.get('playerName')).trim(), + position: String(data.get('position')).trim().toUpperCase(), + teamId: String(data.get('teamId')).trim(), + teamName: String(data.get('teamId')).trim(), + price: Number(data.get('price')), + }, + }); + ev.target.reset(); +}); + +// --- boot ------------------------------------------------------------------- +(async () => { + const snap = await send({ kind: 'get-snapshot' }); + const c = snap.config; + form.numTeams.value = c.numTeams; + form.budget.value = c.budget; + form.minBid.value = c.minBid; + form.myTeamId.value = c.myTeamId ?? ''; + form.aggressiveness.value = c.aggressiveness ?? 0.5; + form.rosterSlots.value = c.rosterSlots.join(','); + $('#aggr-value').textContent = form.aggressiveness.value; + render(snap); +})(); + +// Keeps the "last event Ns ago" tooltip honest while the draft is quiet. +setInterval(() => { if (latest) renderStatus(latest); }, 5000); diff --git a/fantasy-auction-tracker/test/analytics.test.js b/fantasy-auction-tracker/test/analytics.test.js new file mode 100644 index 0000000..d2dab2a --- /dev/null +++ b/fantasy-auction-tracker/test/analytics.test.js @@ -0,0 +1,237 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { EventType, makeEvent } from '../src/core/events.js'; +import { reduce } from '../src/core/reducer.js'; +import { DEFAULT_CONFIG, expandSlots } from '../src/core/config.js'; +import { + inflation, positionalInflation, scarcity, tierCliff, + liveBidders, bidAdvice, budgetPressure, availablePlayers, +} from '../src/core/analytics.js'; + +const config = { + ...DEFAULT_CONFIG, + numTeams: 2, + budget: 100, + minBid: 1, + myTeamId: 'me', + rosterSlots: expandSlots({ RB: 2, WR: 2 }), +}; + +/** 8 players at $25 par each: 2 teams x 4 slots, $200 total, exactly at par. */ +const valuations = [ + { name: 'RB A', position: 'RB', value: 25 }, + { name: 'RB B', position: 'RB', value: 25 }, + { name: 'RB C', position: 'RB', value: 25 }, + { name: 'RB D', position: 'RB', value: 25 }, + { name: 'WR A', position: 'WR', value: 25 }, + { name: 'WR B', position: 'WR', value: 25 }, + { name: 'WR C', position: 'WR', value: 25 }, + { name: 'WR D', position: 'WR', value: 25 }, +]; + +const sold = (name, pos, teamId, price, id) => + makeEvent(EventType.SOLD, { + playerName: name, position: pos, teamId, teamName: teamId, price, + }, { id, source: 'ws' }); + +const register = (teamId) => + makeEvent(EventType.TEAM_REGISTERED, { teamId, teamName: teamId }, { id: `r-${teamId}` }); + +const nominate = (name, pos, bid, bidder) => makeEvent(EventType.NOMINATION, { + playerName: name, position: pos, openingBid: bid, nominatingTeamId: bidder, +}, { id: `n-${name}` }); + +test('a perfectly par market has inflation 1.0', () => { + const state = reduce([register('me'), register('you')], { config }); + const inf = inflation(state, valuations); + + assert.equal(inf.remainingMoney, 200); + assert.equal(inf.remainingValue, 200); + assert.equal(inf.raw, 1); + assert.equal(inf.discretionary, 1); +}); + +test('overpaying early inflates the remaining market', () => { + // $25 player goes for $45: $20 of value left the pool but $45 of money did. + const state = reduce([register('me'), register('you'), sold('RB A', 'RB', 'you', 45, 'e1')], { config }); + const inf = inflation(state, valuations); + + assert.equal(inf.remainingMoney, 155); + assert.equal(inf.remainingValue, 175); + assert.equal(inf.surplusSpent, 20); + assert.ok(inf.discretionary < 1, 'money left the pool faster than value'); +}); + +test('bargains early deflate the remaining market', () => { + const state = reduce([register('me'), register('you'), sold('RB A', 'RB', 'you', 5, 'e1')], { config }); + const inf = inflation(state, valuations); + + assert.equal(inf.surplusSpent, -20); + assert.ok(inf.discretionary > 1, 'money outlasted value, so prices rise'); +}); + +test('discretionary inflation strips the $1-per-slot floor from both sides', () => { + const state = reduce([register('me'), register('you')], { config }); + const inf = inflation(state, valuations); + + // 4 open spots per team x 2 = 8 spots, so $8 is pinned and cannot chase value. + assert.equal(inf.openSpots, 8); + // Raw and discretionary agree only because this market is exactly at par. + assert.equal(inf.raw, inf.discretionary); +}); + +test('max bid caps what a rival can actually pay', () => { + const state = reduce([ + register('me'), register('you'), + sold('RB A', 'RB', 'you', 97, 'e1'), + ], { config }); + + const rivals = liveBidders(state, 'WR', { excludeTeamId: 'me' }); + // $3 left, 3 open slots -> can bid $1 and still fill the rest at $1. + assert.equal(rivals[0].maxBid, 1); +}); + +test('a team with no eligible open slot is not a live bidder', () => { + const state = reduce([ + register('me'), register('you'), + sold('RB A', 'RB', 'you', 10, 'e1'), + sold('RB B', 'RB', 'you', 10, 'e2'), + ], { config }); + + const rivals = liveBidders(state, 'RB', { excludeTeamId: 'me' }); + assert.equal(rivals.length, 0, 'both RB slots are full and there is no flex'); +}); + +test('scarcity counts startable players against unfilled starting slots', () => { + const state = reduce([register('me'), register('you')], { config }); + const s = scarcity(state, valuations); + + assert.equal(s.RB.available, 4); + assert.equal(s.RB.openStarterSlots, 4); + assert.ok(Number.isFinite(s.RB.ratio)); +}); + +test('tier cliff measures the drop to the next player at the position', () => { + const uneven = [ + { name: 'Elite RB', position: 'RB', value: 60 }, + { name: 'Meh RB', position: 'RB', value: 12 }, + ]; + const state = reduce([register('me')], { config }); + const cliff = tierCliff(state, uneven, 'RB'); + + assert.equal(cliff.cliff, 48); + assert.equal(cliff.next.name, 'Meh RB'); +}); + +test('drafted players leave the available pool', () => { + const state = reduce([sold('RB A', 'RB', 'you', 25, 'e1')], { config }); + const avail = availablePlayers(state, valuations); + + assert.equal(avail.length, 7); + assert.ok(!avail.some((p) => p.name === 'RB A')); +}); + +test('advice says bid when the board is under your adjusted value', () => { + const state = reduce([register('me'), register('you'), nominate('RB A', 'RB', 10, 'you')], { config }); + const a = bidAdvice(state, valuations, { aggressiveness: 0 }); + + assert.equal(a.verdict, 'bid'); + assert.equal(a.parValue, 25); + assert.equal(a.currentBid, 10); + assert.ok(a.surplus > 0); +}); + +test('advice says pass once the board passes your ceiling', () => { + const state = reduce([ + register('me'), register('you'), + makeEvent(EventType.NOMINATION, { playerName: 'RB A', position: 'RB', openingBid: 40 }, { id: 'n1' }), + ], { config }); + + const a = bidAdvice(state, valuations, { aggressiveness: 0 }); + assert.equal(a.verdict, 'pass'); +}); + +test('aggressiveness raises the ceiling by a share of the tier cliff', () => { + // Scaled to the league (8 players, $200 total) so inflation is exactly 1.0 + // and the only thing separating the two ceilings is the cliff allowance. + const cliffy = [ + { name: 'Elite RB', position: 'RB', value: 60 }, + { name: 'Meh RB', position: 'RB', value: 12 }, + { name: 'RB C', position: 'RB', value: 12 }, + { name: 'RB D', position: 'RB', value: 12 }, + { name: 'WR A', position: 'WR', value: 30 }, + { name: 'WR B', position: 'WR', value: 26 }, + { name: 'WR C', position: 'WR', value: 26 }, + { name: 'WR D', position: 'WR', value: 22 }, + ]; + const log = [register('me'), register('you'), nominate('Elite RB', 'RB', 60)]; + const state = reduce(log, { config }); + + const timid = bidAdvice(state, cliffy, { aggressiveness: 0 }); + const bold = bidAdvice(state, cliffy, { aggressiveness: 1 }); + + assert.ok(bold.ceiling > timid.ceiling); + assert.equal(bold.verdict, 'stretch'); + assert.equal(timid.verdict, 'pass'); +}); + +test('your own max bid hard-caps the ceiling', () => { + const state = reduce([ + register('me'), register('you'), + sold('WR A', 'WR', 'me', 97, 'e1'), // me: $3 left, 3 slots open + nominate('RB A', 'RB', 1), + ], { config }); + + const a = bidAdvice(state, valuations, { aggressiveness: 1 }); + assert.equal(a.myMaxBid, 1); + assert.ok(a.ceiling <= 1, 'never advise a bid you cannot legally make'); +}); + +test('advice flags a player with no open slot on your roster', () => { + const state = reduce([ + register('me'), register('you'), + sold('RB A', 'RB', 'me', 10, 'e1'), + sold('RB B', 'RB', 'me', 10, 'e2'), + nominate('RB C', 'RB', 5), + ], { config }); + + assert.equal(bidAdvice(state, valuations).verdict, 'no-slot'); +}); + +test('advice flags a player missing from your valuations', () => { + const state = reduce([register('me'), nominate('Undrafted Guy', 'RB', 3)], { config }); + assert.equal(bidAdvice(state, valuations).verdict, 'unvalued'); +}); + +test('no nomination means no advice', () => { + const state = reduce([register('me')], { config }); + assert.equal(bidAdvice(state, valuations), null); +}); + +test('positional inflation reflects money chasing one position', () => { + const state = reduce([register('me'), register('you')], { config }); + const rb = positionalInflation(state, valuations, 'RB'); + + assert.equal(rb.demand, 4, 'two teams x two RB slots'); + assert.ok(rb.rate > 0); +}); + +test('budget pressure ranks teams closest to forced $1 bids', () => { + const state = reduce([ + register('me'), register('you'), + sold('RB A', 'RB', 'you', 97, 'e1'), + ], { config }); + + const pressure = budgetPressure(state); + assert.equal(pressure[0].teamId, 'you'); + assert.ok(pressure[0].locked > pressure[1].locked); +}); + +test('analytics tolerate an empty valuation set', () => { + const state = reduce([register('me'), nominate('Whoever', 'RB', 5)], { config }); + + assert.doesNotThrow(() => inflation(state, [])); + assert.doesNotThrow(() => scarcity(state, [])); + assert.equal(bidAdvice(state, []).verdict, 'unvalued'); +}); diff --git a/fantasy-auction-tracker/test/dom-adapter.test.js b/fantasy-auction-tracker/test/dom-adapter.test.js new file mode 100644 index 0000000..8a3f3de --- /dev/null +++ b/fantasy-auction-tracker/test/dom-adapter.test.js @@ -0,0 +1,337 @@ +/** + * DOM adapter tests, driven through a real DOM (linkedom). + * + * Scope note: these prove the adapter turns a page of the expected SHAPE into + * correct draft events, including the messy cases real rooms produce. They do + * not prove the CBS selectors match the live site -- nothing runnable offline + * can prove that. tools/selftest.js is what closes that gap, in the room. + */ + +import { test, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { parseHTML } from 'linkedom'; + +import { profileFor } from '../src/adapters/profiles.js'; +import { GenericDomAdapter } from '../src/adapters/generic-dom.js'; +import { parseMoney } from '../src/adapters/base.js'; +import { DraftStore } from '../src/core/store.js'; +import { EventType } from '../src/core/events.js'; +import { DEFAULT_CONFIG, expandSlots } from '../src/core/config.js'; +import { playerKey } from '../src/core/players.js'; + +const here = dirname(fileURLToPath(import.meta.url)); +const roomHtml = readFileSync(join(here, '..', 'fixtures', 'cbs-draft-room.html'), 'utf8'); + +const cbs = profileFor('https://www.cbssports.com/fantasy/football/draft/'); + +const config = { + ...DEFAULT_CONFIG, + numTeams: 3, + budget: 200, + rosterSlots: expandSlots({ QB: 1, RB: 2, WR: 3, TE: 1, FLEX: 1, DST: 1, BN: 4 }), +}; + +let saved; + +/** Install a DOM into globals, since the adapter reads `document` directly. */ +function mountDom(html) { + const { window, document } = parseHTML(`${html}`); + saved = { + document: globalThis.document, + MutationObserver: globalThis.MutationObserver, + window: globalThis.window, + }; + globalThis.document = document; + globalThis.MutationObserver = window.MutationObserver; + globalThis.window = window; + return { window, document }; +} + +beforeEach(() => { mountDom(roomHtml); }); + +afterEach(() => { + if (!saved) return; + globalThis.document = saved.document; + globalThis.MutationObserver = saved.MutationObserver; + globalThis.window = saved.window; +}); + +/** Run one synchronous scan and collect what the adapter emitted. */ +function scanOnce(profile = cbs) { + const events = []; + const adapter = new GenericDomAdapter((e) => events.push(e), { profile }); + adapter.scan(); + return { events, adapter }; +} + +const ofType = (events, type) => events.filter((e) => e.type === type); + +// --- profile resolution ----------------------------------------------------- + +test('the CBS profile is selected for a cbssports.com URL', () => { + assert.equal(cbs.id, 'cbs'); + assert.equal(profileFor('https://www.espn.com/whatever'), null); +}); + +test('user overrides merge over the shipped selectors', () => { + const custom = profileFor('https://www.cbssports.com/x', { + selectors: { nomName: '.my-own-name' }, + }); + assert.equal(custom.selectors.nomName, '.my-own-name'); + assert.equal(custom.selectors.rowPrice, cbs.selectors.rowPrice, 'untouched keys survive'); +}); + +// --- nomination ------------------------------------------------------------- + +test('the live nomination is read, with position and NFL team', () => { + const { events } = scanOnce(); + const [nom] = ofType(events, EventType.NOMINATION); + + assert.ok(nom, 'a nomination was emitted'); + assert.equal(nom.payload.playerName, "Ja'Marr Chase"); + assert.equal(nom.payload.position, 'WR'); + assert.equal(nom.payload.nflTeam, 'CIN'); + assert.equal(nom.payload.openingBid, 47); + assert.equal(nom.source, 'dom'); +}); + +test('the current bid and high bidder are emitted as a BID', () => { + const { events } = scanOnce(); + const [bid] = ofType(events, EventType.BID); + + assert.equal(bid.payload.amount, 47); + assert.equal(bid.payload.teamId, 'Gridiron Gurus'); +}); + +test('a repeat scan does not re-emit an unchanged nomination', () => { + const events = []; + const adapter = new GenericDomAdapter((e) => events.push(e), { profile: cbs }); + adapter.scan(); + adapter.scan(); + adapter.scan(); + + assert.equal(ofType(events, EventType.NOMINATION).length, 1, 'nominated once, not three times'); + assert.equal(ofType(events, EventType.BID).length, 1); +}); + +test('a new player on the block produces a fresh nomination', () => { + const events = []; + const adapter = new GenericDomAdapter((e) => events.push(e), { profile: cbs }); + adapter.scan(); + + document.querySelector('.auction-nomination .player-name').textContent = 'Justin Jefferson'; + document.querySelector('.current-bid').textContent = '$12'; + adapter.scan(); + + const noms = ofType(events, EventType.NOMINATION); + assert.equal(noms.length, 2); + assert.equal(noms[1].payload.playerName, 'Justin Jefferson'); + assert.equal(noms[1].payload.openingBid, 12); +}); + +test('a rising bid on the same player emits BIDs, not nominations', () => { + const events = []; + const adapter = new GenericDomAdapter((e) => events.push(e), { profile: cbs }); + adapter.scan(); + + for (const [amount, team] of [[50, 'Team Chaos'], [55, 'Gridiron Gurus']]) { + document.querySelector('.current-bid').textContent = `$${amount}`; + document.querySelector('.high-bidder').textContent = team; + adapter.scan(); + } + + assert.equal(ofType(events, EventType.NOMINATION).length, 1); + const bids = ofType(events, EventType.BID); + assert.deepEqual(bids.map((b) => b.payload.amount), [47, 50, 55]); + assert.equal(bids.at(-1).payload.teamId, 'Gridiron Gurus'); +}); + +test('an empty nomination area emits nothing rather than throwing', () => { + document.querySelector('.auction-nomination').remove(); + const { events } = scanOnce(); + assert.equal(ofType(events, EventType.NOMINATION).length, 0); +}); + +// --- results ---------------------------------------------------------------- + +test('completed sales are read from the results table', () => { + const { events } = scanOnce(); + const sales = ofType(events, EventType.SOLD); + + assert.equal(sales.length, 4); + const byName = Object.fromEntries(sales.map((s) => [s.payload.playerName, s.payload])); + + assert.equal(byName['Bijan Robinson'].price, 58); + assert.equal(byName['Bijan Robinson'].teamId, 'Team Chaos'); + assert.equal(byName['Bijan Robinson'].position, 'RB'); +}); + +test('prices parse whether or not they carry a $ or stray whitespace', () => { + const { events } = scanOnce(); + const byName = Object.fromEntries( + ofType(events, EventType.SOLD).map((s) => [s.payload.playerName, s.payload.price]), + ); + + assert.equal(byName['Bijan Robinson'], 58, '"$58"'); + assert.equal(byName['Marvin Harrison Jr.'], 44, 'bare "44"'); + assert.equal(byName['San Francisco 49ers'], 7, '" $7 " with padding'); +}); + +test('a header row parses to nothing and is skipped silently', () => { + const { events } = scanOnce(); + assert.ok(!ofType(events, EventType.SOLD).some((s) => /Player|Pos|Price/.test(s.payload.playerName))); +}); + +test('a sale with no position is still recorded but flagged for review', () => { + const { events } = scanOnce(); + const kraft = ofType(events, EventType.SOLD).find((s) => s.payload.playerName === 'Tucker Kraft'); + + assert.ok(kraft, 'the sale is not dropped -- a missing position is not a missing sale'); + assert.equal(kraft.payload.position, 'UNK'); + assert.ok(kraft.confidence < 0.8, `confidence ${kraft.confidence} falls below the review threshold`); +}); + +test('a fully-parsed sale carries DOM-tier confidence', () => { + const { events } = scanOnce(); + const bijan = ofType(events, EventType.SOLD).find((s) => s.payload.playerName === 'Bijan Robinson'); + assert.equal(bijan.confidence, 0.85); +}); + +test('already-seen sales are not re-emitted when the table re-renders', () => { + const events = []; + const adapter = new GenericDomAdapter((e) => events.push(e), { profile: cbs }); + adapter.scan(); + const first = ofType(events, EventType.SOLD).length; + adapter.scan(); + + assert.equal(ofType(events, EventType.SOLD).length, first, 'no duplicates on re-scan'); +}); + +test('a newly appended sale is picked up', () => { + const events = []; + const adapter = new GenericDomAdapter((e) => events.push(e), { profile: cbs }); + adapter.scan(); + + const tbody = document.querySelector('.draft-results tbody'); + const row = document.createElement('tr'); + row.className = 'results-row'; + row.innerHTML = 'Puka Nacua' + + 'WR' + + '$41' + + 'Gridiron Gurus'; + tbody.append(row); + adapter.scan(); + + const puka = ofType(events, EventType.SOLD).find((s) => s.payload.playerName === 'Puka Nacua'); + assert.ok(puka); + assert.equal(puka.payload.price, 41); +}); + +// --- teams ------------------------------------------------------------------ + +test('teams are registered from the budget panel', () => { + const { events } = scanOnce(); + const names = ofType(events, EventType.TEAM_REGISTERED).map((e) => e.payload.teamName); + assert.deepEqual(names, ['Team Chaos', 'Gridiron Gurus', 'Waiver Wire Warriors']); +}); + +// --- resilience ------------------------------------------------------------- + +test('a selector that matches nothing degrades instead of throwing', () => { + const broken = profileFor('https://www.cbssports.com/x', { + selectors: { nomination: '.does-not-exist', resultRow: '.also-missing', teamRow: '.nope' }, + }); + const { events } = scanOnce(broken); + assert.equal(events.length, 0, 'silence, not a crash'); +}); + +test('scan survives a selector that throws', () => { + const bad = profileFor('https://www.cbssports.com/x', { + selectors: { resultRow: ':::not-valid-css:::' }, + }); + const events = []; + const adapter = new GenericDomAdapter((e) => events.push(e), { profile: bad }); + assert.doesNotThrow(() => adapter.scan(), 'a bad selector must not kill the observer'); +}); + +test('parseMoney extracts the number from whatever wrapping text', () => { + assert.equal(parseMoney('$47'), 47); + assert.equal(parseMoney('Sold for 58'), 58); + assert.equal(parseMoney('$1,200'), 1200); + assert.equal(parseMoney('—'), null); + assert.equal(parseMoney(null), null); +}); + +// --- end to end ------------------------------------------------------------- + +test('a scan drives the store to correct league state', () => { + const store = new DraftStore({ config }); + const adapter = new GenericDomAdapter((e) => store.append(e), { profile: cbs }); + adapter.scan(); + + const state = store.state; + + // Three of the four sales are confident enough to apply; the position-less + // one waits for confirmation rather than silently landing on a roster. + assert.equal(state.history.length, 3); + assert.equal(state.needsReview.length, 1); + assert.equal(state.needsReview[0].payload.playerName, 'Tucker Kraft'); + + const chaos = state.teams.get('Team Chaos'); + assert.equal(chaos.spent, 65, '$58 + $7'); + assert.equal(chaos.roster.length, 2); + + assert.ok(state.drafted.has(playerKey('Bijan Robinson', 'RB'))); + // The D/ST alias normalizes, so the defense is matchable against a CSV. + assert.ok(state.drafted.has(playerKey('San Francisco 49ers', 'DST'))); + // The suffix normalizes, so "Marvin Harrison" in a CSV still resolves. + assert.ok(state.drafted.has(playerKey('Marvin Harrison', 'WR'))); +}); + +test('confirming the flagged sale applies it', () => { + const store = new DraftStore({ config }); + const adapter = new GenericDomAdapter((e) => store.append(e), { profile: cbs }); + adapter.scan(); + + const pending = store.state.needsReview[0]; + store.correct(pending.id, { position: 'TE' }); + + assert.equal(store.state.history.length, 4); + assert.equal(store.state.needsReview.length, 0); + assert.ok(store.state.drafted.has(playerKey('Tucker Kraft', 'TE'))); +}); + +test('the observer wires up and fires on a real mutation', async () => { + const events = []; + const adapter = new GenericDomAdapter((e) => events.push(e), { profile: cbs, debounceMs: 5 }); + await adapter.start(); + const initial = events.length; + + document.querySelector('.auction-nomination .player-name').textContent = 'Breece Hall'; + document.querySelector('.auction-nomination .player-position').textContent = 'RB'; + + await new Promise((resolve) => setTimeout(resolve, 60)); + adapter.stop(); + + assert.ok(events.length > initial, 'the mutation produced new events'); + assert.ok( + ofType(events, EventType.NOMINATION).some((n) => n.payload.playerName === 'Breece Hall'), + 'the new nomination came through the observer, not a manual scan', + ); +}); + +test('stop() detaches the observer', async () => { + const events = []; + const adapter = new GenericDomAdapter((e) => events.push(e), { profile: cbs, debounceMs: 5 }); + await adapter.start(); + adapter.stop(); + const after = events.length; + + document.querySelector('.auction-nomination .player-name').textContent = 'Nobody Home'; + await new Promise((resolve) => setTimeout(resolve, 60)); + + assert.equal(events.length, after, 'no events after teardown'); +}); diff --git a/fantasy-auction-tracker/test/reducer.test.js b/fantasy-auction-tracker/test/reducer.test.js new file mode 100644 index 0000000..85b8638 --- /dev/null +++ b/fantasy-auction-tracker/test/reducer.test.js @@ -0,0 +1,235 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { EventType, makeEvent, validate } from '../src/core/events.js'; +import { reduce, teamSummary, allTeamSummaries } from '../src/core/reducer.js'; +import { DraftStore } from '../src/core/store.js'; +import { DEFAULT_CONFIG, expandSlots, startersByPosition } from '../src/core/config.js'; +import { normalizeName, playerKey, resolvePlayer, buildIndex } from '../src/core/players.js'; + +const config = { + ...DEFAULT_CONFIG, + numTeams: 4, + budget: 100, + rosterSlots: expandSlots({ QB: 1, RB: 2, WR: 2, FLEX: 1, BN: 2 }), +}; + +const sold = (name, pos, teamId, price, id) => + makeEvent(EventType.SOLD, { + playerName: name, position: pos, teamId, teamName: teamId, price, + }, { id, source: 'ws' }); + +test('sale updates budget, roster and drafted set', () => { + const state = reduce([sold('Bijan Robinson', 'RB', 'alpha', 55)], { config }); + const alpha = teamSummary(state.teams.get('alpha'), config); + + assert.equal(alpha.spent, 55); + assert.equal(alpha.remaining, 45); + assert.equal(alpha.roster.length, 1); + assert.ok(state.drafted.has(playerKey('Bijan Robinson', 'RB'))); +}); + +test('max bid reserves $1 for every other open slot', () => { + // 8 slots, $100 budget, nothing spent -> 7 slots must keep $1 each. + const state = reduce([makeEvent(EventType.TEAM_REGISTERED, { + teamId: 'alpha', teamName: 'alpha', + })], { config }); + const alpha = teamSummary(state.teams.get('alpha'), config); + + assert.equal(alpha.openSlots, 8); + assert.equal(alpha.maxBid, 93); +}); + +test('max bid is zero when the roster is full', () => { + const log = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'].map((n, i) => + sold(`Player ${n}`, i < 3 ? 'RB' : 'WR', 'alpha', 1, `e${i}`)); + const state = reduce(log, { config }); + const alpha = teamSummary(state.teams.get('alpha'), config); + + assert.equal(alpha.openSlots, 0); + assert.equal(alpha.maxBid, 0); +}); + +test('players fill dedicated slots before flex, and flex before bench', () => { + const state = reduce([ + sold('RB One', 'RB', 'alpha', 10, 'e1'), + sold('RB Two', 'RB', 'alpha', 10, 'e2'), + sold('RB Three', 'RB', 'alpha', 10, 'e3'), + ], { config }); + + const slots = state.teams.get('alpha').roster.map((r) => r.slot); + assert.deepEqual(slots, ['RB', 'RB', 'FLEX']); +}); + +test('a duplicate sale is ignored and warned about, not double-counted', () => { + const state = reduce([ + sold('Bijan Robinson', 'RB', 'alpha', 55, 'e1'), + sold('Bijan Robinson', 'RB', 'beta', 55, 'e2'), + ], { config }); + + assert.equal(state.drafted.get(playerKey('Bijan Robinson', 'RB')).teamId, 'alpha'); + assert.equal(state.warnings.length, 1); + assert.match(state.warnings[0].message, /duplicate/); +}); + +test('retraction removes a sale and restores the budget', () => { + const log = [ + sold('Bijan Robinson', 'RB', 'alpha', 55, 'e1'), + makeEvent(EventType.RETRACTION, { targetId: 'e1' }, { id: 'e2' }), + ]; + const state = reduce(log, { config }); + + assert.equal(state.drafted.size, 0); + assert.equal(teamSummary(state.teams.get('alpha') ?? { + spent: 0, roster: [], slots: config.rosterSlots.map((t) => ({ type: t, filled: null })), + }, config).spent, 0); +}); + +test('correction patches an earlier payload in place', () => { + const log = [ + sold('Bijan Robinson', 'RB', 'alpha', 5, 'e1'), + makeEvent(EventType.CORRECTION, { targetId: 'e1', patch: { price: 55 } }, { id: 'e2' }), + ]; + const state = reduce(log, { config }); + assert.equal(state.teams.get('alpha').spent, 55); +}); + +test('low-confidence events are quarantined rather than applied', () => { + const shaky = makeEvent(EventType.SOLD, { + playerName: 'Blurry Name', position: 'WR', teamId: 'alpha', price: 30, + }, { id: 'e1', source: 'ocr', confidence: 0.4 }); + + const state = reduce([shaky], { config }); + assert.equal(state.drafted.size, 0); + assert.equal(state.needsReview.length, 1); +}); + +test('confirming a quarantined event applies it', () => { + const shaky = makeEvent(EventType.SOLD, { + playerName: 'Blurry Name', position: 'WR', teamId: 'alpha', price: 30, + }, { id: 'e1', source: 'ocr', confidence: 0.4 }); + + const state = reduce([ + shaky, + makeEvent(EventType.CORRECTION, { targetId: 'e1', patch: {} }, { id: 'e2' }), + ], { config }); + + assert.equal(state.drafted.size, 1); + assert.equal(state.needsReview.length, 0); +}); + +test('bids never move the high bid backwards', () => { + const state = reduce([ + makeEvent(EventType.NOMINATION, { playerName: 'CeeDee Lamb', position: 'WR' }, { id: 'e1' }), + makeEvent(EventType.BID, { amount: 40, teamId: 'alpha' }, { id: 'e2' }), + makeEvent(EventType.BID, { amount: 12, teamId: 'beta' }, { id: 'e3' }), // stale frame + ], { config }); + + assert.equal(state.nomination.highBid, 40); + assert.equal(state.nomination.highBidder, 'alpha'); +}); + +test('a sale clears the matching nomination', () => { + const state = reduce([ + makeEvent(EventType.NOMINATION, { playerName: 'CeeDee Lamb', position: 'WR' }, { id: 'e1' }), + sold('CeeDee Lamb', 'WR', 'alpha', 52, 'e2'), + ], { config }); + + assert.equal(state.nomination, null); +}); + +test('reduce is deterministic and side-effect free', () => { + const log = [sold('A B', 'RB', 'alpha', 10, 'e1'), sold('C D', 'WR', 'beta', 20, 'e2')]; + const a = JSON.stringify(allTeamSummaries(reduce(log, { config }))); + const b = JSON.stringify(allTeamSummaries(reduce(log, { config }))); + assert.equal(a, b); +}); + +test('store rejects malformed events instead of logging them', () => { + const store = new DraftStore({ config }); + const bad = makeEvent(EventType.SOLD, { + playerName: 'No Price', position: 'RB', teamId: 'alpha', + }); + + const result = store.append(bad); + assert.equal(result.ok, false); + assert.equal(store.log.length, 0); + assert.match(result.problems[0], /price/); +}); + +test('store suppresses a repeated sale from a re-rendering DOM', () => { + const store = new DraftStore({ config }); + assert.equal(store.append(sold('Bijan Robinson', 'RB', 'alpha', 55, 'e1')).ok, true); + assert.equal(store.append(sold('Bijan Robinson', 'RB', 'alpha', 55, 'e2')).ok, false); + assert.equal(store.log.length, 1); +}); + +test('store round-trips through serialize/deserialize', () => { + const store = new DraftStore({ config }); + store.append(sold('Bijan Robinson', 'RB', 'alpha', 55, 'e1')); + const restored = DraftStore.deserialize(store.serialize()); + + assert.equal(restored.log.length, 1); + assert.equal(restored.state.teams.get('alpha').spent, 55); +}); + +test('name normalization collapses suffixes, punctuation and inversion', () => { + assert.equal(normalizeName('Marvin Harrison Jr.'), 'marvin harrison'); + assert.equal(normalizeName('Harrison Jr., Marvin'), 'marvin harrison'); + assert.equal(normalizeName("Ja'Marr Chase"), 'jamarr chase'); + assert.equal(normalizeName('Amon-Ra St. Brown'), 'amon-ra st brown'); +}); + +test('player resolution falls back through name and last-name tiers', () => { + const index = buildIndex([ + { name: 'Marvin Harrison Jr.', position: 'WR', nflTeam: 'ARI', value: 30 }, + { name: 'Josh Allen', position: 'QB', nflTeam: 'BUF', value: 25 }, + ]); + + assert.equal( + resolvePlayer(index, { name: 'Marvin Harrison', position: 'WR' }).tier, + 'name+pos', + ); + assert.equal( + resolvePlayer(index, { name: 'Josh Allen', position: 'RB' }).confidence, + 0.9, + ); + assert.equal(resolvePlayer(index, { name: 'Nobody Here', position: 'TE' }), null); +}); + +test('starter counts split flex demand across eligible positions', () => { + const starters = startersByPosition(config); + // 4 teams: 2 dedicated RB each = 8, plus a third of each team's FLEX. + assert.equal(starters.RB, 8 + (4 * (1 / 3))); + assert.equal(starters.QB, 4); +}); + +test('event validation catches every required field', () => { + assert.deepEqual(validate(makeEvent(EventType.SOLD, { + playerName: 'A B', position: 'RB', teamId: 'alpha', price: 10, + })), []); + + const problems = validate(makeEvent(EventType.SOLD, { price: -1 })); + assert.ok(problems.length >= 4); +}); + +test('a league config event does not erase your own team identity', () => { + // Draft rooms emit league settings that know nothing about which team is + // yours. A null in that payload must not clobber a value you set. + const state = reduce([ + makeEvent(EventType.LEAGUE_CONFIGURED, { + numTeams: 12, budget: 200, rosterSlots: config.rosterSlots, myTeamId: null, + }, { id: 'e1' }), + ], { config: { ...config, myTeamId: 'me' } }); + + assert.equal(state.config.myTeamId, 'me'); + assert.equal(state.config.numTeams, 12, 'real values still apply'); +}); + +test('a league config event can still change a setting to a real value', () => { + const state = reduce([ + makeEvent(EventType.LEAGUE_CONFIGURED, { myTeamId: 'someone-else' }, { id: 'e1' }), + ], { config: { ...config, myTeamId: 'me' } }); + + assert.equal(state.config.myTeamId, 'someone-else'); +}); diff --git a/fantasy-auction-tracker/test/replay.test.js b/fantasy-auction-tracker/test/replay.test.js new file mode 100644 index 0000000..f70c6af --- /dev/null +++ b/fantasy-auction-tracker/test/replay.test.js @@ -0,0 +1,105 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +import { replay } from '../tools/replay.js'; +import { loadValuations, rescaleToLeague } from '../src/core/valuations.js'; +import { allTeamSummaries, reduce } from '../src/core/reducer.js'; +import { inflation, scarcity, budgetPressure } from '../src/core/analytics.js'; +import { rosterSize } from '../src/core/config.js'; + +const here = dirname(fileURLToPath(import.meta.url)); +const fixtures = join(here, '..', 'fixtures'); + +const logData = JSON.parse(readFileSync(join(fixtures, 'sample-draft.json'), 'utf8')); +const { players } = loadValuations(readFileSync(join(fixtures, 'sample-values.csv'), 'utf8')); +const valuations = rescaleToLeague(players, logData.config); + +test('a full draft replays to a complete, consistent league', () => { + const { store } = replay(logData, valuations); + const state = store.state; + const teams = allTeamSummaries(state); + const size = rosterSize(state.config); + + assert.equal(teams.length, state.config.numTeams); + assert.equal(state.history.length, state.config.numTeams * size); + assert.ok(state.complete, 'DRAFT_COMPLETE was seen'); + + for (const t of teams) { + assert.equal(t.roster.length, size, `${t.teamName} filled every slot`); + assert.equal(t.openSlots, 0); + assert.equal(t.maxBid, 0); + assert.ok(t.spent <= state.config.budget, `${t.teamName} never overspent`); + assert.ok(t.remaining >= 0); + } +}); + +test('no player is drafted twice across a full draft', () => { + const { store } = replay(logData, valuations); + const keys = store.state.history.map((s) => s.playerKey); + assert.equal(new Set(keys).size, keys.length); + assert.equal(store.state.warnings.length, 0, 'a clean log produces no warnings'); +}); + +test('every dollar is accounted for', () => { + const { store } = replay(logData, valuations); + const spent = allTeamSummaries(store.state).reduce((s, t) => s + t.spent, 0); + const sales = store.state.history.reduce((s, r) => s + r.price, 0); + assert.equal(spent, sales); +}); + +test('replay is deterministic', () => { + const a = replay(logData, valuations).store.state.history.map((s) => `${s.playerKey}:${s.price}`); + const b = replay(logData, valuations).store.state.history.map((s) => `${s.playerKey}:${s.price}`); + assert.deepEqual(a, b); +}); + +test('replaying a prefix matches replaying the whole log then rewinding', () => { + // The reducer is pure, so a prefix of the log must give the same state as + // reducing that prefix directly. This is the property page-refresh recovery + // depends on. + const cut = 250; + const viaReplay = replay(logData, valuations, { until: cut }).store.state; + const viaReduce = reduce(logData.log.slice(0, cut), { config: logData.config }); + + assert.equal(viaReplay.history.length, viaReduce.history.length); + assert.deepEqual( + allTeamSummaries(viaReplay).map((t) => [t.teamId, t.spent, t.openSlots]).sort(), + allTeamSummaries(viaReduce).map((t) => [t.teamId, t.spent, t.openSlots]).sort(), + ); +}); + +test('mid-draft analytics stay in a sane range', () => { + const { store } = replay(logData, valuations, { until: 300 }); + const state = store.state; + const inf = inflation(state, valuations); + + assert.ok(inf.remainingMoney > 0 && inf.remainingMoney < state.config.numTeams * state.config.budget); + assert.ok(inf.openSpots > 0); + assert.ok(inf.discretionary > 0.2 && inf.discretionary < 3, `inflation ${inf.discretionary} is plausible`); + + for (const [pos, s] of Object.entries(scarcity(state, valuations))) { + assert.ok(s.startable >= 0, `${pos} startable is non-negative`); + assert.ok(s.available >= s.startable, `${pos} startable never exceeds available`); + } +}); + +test('budget pressure rises monotonically as the draft drains money', () => { + const early = budgetPressure(replay(logData, valuations, { until: 100 }).store.state); + const late = budgetPressure(replay(logData, valuations, { until: 450 }).store.state); + + const avg = (rows) => rows.reduce((s, t) => s + t.locked, 0) / rows.length; + assert.ok(avg(late) > avg(early), 'teams are more constrained later'); +}); + +test('the final state has no money left unspent beyond the $1 floor', () => { + const { store } = replay(logData, valuations); + // Every team filled every slot, so remaining money is pure surplus. The + // simulator spends to the cap, so this should be near zero; the assertion + // guards against the reducer double-counting or dropping a sale. + for (const t of allTeamSummaries(store.state)) { + assert.ok(t.remaining >= 0 && t.remaining <= store.state.config.budget); + } +}); diff --git a/fantasy-auction-tracker/test/selftest.test.js b/fantasy-auction-tracker/test/selftest.test.js new file mode 100644 index 0000000..2be82e5 --- /dev/null +++ b/fantasy-auction-tracker/test/selftest.test.js @@ -0,0 +1,204 @@ +/** + * Tests for the in-room self-test tool. + * + * This tool is what the user leans on to decide whether the tracker will work + * in their draft room, so a false "everything is fine" is worse than no tool at + * all. These check it reports OK on a matching page, FAIL on a broken one, and + * distinguishes "selector is wrong" from "nothing has happened yet". + */ + +import { test, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { parseHTML } from 'linkedom'; + +const here = dirname(fileURLToPath(import.meta.url)); +const root = join(here, '..'); +const roomHtml = readFileSync(join(root, 'fixtures', 'cbs-draft-room.html'), 'utf8'); +const selfTestSource = readFileSync(join(root, 'tools', 'selftest.js'), 'utf8'); + +let saved; +let logs; + +function mount(html, href = 'https://www.cbssports.com/fantasy/football/draft/') { + const { window, document } = parseHTML(`${html}`); + saved = { + document: globalThis.document, + window: globalThis.window, + location: globalThis.location, + console: globalThis.console, + MutationObserver: globalThis.MutationObserver, + }; + + logs = []; + const record = (...args) => logs.push(args.map(String).join(' ')); + + globalThis.document = document; + globalThis.window = window; + globalThis.MutationObserver = window.MutationObserver; + globalThis.location = new URL(href); + globalThis.console = { + log: record, warn: record, group: record, groupEnd: () => {}, + table: (rows) => logs.push(JSON.stringify(rows)), + }; + + // Evaluate the tool exactly as a console paste would. + // eslint-disable-next-line no-new-func + new Function(selfTestSource)(); + return { window, document }; +} + +beforeEach(() => { mount(roomHtml); }); + +afterEach(() => { + if (!saved) return; + Object.assign(globalThis, saved); +}); + +const statusOf = (result, field) => result.rows.find((r) => r.field === field)?.status; + +test('the profile is detected from the CBS hostname', () => { + const result = globalThis.window.__auctionSelfTest(); + assert.ok(result, 'a CBS URL resolves a profile without being told which'); + assert.ok(logs.some((l) => /CBS Sports Fantasy/.test(l))); +}); + +test('an unknown host refuses rather than guessing', () => { + mount(roomHtml, 'https://example.com/'); + assert.equal(globalThis.window.__auctionSelfTest(), null); + assert.ok(logs.some((l) => /No profile for/.test(l))); +}); + +test('every selector resolves against a matching room', () => { + const result = globalThis.window.__auctionSelfTest(); + + for (const field of ['nomination', 'nomName', 'nomPosition', 'highBid', 'highBidder', + 'resultRow', 'rowPlayer', 'rowPrice', 'rowTeam', 'teamRow', 'teamName', 'teamBudget']) { + assert.equal(statusOf(result, field), 'OK', `${field} should resolve`); + } + assert.deepEqual(Object.keys(result.broken), []); + assert.ok(logs.some((l) => /every selector resolved/.test(l))); +}); + +test('it reports the sales it would emit, with prices parsed', () => { + const result = globalThis.window.__auctionSelfTest(); + + assert.equal(result.sales.length, 4); + const bijan = result.sales.find((s) => s.player === 'Bijan Robinson'); + assert.equal(bijan.price, 58); + assert.equal(bijan.team, 'Team Chaos'); +}); + +test('it warns about sales parsed without a position', () => { + globalThis.window.__auctionSelfTest(); + assert.ok( + logs.some((l) => /parsed without a position/.test(l)), + 'a position-less sale is called out, since those never auto-apply', + ); +}); + +test('it lists the teams it would register', () => { + const result = globalThis.window.__auctionSelfTest(); + assert.deepEqual( + result.teams.map((t) => t.team), + ['Team Chaos', 'Gridiron Gurus', 'Waiver Wire Warriors'], + ); + assert.equal(result.teams[0].budget, '$135'); +}); + +test('a wrong selector is reported as broken, not quietly ignored', () => { + const result = globalThis.window.__auctionSelfTest('cbs', { rowPrice: '.totally-wrong' }); + + // FAIL, not EMPTY: the results rows exist, so "wait for a sale" is no + // excuse -- the selector is genuinely wrong and must be called out as such. + assert.equal(statusOf(result, 'rowPrice'), 'FAIL'); + assert.ok('rowPrice' in result.broken); + assert.ok(logs.some((l) => /selector\(s\) need fixing/.test(l))); + assert.ok( + logs.some((l) => /without them no sale is ever recorded/.test(l)), + 'a broken price selector is flagged as critical, not cosmetic', + ); +}); + +test('invalid CSS is reported distinctly from a selector that simply misses', () => { + const result = globalThis.window.__auctionSelfTest('cbs', { teamRow: ':::nope:::' }); + assert.equal(statusOf(result, 'teamRow'), 'INVALID'); +}); + +test('a missing nomination marks its children NO-SCOPE, not FAIL', () => { + // Mid-draft lulls have no player on the block. Reporting nomName as broken + // then would send the user chasing a selector that is actually correct. + document.querySelector('.auction-nomination').remove(); + const result = globalThis.window.__auctionSelfTest(); + + assert.equal(statusOf(result, 'nomination'), 'EMPTY'); + assert.equal(statusOf(result, 'nomName'), 'NO-SCOPE'); + assert.ok(!('nomName' in result.broken), 'not counted as broken'); +}); + +test('an empty pre-draft room tells you what to do rather than failing', () => { + mount('
'); + const result = globalThis.window.__auctionSelfTest(); + + assert.equal(statusOf(result, 'nomination'), 'EMPTY'); + assert.equal(statusOf(result, 'resultRow'), 'EMPTY'); + assert.ok( + result.rows.find((r) => r.field === 'nomination').found.includes('nominate a player'), + 'says what action would populate it', + ); + assert.ok( + result.rows.find((r) => r.field === 'resultRow').found.includes('let a sale complete'), + ); +}); + +test('a completely foreign page reports the critical selectors broken', () => { + mount('
nothing familiar
'); + const result = globalThis.window.__auctionSelfTest('cbs'); + + assert.equal(result.sales.length, 0); + assert.ok(logs.some((l) => /need fixing/.test(l))); +}); + +test('it prints a pasteable selector override block', () => { + globalThis.window.__auctionSelfTest(); + const printed = logs.find((l) => l.trim().startsWith('{') && l.includes('"selectors"')); + assert.ok(printed, 'a JSON fragment is emitted'); + assert.equal(JSON.parse(printed).selectors.rowPrice, '.bid-amount, .price, .salary'); +}); + +test('the live watcher warns when nothing observable changes', async () => { + globalThis.window.__auctionSelfTestLive(0.05); + await new Promise((r) => setTimeout(r, 120)); + assert.ok( + logs.some((l) => /fewer than 2 distinct states/.test(l)), + 'a static page is called out as untrackable rather than passing silently', + ); +}); + +test('the live watcher confirms when bids do move', async () => { + globalThis.window.__auctionSelfTestLive(0.4); + for (const amount of [50, 55, 61]) { + document.querySelector('.current-bid').textContent = `$${amount}`; + await new Promise((r) => setTimeout(r, 30)); + } + await new Promise((r) => setTimeout(r, 450)); + + assert.ok( + logs.some((l) => /distinct bid states/.test(l)), + 'observable bid movement is reported as a pass', + ); +}); + +test('the self-test selectors match the shipped CBS profile', async () => { + // The tool is standalone (pasteable with no extension loaded), so its copy + // of the selectors can drift from the real profile. That drift would make it + // certify selectors the adapter does not actually use. + const { profileFor } = await import('../src/adapters/profiles.js'); + const shipped = profileFor('https://www.cbssports.com/x').selectors; + const result = globalThis.window.__auctionSelfTest(); + const used = JSON.parse(logs.find((l) => l.trim().startsWith('{') && l.includes('"selectors"'))).selectors; + + assert.deepEqual(used, shipped, 'tools/selftest.js has drifted from profiles.js'); +}); diff --git a/fantasy-auction-tracker/test/targets.test.js b/fantasy-auction-tracker/test/targets.test.js new file mode 100644 index 0000000..b267a8f --- /dev/null +++ b/fantasy-auction-tracker/test/targets.test.js @@ -0,0 +1,364 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { EventType, makeEvent } from '../src/core/events.js'; +import { reduce } from '../src/core/reducer.js'; +import { DEFAULT_CONFIG, expandSlots } from '../src/core/config.js'; +import { bidAdvice, snapshot } from '../src/core/analytics.js'; +import { + makeTarget, buildTargets, seedFromValuations, effectivePrice, + targetStatus, tierSummary, tierPressure, planFeasibility, findByName, +} from '../src/core/targets.js'; + +const config = { + ...DEFAULT_CONFIG, + numTeams: 2, + budget: 100, + minBid: 1, + myTeamId: 'me', + rosterSlots: expandSlots({ RB: 2, WR: 2 }), +}; + +const valuations = [ + { name: 'RB A', position: 'RB', value: 40 }, + { name: 'RB B', position: 'RB', value: 30 }, + { name: 'RB C', position: 'RB', value: 20 }, + { name: 'RB D', position: 'RB', value: 10 }, + { name: 'WR A', position: 'WR', value: 40 }, + { name: 'WR B', position: 'WR', value: 30 }, + { name: 'WR C', position: 'WR', value: 20 }, + { name: 'WR D', position: 'WR', value: 10 }, +]; + +const sold = (name, pos, teamId, price, id) => + makeEvent(EventType.SOLD, { + playerName: name, position: pos, teamId, teamName: teamId, price, + }, { id, source: 'ws' }); + +const register = (teamId) => + makeEvent(EventType.TEAM_REGISTERED, { teamId, teamName: teamId }, { id: `r-${teamId}` }); + +const nominate = (name, pos, bid) => makeEvent(EventType.NOMINATION, { + playerName: name, position: pos, openingBid: bid, +}, { id: `n-${name}` }); + +// --- construction ----------------------------------------------------------- + +test('a target normalizes its name, position and key', () => { + const t = makeTarget({ name: 'Marvin Harrison Jr.', position: 'wr', tier: 2, maxPrice: 35 }); + assert.equal(t.key, 'marvin harrison|WR'); + assert.equal(t.position, 'WR'); + assert.equal(t.tier, 2); + assert.equal(t.maxPrice, 35); +}); + +test('tier defaults to 1 and is clamped to a positive integer', () => { + assert.equal(makeTarget({ name: 'A B', position: 'RB' }).tier, 1); + assert.equal(makeTarget({ name: 'A B', position: 'RB', tier: 0 }).tier, 1); + assert.equal(makeTarget({ name: 'A B', position: 'RB', tier: 2.6 }).tier, 3); +}); + +test('a missing price stays null rather than becoming zero', () => { + assert.equal(makeTarget({ name: 'A B', position: 'RB' }).maxPrice, null); +}); + +test('building sorts by tier then price, and later edits replace earlier ones', () => { + const list = buildTargets([ + { name: 'Cheap Guy', position: 'RB', tier: 2, maxPrice: 5 }, + { name: 'RB A', position: 'RB', tier: 1, maxPrice: 20 }, + { name: 'RB A', position: 'RB', tier: 1, maxPrice: 45 }, // edited + { name: 'Pricey Guy', position: 'WR', tier: 1, maxPrice: 50 }, + ]); + + assert.deepEqual(list.map((t) => t.name), ['Pricey Guy', 'RB A', 'Cheap Guy']); + assert.equal(list.find((t) => t.name === 'RB A').maxPrice, 45, 'the later edit wins'); +}); + +test('seeding uses the CSV tier column when it exists', () => { + const withTiers = [ + { name: 'RB A', position: 'RB', value: 40, tier: 1 }, + { name: 'WR A', position: 'WR', value: 30, tier: 3 }, + ]; + const seeded = seedFromValuations(withTiers); + assert.equal(seeded.find((t) => t.name === 'RB A').tier, 1); + assert.equal(seeded.find((t) => t.name === 'WR A').tier, 3); +}); + +test('seeding bands by value when the CSV has no tiers', () => { + const seeded = seedFromValuations(valuations, { topN: 8, tierCount: 4 }); + assert.equal(seeded.length, 8); + assert.equal(new Set(seeded.map((t) => t.tier)).size, 4, 'four bands of two'); + assert.equal(seeded[0].tier, 1, 'the most valuable player lands in tier 1'); + assert.equal(seeded[0].maxPrice, 40, 'price target seeded from par value'); +}); + +test('seeding can be restricted to positions you care about', () => { + const seeded = seedFromValuations(valuations, { positions: ['RB'] }); + assert.ok(seeded.every((t) => t.position === 'RB')); +}); + +// --- pricing ---------------------------------------------------------------- + +test('an explicit price is used as written when auto-adjust is off', () => { + const t = makeTarget({ name: 'RB A', position: 'RB', maxPrice: 40 }); + assert.equal(effectivePrice(t, { inflation: 1.5, autoAdjust: false }), 40); +}); + +test('auto-adjust scales the price by market inflation', () => { + const t = makeTarget({ name: 'RB A', position: 'RB', maxPrice: 40 }); + assert.equal(effectivePrice(t, { inflation: 1.2, autoAdjust: true }), 48); + assert.equal(effectivePrice(t, { inflation: 0.5, autoAdjust: true }), 20); +}); + +test('a target with no price falls back to par value', () => { + const t = makeTarget({ name: 'RB A', position: 'RB' }); + assert.equal(effectivePrice(t, { parValue: 33 }), 33); + assert.equal(effectivePrice(t, { parValue: null }), null); +}); + +// --- status and tiers ------------------------------------------------------- + +test('target status tracks won, lost and open', () => { + const targets = buildTargets([ + { name: 'RB A', position: 'RB', tier: 1, maxPrice: 40 }, + { name: 'RB B', position: 'RB', tier: 1, maxPrice: 30 }, + { name: 'WR A', position: 'WR', tier: 2, maxPrice: 40 }, + ]); + const state = reduce([ + register('me'), register('you'), + sold('RB A', 'RB', 'me', 38, 'e1'), + sold('RB B', 'RB', 'you', 44, 'e2'), + ], { config }); + + const status = targetStatus(state, targets); + const byName = Object.fromEntries(status.map((t) => [t.name, t])); + + assert.equal(byName['RB A'].status, 'won'); + assert.equal(byName['RB A'].soldFor, 38); + assert.equal(byName['RB B'].status, 'lost'); + assert.equal(byName['RB B'].wonBy, 'you'); + assert.equal(byName['RB B'].overshoot, 14, 'went $14 past your target'); + assert.equal(byName['WR A'].status, 'open'); +}); + +test('tier summary rolls up counts and money per tier', () => { + const targets = buildTargets([ + { name: 'RB A', position: 'RB', tier: 1, maxPrice: 40 }, + { name: 'RB B', position: 'RB', tier: 1, maxPrice: 30 }, + { name: 'WR A', position: 'WR', tier: 2, maxPrice: 25 }, + ]); + const state = reduce([register('me'), register('you'), sold('RB A', 'RB', 'me', 38, 'e1')], { config }); + + const [tier1, tier2] = tierSummary(state, targets); + assert.equal(tier1.tier, 1); + assert.equal(tier1.total, 2); + assert.equal(tier1.won, 1); + assert.equal(tier1.open, 1); + assert.equal(tier1.spent, 38); + assert.equal(tier1.plannedCost, 30, 'only the still-open target counts as planned'); + assert.equal(tier2.open, 1); +}); + +test('tier pressure flags a tier that is nearly or fully gone', () => { + const targets = buildTargets([ + { name: 'RB A', position: 'RB', tier: 1, maxPrice: 40 }, + { name: 'RB B', position: 'RB', tier: 1, maxPrice: 30 }, + { name: 'WR A', position: 'WR', tier: 2, maxPrice: 25 }, + ]); + const state = reduce([ + register('me'), register('you'), + sold('RB A', 'RB', 'you', 38, 'e1'), + sold('WR A', 'WR', 'you', 25, 'e2'), + ], { config }); + + const [tier1, tier2] = tierPressure(state, targets); + assert.equal(tier1.depletion, 0.5); + assert.equal(tier1.critical, true, 'one name left is critical'); + assert.equal(tier2.exhausted, true); + assert.equal(tier2.critical, false, 'exhausted is not critical, it is over'); +}); + +// --- plan feasibility ------------------------------------------------------- + +test('an affordable plan reports headroom', () => { + // 4 slots, $100. Targets cost $60, leaving $40 of room. + const targets = buildTargets([ + { name: 'RB A', position: 'RB', tier: 1, maxPrice: 30 }, + { name: 'WR A', position: 'WR', tier: 1, maxPrice: 30 }, + ]); + const state = reduce([register('me')], { config }); + const plan = planFeasibility(state, targets, { valuations }); + + assert.equal(plan.feasible, true); + assert.equal(plan.plannedCost, 60); + assert.equal(plan.reserve, 2, '$1 each for the two slots the plan does not cover'); + assert.equal(plan.required, 62); + assert.equal(plan.headroom, 38); + assert.equal(plan.shortfall, 0); +}); + +test('an unaffordable plan reports the exact shortfall', () => { + const targets = buildTargets([ + { name: 'RB A', position: 'RB', tier: 1, maxPrice: 60 }, + { name: 'WR A', position: 'WR', tier: 1, maxPrice: 60 }, + ]); + const state = reduce([register('me')], { config }); + const plan = planFeasibility(state, targets, { valuations }); + + assert.equal(plan.feasible, false); + assert.equal(plan.required, 122, '$120 of targets plus $2 reserve'); + assert.equal(plan.shortfall, 22); +}); + +test('the plan only counts targets that fit a remaining roster slot', () => { + // Three RB targets but only two RB slots and no flex. + const targets = buildTargets([ + { name: 'RB A', position: 'RB', tier: 1, maxPrice: 20 }, + { name: 'RB B', position: 'RB', tier: 1, maxPrice: 20 }, + { name: 'RB C', position: 'RB', tier: 2, maxPrice: 20 }, + ]); + const state = reduce([register('me')], { config }); + const plan = planFeasibility(state, targets, { valuations }); + + assert.equal(plan.plan.length, 2); + assert.equal(plan.unslotted.length, 1); + assert.equal(plan.unslotted[0].name, 'RB C', 'the lowest tier is the one that does not fit'); + assert.equal(plan.plannedCost, 40); +}); + +test('the plan shrinks as slots fill and money is spent', () => { + const targets = buildTargets([ + { name: 'RB A', position: 'RB', tier: 1, maxPrice: 30 }, + { name: 'RB B', position: 'RB', tier: 1, maxPrice: 30 }, + { name: 'WR A', position: 'WR', tier: 1, maxPrice: 30 }, + ]); + const state = reduce([register('me'), sold('RB A', 'RB', 'me', 30, 'e1')], { config }); + const plan = planFeasibility(state, targets, { valuations }); + + assert.equal(plan.budget, 70, 'spent money is gone'); + assert.equal(plan.openSlots, 3); + assert.deepEqual(plan.plan.map((p) => p.name), ['RB B', 'WR A'], 'the won target drops out'); + assert.equal(plan.plannedCost, 60); +}); + +test('plan prices follow auto-adjust', () => { + const targets = buildTargets([{ name: 'RB A', position: 'RB', tier: 1, maxPrice: 30 }]); + const state = reduce([register('me')], { config }); + + const flat = planFeasibility(state, targets, { valuations, inflation: 1.5, autoAdjust: false }); + const scaled = planFeasibility(state, targets, { valuations, inflation: 1.5, autoAdjust: true }); + + assert.equal(flat.plannedCost, 30); + assert.equal(scaled.plannedCost, 45); +}); + +test('a full roster yields an empty but feasible plan', () => { + const targets = buildTargets([{ name: 'RB C', position: 'RB', tier: 1, maxPrice: 20 }]); + const state = reduce([ + register('me'), + sold('RB A', 'RB', 'me', 25, 'e1'), sold('RB B', 'RB', 'me', 25, 'e2'), + sold('WR A', 'WR', 'me', 25, 'e3'), sold('WR B', 'WR', 'me', 25, 'e4'), + ], { config }); + const plan = planFeasibility(state, targets, { valuations }); + + assert.equal(plan.openSlots, 0); + assert.equal(plan.plan.length, 0); + assert.equal(plan.feasible, true); +}); + +// --- integration with bid advice -------------------------------------------- + +test('a price target overrides the market-derived ceiling', () => { + const targets = buildTargets([{ name: 'RB A', position: 'RB', tier: 1, maxPrice: 25 }]); + const state = reduce([register('me'), register('you'), nominate('RB A', 'RB', 30)], { config }); + + const withTarget = bidAdvice(state, valuations, { targets, aggressiveness: 1 }); + const without = bidAdvice(state, valuations, { targets: [], aggressiveness: 1 }); + + assert.equal(withTarget.isTarget, true); + assert.equal(withTarget.targetTier, 1); + assert.equal(withTarget.ceiling, 25, 'your number, not the market cliff'); + assert.equal(withTarget.verdict, 'pass', '$30 is past your $25 target'); + assert.ok(without.ceiling > withTarget.ceiling, 'the cliff allowance is disabled by an explicit target'); +}); + +test('a target above the market read still advises bidding', () => { + const targets = buildTargets([{ name: 'RB A', position: 'RB', tier: 1, maxPrice: 55 }]); + const state = reduce([register('me'), register('you'), nominate('RB A', 'RB', 45)], { config }); + const a = bidAdvice(state, valuations, { targets }); + + assert.equal(a.verdict, 'bid'); + assert.equal(a.walkAway, 55); + assert.ok(a.targetVsMarket > 0, 'you value him above par and it is shown'); +}); + +test('a targeted player with no valuation is still actionable', () => { + const targets = buildTargets([{ name: 'Deep Sleeper', position: 'RB', tier: 3, maxPrice: 8 }]); + const state = reduce([register('me'), nominate('Deep Sleeper', 'RB', 3)], { config }); + const a = bidAdvice(state, valuations, { targets }); + + assert.notEqual(a.verdict, 'unvalued', 'your own target is a valuation'); + assert.equal(a.verdict, 'bid'); + assert.equal(a.ceiling, 8); +}); + +test('your max bid still caps an over-ambitious target', () => { + const targets = buildTargets([{ name: 'RB A', position: 'RB', tier: 1, maxPrice: 90 }]); + const state = reduce([ + register('me'), register('you'), + sold('WR A', 'WR', 'me', 97, 'e1'), + nominate('RB A', 'RB', 1), + ], { config }); + + const a = bidAdvice(state, valuations, { targets }); + assert.equal(a.myMaxBid, 1); + assert.equal(a.ceiling, 1, 'never advise a bid you cannot make, target or not'); +}); + +test('auto-adjust moves the target with the market', () => { + const targets = buildTargets([{ name: 'RB A', position: 'RB', tier: 1, maxPrice: 30 }]); + // Big overpays early push inflation down for what remains. + const state = reduce([ + register('me'), register('you'), + sold('WR A', 'WR', 'you', 60, 'e1'), + nominate('RB A', 'RB', 20), + ], { config }); + + const flat = bidAdvice(state, valuations, { targets, autoAdjustTargets: false }); + const scaled = bidAdvice(state, valuations, { targets, autoAdjustTargets: true }); + + assert.equal(flat.targetPrice, 30); + assert.notEqual(scaled.targetPrice, 30, 'the target tracks the market when asked to'); +}); + +test('snapshot exposes the board, tiers and plan for the sidebar', () => { + const targets = buildTargets([ + { name: 'RB A', position: 'RB', tier: 1, maxPrice: 30 }, + { name: 'WR A', position: 'WR', tier: 2, maxPrice: 20 }, + ]); + const state = reduce([register('me'), register('you'), sold('RB A', 'RB', 'you', 40, 'e1')], { config }); + const snap = snapshot(state, valuations, { targets }); + + assert.equal(snap.targetBoard.length, 2); + assert.equal(snap.targetBoard.find((t) => t.name === 'RB A').status, 'lost'); + assert.equal(snap.tiers.length, 2); + assert.ok(snap.plan.plannedCost > 0); + assert.equal(snap.autoAdjustTargets, false); +}); + +test('findByName tolerates suffix and punctuation differences', () => { + const targets = buildTargets([{ name: 'Marvin Harrison Jr.', position: 'WR', maxPrice: 30 }]); + assert.ok(findByName(targets, 'Marvin Harrison', 'WR')); + assert.equal(findByName(targets, 'Somebody Else', 'WR'), null); +}); + +test('an empty board changes nothing', () => { + const state = reduce([register('me'), nominate('RB A', 'RB', 10)], { config }); + const a = bidAdvice(state, valuations, { targets: [] }); + assert.equal(a.isTarget, false); + assert.equal(a.targetPrice, null); + + const plan = planFeasibility(state, [], { valuations }); + assert.equal(plan.feasible, true); + assert.equal(plan.plannedCost, 0); +}); diff --git a/fantasy-auction-tracker/test/valuations.test.js b/fantasy-auction-tracker/test/valuations.test.js new file mode 100644 index 0000000..2e29f33 --- /dev/null +++ b/fantasy-auction-tracker/test/valuations.test.js @@ -0,0 +1,127 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { parseCsv, loadValuations, rescaleToLeague } from '../src/core/valuations.js'; +import { DEFAULT_CONFIG, expandSlots } from '../src/core/config.js'; + +test('csv parser handles quotes, embedded commas and CRLF', () => { + const rows = parseCsv('a,b\r\n"Smith, John",7\r\n"say ""hi""",8\r\n'); + assert.deepEqual(rows, [['a', 'b'], ['Smith, John', '7'], ['say "hi"', '8']]); +}); + +test('csv parser skips blank lines', () => { + assert.equal(parseCsv('a,b\n\n1,2\n\n').length, 2); +}); + +test('loads a FantasyPros-shaped export', () => { + const csv = [ + 'Rank,Player,Team,Position,Auction Value,Tier,Bye', + '1,Ja\'Marr Chase,CIN,WR1,$62,1,10', + '2,Bijan Robinson,ATL,RB1,"$58",1,5', + '3,Justin Jefferson,MIN,WR2,$55,1,6', + ].join('\n'); + + const { players, problems } = loadValuations(csv); + + assert.equal(players.length, 3); + assert.equal(problems.length, 0); + assert.deepEqual( + players[0], + { + name: "Ja'Marr Chase", + position: 'WR', + nflTeam: 'CIN', + value: 62, + hasValue: true, + rank: 1, + posRank: 1, + tier: 1, + projection: null, + bye: 10, + source: 'csv', + }, + ); + assert.equal(players[1].value, 58, 'quoted dollar amounts parse'); + assert.equal(players[1].posRank, 1, 'RB1 splits into position + positional rank'); +}); + +test('alternative header names are recognized', () => { + const { players } = loadValuations('Name,Pos,$\nJosh Allen,QB,40'); + assert.equal(players[0].name, 'Josh Allen'); + assert.equal(players[0].position, 'QB'); + assert.equal(players[0].value, 40); +}); + +test('defense position aliases normalize to DST', () => { + const { players } = loadValuations('Player,Position,Value\n49ers,D/ST,4\nRavens,DEF,3'); + assert.deepEqual(players.map((p) => p.position), ['DST', 'DST']); +}); + +test('missing values are reported rather than silently zeroed', () => { + const { players, problems } = loadValuations('Player,Position,Value\nA B,RB,\nC D,WR,10'); + assert.equal(players[0].value, 0); + assert.equal(players[0].hasValue, false); + assert.ok(problems.some((p) => /no auction value/.test(p))); +}); + +test('a CSV with no value column is flagged, not accepted quietly', () => { + const { problems } = loadValuations('Player,Position\nA B,RB'); + assert.ok(problems.some((p) => /auction-value column/.test(p))); +}); + +test('an empty CSV degrades to an empty set with a problem', () => { + const { players, problems } = loadValuations('Player,Position,Value'); + assert.equal(players.length, 0); + assert.equal(problems[0], 'CSV has no data rows'); +}); + +test('rescaling makes values sum to the league total money', () => { + const config = { + ...DEFAULT_CONFIG, + numTeams: 2, + budget: 100, + minBid: 1, + rosterSlots: expandSlots({ RB: 2, WR: 2 }), + }; + + // Values from a different-sized league: total $100, not $200. + const players = [ + { name: 'A B', position: 'RB', value: 40 }, + { name: 'C D', position: 'RB', value: 30 }, + { name: 'E F', position: 'WR', value: 20 }, + { name: 'G H', position: 'WR', value: 10 }, + ]; + + const scaled = rescaleToLeague(players, config); + const total = scaled.reduce((s, p) => s + p.value, 0); + + assert.ok(Math.abs(total - 200) < 0.5, `expected ~200, got ${total}`); + assert.ok(scaled.every((p) => p.value >= config.minBid)); + assert.equal(scaled[0].rawValue, 40, 'the original value is preserved'); + assert.ok(scaled[0].value > scaled[1].value, 'ordering is preserved'); +}); + +test('players beyond the rosterable pool are zeroed and marked', () => { + const config = { + ...DEFAULT_CONFIG, + numTeams: 1, + budget: 100, + rosterSlots: expandSlots({ RB: 2 }), + }; + const players = [ + { name: 'A B', position: 'RB', value: 50 }, + { name: 'C D', position: 'RB', value: 40 }, + { name: 'E F', position: 'RB', value: 1 }, + ]; + + const scaled = rescaleToLeague(players, config); + assert.equal(scaled[2].rosterable, false); + assert.equal(scaled[2].value, 0); + assert.equal(scaled[0].rosterable, true); +}); + +test('rescaling is a no-op when there is no surplus to distribute', () => { + const config = { ...DEFAULT_CONFIG, numTeams: 1, budget: 10, rosterSlots: ['RB'] }; + const players = [{ name: 'A B', position: 'RB', value: 0 }]; + assert.deepEqual(rescaleToLeague(players, config), players); +}); diff --git a/fantasy-auction-tracker/tools/calibrate.js b/fantasy-auction-tracker/tools/calibrate.js new file mode 100644 index 0000000..c3768ed --- /dev/null +++ b/fantasy-auction-tracker/tools/calibrate.js @@ -0,0 +1,137 @@ +/** + * Selector calibration helper. + * + * The site profiles in src/adapters/profiles.js are guesses. This finds the + * real selectors. Open your draft room (a MOCK draft -- never calibrate during + * the real thing), paste this whole file into the browser console, and run: + * + * __auctionCalibrate() // survey the page + * __auctionCalibrate('Bijan Robinson') // find where a known name lives + * __auctionWatch() // log DOM changes for 30s + * + * Copy the reported selectors into the profile for your platform, or paste + * them into the sidebar's Advanced panel. + */ + +(() => { + /** Shortest reasonably stable CSS path to a node. */ + function pathFor(node) { + const parts = []; + let el = node; + while (el && el.nodeType === 1 && parts.length < 5) { + let part = el.tagName.toLowerCase(); + const classes = [...el.classList] + // Skip hashed/utility classes -- they change on every deploy. + .filter((c) => !/^(css-|sc-|jsx-|_)/.test(c) && !/\d{4,}/.test(c)) + .slice(0, 2); + if (el.id && !/\d{4,}/.test(el.id)) { parts.unshift(`#${el.id}`); break; } + if (classes.length) part += `.${classes.join('.')}`; + parts.unshift(part); + el = el.parentElement; + } + return parts.join(' > '); + } + + function textNodes(root = document.body) { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + const out = []; + let node; + while ((node = walker.nextNode())) { + const text = node.textContent.trim(); + if (text && node.parentElement) out.push({ text, el: node.parentElement }); + } + return out; + } + + window.__auctionCalibrate = (knownName = null) => { + const nodes = textNodes(); + + const report = (label, matches) => { + const grouped = new Map(); + for (const m of matches) { + const path = pathFor(m.el); + if (!grouped.has(path)) grouped.set(path, []); + grouped.get(path).push(m.text); + } + const rows = [...grouped.entries()] + .sort((a, b) => b[1].length - a[1].length) + .slice(0, 5) + .map(([selector, samples]) => ({ + selector, + count: samples.length, + samples: samples.slice(0, 3).join(' | '), + })); + console.group(`%c${label} (${matches.length} candidates)`, 'font-weight:bold'); + if (rows.length) console.table(rows); else console.log('none found'); + console.groupEnd(); + return rows[0]?.selector ?? null; + }; + + const found = {}; + + if (knownName) { + found.player = report( + `nodes containing "${knownName}"`, + nodes.filter((n) => n.text.includes(knownName)), + ); + } + + found.playerName = report( + 'player-name shaped text ("First Last")', + nodes.filter((n) => /^[A-Z][a-z'’.-]+ [A-Z][a-zA-Z'’.-]+/.test(n.text) && n.text.length < 40), + ); + + found.position = report( + 'position labels (QB/RB/WR/TE/K/DST)', + nodes.filter((n) => /^(QB|RB|WR|TE|K|DEF|DST|D\/ST)$/i.test(n.text)), + ); + + found.money = report( + 'dollar amounts', + nodes.filter((n) => /^\$\s?\d{1,3}$/.test(n.text)), + ); + + console.group('%cWebSocket / fetch endpoints seen', 'font-weight:bold'); + console.log('Reload the page with the extension installed, then use'); + console.log(' Setup -> Export WS capture'); + console.log('to get the real frames. That beats every selector below.'); + console.groupEnd(); + + console.log('%cSuggested profile fragment:', 'font-weight:bold'); + console.log(JSON.stringify({ + selectors: { + nomName: found.playerName, + nomPosition: found.position, + highBid: found.money, + }, + }, null, 2)); + + return found; + }; + + window.__auctionWatch = (seconds = 30) => { + const seen = new Map(); + const observer = new MutationObserver((records) => { + for (const r of records) { + const target = r.target.nodeType === 1 ? r.target : r.target.parentElement; + if (!target) continue; + const path = pathFor(target); + seen.set(path, (seen.get(path) ?? 0) + 1); + } + }); + observer.observe(document.body, { childList: true, subtree: true, characterData: true }); + + console.log(`watching for ${seconds}s -- run a few bids now`); + setTimeout(() => { + observer.disconnect(); + const rows = [...seen.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 15) + .map(([selector, changes]) => ({ selector, changes })); + console.log('%cMost-mutated nodes (the live bid area is usually near the top):', 'font-weight:bold'); + console.table(rows); + }, seconds * 1000); + }; + + console.log('calibration loaded: __auctionCalibrate() / __auctionWatch()'); +})(); diff --git a/fantasy-auction-tracker/tools/replay.js b/fantasy-auction-tracker/tools/replay.js new file mode 100644 index 0000000..ba93f07 --- /dev/null +++ b/fantasy-auction-tracker/tools/replay.js @@ -0,0 +1,170 @@ +#!/usr/bin/env node +/** + * Replay a recorded draft log through the real store and analytics. + * + * This is the rehearsal you cannot otherwise have. Point it at a log exported + * from the sidebar (or the synthetic fixture) and watch the same numbers the + * sidebar would have shown, sale by sale. + * + * node tools/replay.js fixtures/sample-draft.json fixtures/sample-values.csv + * node tools/replay.js fixtures/sample-draft.json fixtures/sample-values.csv --at 100 + */ + +import { readFileSync } from 'node:fs'; + +import { DraftStore } from '../src/core/store.js'; +import { EventType } from '../src/core/events.js'; +import { loadValuations, rescaleToLeague } from '../src/core/valuations.js'; +import { inflation, scarcity, budgetPressure, bidAdvice } from '../src/core/analytics.js'; +import { buildTargets, tierPressure, planFeasibility, targetStatus } from '../src/core/targets.js'; +import { allTeamSummaries } from '../src/core/reducer.js'; + +const money = (n) => `$${Math.round(n)}`; +const pad = (s, n) => String(s).padEnd(n).slice(0, n); +const padNum = (s, n) => String(s).padStart(n); + +export function replay(logData, valuations, { until = Infinity } = {}) { + const store = new DraftStore({ config: logData.config, log: [] }); + const timeline = []; + + for (const event of logData.log) { + if (store.log.length >= until) break; + // Bypass the dedup guard: a recorded log is already deduplicated, and + // re-checking it would drop legitimate repeat prices. + store.log.push(event); + + if (event.type === EventType.SOLD) { + store.recompute(); + timeline.push({ + index: timeline.length + 1, + sale: store.state.history.at(-1), + inflation: inflation(store.state, valuations).discretionary, + }); + } + } + store.recompute(); + return { store, timeline }; +} + +function main() { + const [logPath, csvPath] = process.argv.slice(2).filter((a) => !a.startsWith('--')); + const atFlag = process.argv.indexOf('--at'); + const until = atFlag !== -1 ? Number(process.argv[atFlag + 1]) : Infinity; + const targetsFlag = process.argv.indexOf('--targets'); + const targetsPath = targetsFlag !== -1 ? process.argv[targetsFlag + 1] : null; + const meFlag = process.argv.indexOf('--me'); + const myTeamId = meFlag !== -1 ? process.argv[meFlag + 1] : null; + + if (!logPath) { + console.error('usage: node tools/replay.js [values.csv] ' + + '[--at N] [--targets targets.json] [--me ]'); + process.exit(1); + } + + const logData = JSON.parse(readFileSync(logPath, 'utf8')); + if (myTeamId) logData.config = { ...logData.config, myTeamId }; + let valuations = []; + if (csvPath) { + const { players, problems } = loadValuations(readFileSync(csvPath, 'utf8')); + valuations = rescaleToLeague(players, logData.config); + if (problems.length) console.error(`csv notes: ${problems.join('; ')}`); + } + + const targets = targetsPath + ? buildTargets(JSON.parse(readFileSync(targetsPath, 'utf8'))) + : []; + + const { store, timeline } = replay(logData, valuations, { until }); + const state = store.state; + + console.log(`\nreplayed ${store.log.length} events -> ${state.history.length} sales\n`); + + console.log('last 10 sales'); + console.log(' ' + pad('player', 26) + pad('pos', 5) + pad('team', 10) + padNum('price', 6) + padNum('par', 6) + padNum('infl', 7)); + for (const row of timeline.slice(-10)) { + const val = valuations.find((p) => `${p.name}|${p.position}` === `${row.sale.name}|${row.sale.position}`); + console.log(' ' + + pad(row.sale.name, 26) + + pad(row.sale.position, 5) + + pad(row.sale.teamId, 10) + + padNum(money(row.sale.price), 6) + + padNum(val ? money(val.value) : '—', 6) + + padNum(row.inflation.toFixed(2), 7)); + } + + console.log('\nteams'); + console.log(' ' + pad('team', 12) + padNum('spent', 7) + padNum('left', 6) + padNum('slots', 6) + padNum('max', 6) + ' needs'); + for (const t of allTeamSummaries(state).sort((a, b) => b.maxBid - a.maxBid)) { + const needs = Object.entries(t.needs).filter(([s]) => s !== 'BN').map(([s, n]) => (n > 1 ? `${s}x${n}` : s)).join(' '); + console.log(' ' + + pad(t.teamName, 12) + + padNum(money(t.spent), 7) + + padNum(money(t.remaining), 6) + + padNum(t.openSlots, 6) + + padNum(money(t.maxBid), 6) + + ' ' + needs); + } + + if (valuations.length) { + const inf = inflation(state, valuations); + console.log(`\ninflation ${inf.discretionary.toFixed(2)} ` + + `(${money(inf.remainingMoney)} chasing ${money(inf.remainingValue)} over ${inf.openSpots} spots, ` + + `${money(inf.surplusSpent)} spent above par)`); + + console.log('\nscarcity'); + for (const [pos, s] of Object.entries(scarcity(state, valuations))) { + if (!s.available && !s.openStarterSlots) continue; + console.log(` ${pad(pos, 5)}${padNum(s.startable, 4)} startable / ${padNum(s.openStarterSlots, 3)} slots` + + ` ratio ${Number.isFinite(s.ratio) ? s.ratio.toFixed(2) : 'inf'}` + + ` best: ${s.topAvailable[0]?.name ?? '—'}`); + } + + const advice = bidAdvice(state, valuations); + if (advice) { + console.log(`\non the block: ${advice.player} (${advice.position}) at ${money(advice.currentBid)}` + + ` -> ${advice.verdict.toUpperCase()} walk-away ${money(advice.walkAway)}, ceiling ${money(advice.ceiling)}`); + } + } + + if (targets.length) { + const inf = inflation(state, valuations).discretionary; + const plan = planFeasibility(state, targets, { valuations, inflation: inf }); + + console.log('\ntarget board'); + for (const row of tierPressure(state, targets)) { + const flag = row.exhausted ? ' EXHAUSTED' : row.critical ? ' CRITICAL' : ''; + console.log(` tier ${row.tier}: ${padNum(row.open, 2)}/${row.total} open` + + ` won ${row.won} (${money(row.spent)}) lost ${row.lost}${flag}`); + } + + const missed = targetStatus(state, targets) + .filter((t) => t.status === 'lost' && t.overshoot != null) + .sort((a, b) => b.overshoot - a.overshoot) + .slice(0, 5); + if (missed.length) { + console.log(' biggest misses: ' + + missed.map((t) => `${t.name} (+${t.overshoot} over your ${money(t.maxPrice)})`).join(', ')); + } + + console.log(`\nplan: ${money(plan.plannedCost)} of targets for ${plan.plan.length} of ` + + `${plan.openSlots} open slots, ${money(plan.budget)} available -- ` + + (plan.feasible ? `${money(plan.headroom)} headroom` : `SHORT BY ${money(plan.shortfall)}`)); + if (plan.unslotted.length) { + console.log(` no slot for: ${plan.unslotted.map((t) => t.name).join(', ')}`); + } + } + + const pressure = budgetPressure(state).filter((t) => t.openSlots > 0).slice(0, 3); + if (pressure.length) { + console.log('\nmost budget-constrained: ' + + pressure.map((t) => `${t.teamName} (${Math.round(t.locked * 100)}%)`).join(', ')); + } + + if (state.warnings.length) { + console.log(`\n${state.warnings.length} warning(s):`); + for (const w of state.warnings.slice(0, 5)) console.log(` - ${w.message}`); + } + console.log(); +} + +if (import.meta.url === `file://${process.argv[1]}`) main(); diff --git a/fantasy-auction-tracker/tools/selftest.js b/fantasy-auction-tracker/tools/selftest.js new file mode 100644 index 0000000..ce3fe15 --- /dev/null +++ b/fantasy-auction-tracker/tools/selftest.js @@ -0,0 +1,300 @@ +/** + * In-room profile self-test. + * + * The selectors shipped in src/adapters/profiles.js are guesses. This tells you + * -- in about ten seconds, inside the actual draft room -- which ones resolve, + * which are wrong, and what the adapter would emit if the draft ran right now. + * + * Run it in a CBS **Salary Cap Mock Draft** (not your real league): + * + * 1. open the mock draft room + * 2. paste this whole file into the browser console + * 3. __auctionSelfTest() + * 4. nominate a player, put a bid on him, let one sale complete + * 5. __auctionSelfTest() again -- rows that were EMPTY should now be OK + * + * Anything still FAIL/EMPTY after a completed sale is a selector to fix. Feed + * the printed fragment back into the profile, or paste it into the sidebar's + * Advanced panel. + * + * This is read-only. It queries the page and prints; it never clicks, bids, or + * sends anything anywhere. + */ + +(() => { + // Kept in sync with src/adapters/profiles.js by hand -- this file has to be + // standalone so it can be pasted into a console with no extension loaded. + const PROFILES = { + cbs: { + label: 'CBS Sports Fantasy (Salary Cap)', + selectors: { + root: '.draft-room, #draftRoom, body', + nomination: '.auction-nomination, .nominated-player, .current-player', + nomName: '.player-name, .playerLink', + nomPosition: '.player-position, .position', + nomTeam: '.player-team, .proTeam', + highBid: '.current-bid, .high-bid, .bid-amount', + highBidder: '.high-bidder, .bidding-team', + resultRow: '.draft-results tr, .results-row', + rowPlayer: '.player-name', + rowPosition: '.player-position, .position', + rowPrice: '.bid-amount, .price, .salary', + rowTeam: '.team-name, .owner', + teamRow: '.team-budgets .team-row, .budget-row', + teamName: '.team-name', + teamBudget: '.budget-remaining, .remaining-salary', + }, + }, + nfl: { + label: 'NFL.com Fantasy', + selectors: { + root: '#draftBoard, .draftContainer, body', + nomination: '.currentPlayer, .nominatedPlayer, .auctionNomination', + nomName: '.playerName, .playerNameFull, a.playerName', + nomPosition: '.playerPosition, em', + nomTeam: '.playerTeam', + highBid: '.currentBid, .bidAmount, .auctionCurrentBid', + highBidder: '.highBidder, .currentBidder, .bidTeam', + resultRow: '#draftResults tr, .draftResultsTable tr', + rowPlayer: '.playerName, td.player a', + rowPosition: '.playerPosition, em', + rowPrice: '.auctionPrice, td.price', + rowTeam: '.teamName, td.team', + teamRow: '.auctionBudgets tr, .teamBudgetRow', + teamName: '.teamName', + teamBudget: '.budgetRemaining, .remaining', + }, + }, + }; + + /** Which selectors live inside the nomination container vs. the document. */ + const SCOPED = { + nomName: 'nomination', nomPosition: 'nomination', nomTeam: 'nomination', + highBid: 'nomination', highBidder: 'nomination', + rowPlayer: 'resultRow', rowPosition: 'resultRow', rowPrice: 'resultRow', rowTeam: 'resultRow', + teamName: 'teamRow', teamBudget: 'teamRow', + }; + + /** Selectors that cannot resolve until a draft action has happened. */ + const NEEDS_ACTION = new Set(['nomination', 'nomName', 'nomPosition', 'nomTeam', 'highBid', 'highBidder']); + const NEEDS_SALE = new Set(['resultRow', 'rowPlayer', 'rowPosition', 'rowPrice', 'rowTeam']); + + const clean = (node) => (node ? node.textContent.trim().replace(/\s+/g, ' ') : null); + const money = (text) => { + if (text == null) return null; + const m = /-?\d+(?:\.\d+)?/.exec(String(text).replace(/,/g, '')); + return m ? Number(m[0]) : null; + }; + + function detect() { + const host = location.hostname; + if (/cbssports\.com$/.test(host)) return 'cbs'; + if (/fantasy\.nfl\.com$/.test(host)) return 'nfl'; + return null; + } + + window.__auctionSelfTest = (profileId = detect(), overrides = {}) => { + if (!profileId || !PROFILES[profileId]) { + console.warn(`No profile for ${location.hostname}. Try __auctionSelfTest('cbs').`); + return null; + } + + const profile = PROFILES[profileId]; + const sel = { ...profile.selectors, ...overrides }; + console.log(`%cProfile self-test: ${profile.label}`, 'font-weight:bold;font-size:13px'); + console.log(`%c${location.href}`, 'color:#888'); + + // Every page query is guarded: an invalid selector must be reported, not + // thrown, or one bad override kills the whole report. + const queryAll = (scope, selector) => { + try { return { ok: true, nodes: [...scope.querySelectorAll(selector)] }; } + catch { return { ok: false, nodes: [] }; } + }; + + const nomRoot = sel.nomination ? queryAll(document, sel.nomination).nodes[0] ?? null : null; + // Scope row fields across EVERY matching row, not just the first. The first + // `tr` in a results table is usually the header, which resolves none of + // them -- scoping to it would report correct selectors as broken. + const rowRoots = sel.resultRow ? queryAll(document, sel.resultRow).nodes : []; + const teamRoots = sel.teamRow ? queryAll(document, sel.teamRow).nodes : []; + const scopeFor = { + nomination: nomRoot ? [nomRoot] : [], + resultRow: rowRoots, + teamRow: teamRoots, + }; + + const rows = []; + const broken = {}; + + for (const [field, selector] of Object.entries(sel)) { + if (!selector) { rows.push({ field, selector: '(unset)', status: 'SKIP', found: '' }); continue; } + + const scopeKey = SCOPED[field]; + + if (scopeKey) { + const scopes = scopeFor[scopeKey]; + // A scoped field whose container is missing is not itself broken -- it + // simply had nowhere to look. Reporting it as FAIL would send you + // chasing a selector that is actually fine. + if (!scopes.length) { + rows.push({ field, selector, status: 'NO-SCOPE', found: `needs ${scopeKey}` }); + continue; + } + + const matched = []; + let invalid = false; + for (const scope of scopes) { + const result = queryAll(scope, selector); + if (!result.ok) { invalid = true; break; } + matched.push(...result.nodes); + } + + if (invalid) { + rows.push({ field, selector, status: 'INVALID', found: 'not valid CSS' }); + broken[field] = selector; + } else if (!matched.length) { + // The container exists, so there is no "wait for the draft" excuse: + // this selector is genuinely wrong. + rows.push({ field, selector, status: 'FAIL', found: `no match in ${scopes.length} ${scopeKey}` }); + broken[field] = selector; + } else { + rows.push({ + field, selector, status: 'OK', count: matched.length, + found: matched.slice(0, 3).map(clean).filter(Boolean).join(' | ') || '(matched, no text)', + }); + } + continue; + } + + const result = queryAll(document, selector); + if (!result.ok) { + rows.push({ field, selector, status: 'INVALID', found: 'not valid CSS' }); + broken[field] = selector; + } else if (!result.nodes.length) { + const expected = NEEDS_ACTION.has(field) ? 'nominate a player' + : NEEDS_SALE.has(field) ? 'let a sale complete' : null; + rows.push({ field, selector, status: 'EMPTY', found: expected ? `(${expected}, then re-run)` : '' }); + if (!expected) broken[field] = selector; + } else { + rows.push({ + field, selector, status: 'OK', count: result.nodes.length, + found: result.nodes.slice(0, 3).map(clean).filter(Boolean).join(' | ') || '(matched, no text)', + }); + } + } + + console.table(rows); + + // --- what the adapter would actually emit -------------------------------- + console.group('%cEvents the adapter would emit right now', 'font-weight:bold'); + + if (nomRoot) { + const name = clean(nomRoot.querySelector(sel.nomName)); + const pos = clean(nomRoot.querySelector(sel.nomPosition)); + const bid = money(clean(nomRoot.querySelector(sel.highBid))); + const bidder = clean(nomRoot.querySelector(sel.highBidder)); + if (name && pos) { + console.log('NOMINATION', { playerName: name, position: pos, openingBid: bid, bidder }); + } else { + console.warn('nomination container found but name/position did not resolve', + { name, position: pos }); + } + } else { + console.log('no live nomination visible'); + } + + const sales = []; + for (const row of rowRoots) { + const name = clean(row.querySelector(sel.rowPlayer)); + const price = money(clean(row.querySelector(sel.rowPrice))); + const team = clean(row.querySelector(sel.rowTeam)); + const pos = clean(row.querySelector(sel.rowPosition)); + if (name && price != null && team) sales.push({ player: name, position: pos ?? 'UNK', price, team }); + } + if (sales.length) { + console.log(`SOLD x${sales.length}`); + console.table(sales.slice(0, 10)); + const noPos = sales.filter((s) => s.position === 'UNK').length; + if (noPos) { + console.warn(`${noPos} sale(s) parsed without a position -- these land in the review ` + + 'queue instead of applying. Fix rowPosition to clear that.'); + } + } else { + console.log('no completed sales parsed yet'); + } + + const teams = teamRoots + .map((r) => ({ team: clean(r.querySelector(sel.teamName)), budget: clean(r.querySelector(sel.teamBudget)) })) + .filter((t) => t.team); + if (teams.length) { console.log(`TEAM_REGISTERED x${teams.length}`); console.table(teams); } + console.groupEnd(); + + // --- verdict ------------------------------------------------------------- + const brokenFields = Object.keys(broken); + const critical = brokenFields.filter((f) => ['resultRow', 'rowPlayer', 'rowPrice', 'rowTeam'].includes(f)); + + if (!brokenFields.length) { + console.log('%c✓ every selector resolved. The DOM layer will track this room.', + 'color:#0a0;font-weight:bold'); + } else { + console.log(`%c✗ ${brokenFields.length} selector(s) need fixing: ${brokenFields.join(', ')}`, + 'color:#c00;font-weight:bold'); + if (critical.length) { + console.log('%cThese are the ones that matter -- without them no sale is ever recorded.', + 'color:#c00'); + } + console.log('Run __auctionCalibrate() (tools/calibrate.js) to find replacements.'); + } + + console.log('%cPaste into the sidebar Advanced panel to override:', 'font-weight:bold'); + console.log(JSON.stringify({ selectors: sel }, null, 2)); + + return { rows, broken, sales, teams }; + }; + + /** + * Watch a live nomination and report whether bid changes are actually + * observable -- a room that repaints via canvas or a shadow root will look + * fine on a static scan and then track nothing. + */ + window.__auctionSelfTestLive = (seconds = 45, profileId = detect()) => { + const sel = PROFILES[profileId]?.selectors; + if (!sel) return console.warn('no profile'); + + const seen = []; + const observer = new MutationObserver(() => { + const root = document.querySelector(sel.nomination); + if (!root) return; + const snap = [ + clean(root.querySelector(sel.nomName)), + money(clean(root.querySelector(sel.highBid))), + clean(root.querySelector(sel.highBidder)), + ].join('|'); + if (seen.at(-1) !== snap) seen.push(snap); + }); + observer.observe(document.body, { childList: true, subtree: true, characterData: true }); + + console.log(`%cWatching ${seconds}s -- nominate a player and run the bidding up.`, + 'font-weight:bold'); + setTimeout(() => { + observer.disconnect(); + if (seen.length < 2) { + console.warn('Saw fewer than 2 distinct states. Either nothing happened, or the ' + + 'draft room is not rendering through the DOM (shadow DOM / canvas). ' + + 'If the latter, the WebSocket layer is the only viable path -- ' + + 'export the capture from the sidebar.'); + } else { + console.log(`%c✓ observed ${seen.length} distinct bid states -- the DOM layer tracks live changes.`, + 'color:#0a0;font-weight:bold'); + } + console.table(seen.map((s, i) => { + const [player, bid, bidder] = s.split('|'); + return { step: i + 1, player, bid, bidder }; + })); + }, seconds * 1000); + }; + + console.log('%cself-test loaded.', 'font-weight:bold'); + console.log(' __auctionSelfTest() -- check every selector now'); + console.log(' __auctionSelfTestLive() -- verify live bid changes are observable'); +})(); diff --git a/fantasy-auction-tracker/tools/simulate.js b/fantasy-auction-tracker/tools/simulate.js new file mode 100644 index 0000000..f21a590 --- /dev/null +++ b/fantasy-auction-tracker/tools/simulate.js @@ -0,0 +1,179 @@ +#!/usr/bin/env node +/** + * Generate a synthetic auction draft. + * + * Draft day happens once and cannot be rehearsed, so the whole pipeline needs + * something to run against. This produces a full, internally-consistent draft + * (every team ends at exactly the roster limit and never overspends) plus the + * matching valuation CSV, which the replay test then asserts on. + * + * Deterministic: a fixed seed means the fixture is reproducible and diffs are + * meaningful. + * + * node tools/simulate.js > fixtures/sample-draft.json + */ + +import { EventType, makeEvent } from '../src/core/events.js'; +import { DEFAULT_CONFIG, expandSlots, slotAccepts } from '../src/core/config.js'; +import { rescaleToLeague } from '../src/core/valuations.js'; + +/** mulberry32 — small, seeded, good enough for fixtures. */ +function rng(seed) { + return () => { + seed |= 0; seed = (seed + 0x6D2B79F5) | 0; + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +const FIRST = ['Bijan', 'CeeDee', 'Justin', 'Amon-Ra', 'Marvin', 'Breece', 'Puka', 'Garrett', + 'Jahmyr', 'Nico', 'Malik', 'Brock', 'Kyren', 'Rome', 'Trey', 'Chris', 'Tank', 'Zay', + 'Drake', 'Jaxon', 'Tyrone', 'Deebo', 'Rashee', 'Jordan', 'Isiah', 'Quentin', 'Khalil', + 'Blake', 'Sam', 'Dalton', 'Cooper', 'Micah']; +const LAST = ['Robinson', 'Lamb', 'Jefferson', 'Brown', 'Harrison', 'Hall', 'Nacua', 'Wilson', + 'Gibbs', 'Collins', 'Nabers', 'Bowers', 'Williams', 'Odunze', 'McBride', 'Olave', 'Dell', + 'Flowers', 'London', 'Smith-Njigba', 'Tracy', 'Samuel', 'Rice', 'Addison', 'Pacheco', + 'Johnston', 'Shakir', 'Corum', 'LaPorta', 'Kincaid', 'Kupp', 'Parsons']; +const NFL = ['ATL', 'DAL', 'MIN', 'DET', 'ARI', 'NYJ', 'LAR', 'SEA', 'BUF', 'HOU', 'NYG', + 'LV', 'CHI', 'PHI', 'KC', 'NO', 'CIN', 'BAL', 'SF', 'TB']; + +const config = { + ...DEFAULT_CONFIG, + numTeams: 12, + budget: 200, + minBid: 1, + rosterSlots: expandSlots({ QB: 1, RB: 2, WR: 3, TE: 1, FLEX: 1, K: 1, DST: 1, BN: 6 }), +}; + +const rosterSize = config.rosterSlots.length; +const totalSpots = config.numTeams * rosterSize; + +// --- player pool ------------------------------------------------------------- +// Positional supply roughly mirrors a real player pool, and values follow a +// steep power curve so tier cliffs are real rather than uniform. +const POOL = { QB: 24, RB: 60, WR: 72, TE: 24, K: 14, DST: 14 }; +const POS_TOP = { QB: 30, RB: 62, WR: 60, TE: 34, K: 2, DST: 3 }; + +function buildPlayers(rand) { + const players = []; + for (const [pos, count] of Object.entries(POOL)) { + for (let i = 0; i < count; i += 1) { + const decay = Math.exp(-i / (count * 0.28)); + const noise = 0.9 + rand() * 0.2; + const value = Math.max(1, Math.round(POS_TOP[pos] * decay * noise)); + players.push({ + name: `${FIRST[Math.floor(rand() * FIRST.length)]} ${LAST[Math.floor(rand() * LAST.length)]} ${pos}${i + 1}`, + position: pos, + nflTeam: NFL[Math.floor(rand() * NFL.length)], + value, + }); + } + } + return players; +} + +// --- draft ------------------------------------------------------------------- +function simulate(seed = 20260817) { + const rand = rng(seed); + // Scale the pool so total par equals total league money. Without this the + // simulated teams pay par for early studs and run dry halfway through, + // producing a fixture that exercises only the broke end of the market. + const players = rescaleToLeague(buildPlayers(rand), config) + .filter((p) => p.value > 0 || !p.rosterable); + const log = []; + let eventId = 0; + const next = (type, payload, meta = {}) => + log.push(makeEvent(type, payload, { id: `e${++eventId}`, source: 'ws', ts: 1_700_000_000_000 + eventId * 1000, ...meta })); + + next(EventType.LEAGUE_CONFIGURED, config, { source: 'manual' }); + + const teams = Array.from({ length: config.numTeams }, (_, i) => ({ + teamId: `team-${i + 1}`, + teamName: `Team ${i + 1}`, + spent: 0, + slots: config.rosterSlots.map((type) => ({ type, filled: false })), + })); + for (const t of teams) next(EventType.TEAM_REGISTERED, { teamId: t.teamId, teamName: t.teamName }); + + const openSlots = (t) => t.slots.filter((s) => !s.filled).length; + const maxBid = (t) => { + const open = openSlots(t); + return open === 0 ? 0 : (config.budget - t.spent) - (open - 1) * config.minBid; + }; + const canTake = (t, pos) => t.slots.some((s) => !s.filled && slotAccepts(s.type, pos)); + + const available = [...players].sort((a, b) => b.value - a.value); + let sold = 0; + + while (sold < totalSpots && available.length) { + // Nomination: mostly the best player left, sometimes a random one -- real + // rooms mix value nominations with price enforcement. + const idx = rand() < 0.7 ? 0 : Math.floor(rand() * Math.min(20, available.length)); + const player = available.splice(idx, 1)[0]; + + const bidders = teams.filter((t) => canTake(t, player.position) && maxBid(t) >= config.minBid); + if (!bidders.length) continue; + + next(EventType.NOMINATION, { + playerName: player.name, + position: player.position, + nflTeam: player.nflTeam, + openingBid: config.minBid, + nominatingTeamId: bidders[Math.floor(rand() * bidders.length)].teamId, + }); + + // Price forms around par with noise, then gets clipped by what the field + // can actually afford -- this is what produces realistic late-draft + // bargains and the inflation swings the analytics are meant to catch. + const ceiling = Math.max(...bidders.map(maxBid)); + const wanted = Math.max(config.minBid, Math.round(player.value * (0.75 + rand() * 0.55))); + const price = Math.max(config.minBid, Math.min(wanted, ceiling)); + + const affordable = bidders.filter((t) => maxBid(t) >= price); + const winner = affordable[Math.floor(rand() * affordable.length)] ?? bidders[0]; + + // A couple of intermediate bids so the BID path gets exercised too. + for (let step = Math.max(1, Math.floor(price / 2)); step < price; step += Math.max(1, Math.floor(price / 3))) { + const other = bidders[Math.floor(rand() * bidders.length)]; + next(EventType.BID, { amount: step, teamId: other.teamId }); + } + + next(EventType.SOLD, { + playerName: player.name, + position: player.position, + nflTeam: player.nflTeam, + teamId: winner.teamId, + teamName: winner.teamName, + price, + }); + + const slot = winner.slots.find((s) => !s.filled && slotAccepts(s.type, player.position)); + slot.filled = true; + winner.spent += price; + sold += 1; + } + + next(EventType.DRAFT_COMPLETE, {}); + return { config, log, players }; +} + +function toCsv(players) { + const rows = [['Rank', 'Player', 'Team', 'Position', 'Auction Value']]; + [...players] + .sort((a, b) => b.value - a.value) + .forEach((p, i) => rows.push([i + 1, p.name, p.nflTeam, p.position, `$${p.value}`])); + return rows.map((r) => r.map((c) => (String(c).includes(',') ? `"${c}"` : c)).join(',')).join('\n'); +} + +export { simulate, toCsv, config as simConfig }; + +if (import.meta.url === `file://${process.argv[1]}`) { + const { config: cfg, log, players } = simulate(); + const which = process.argv[2] ?? 'draft'; + if (which === 'csv') { + process.stdout.write(toCsv(players)); + } else { + process.stdout.write(JSON.stringify({ version: 1, config: cfg, log }, null, 2)); + } +}