Skip to content

feat: implement stat point allocation on level-up#141

Open
TheMichaelFeng wants to merge 4 commits into
fiorastudio:masterfrom
TheMichaelFeng:feat/stat-point-allocation
Open

feat: implement stat point allocation on level-up#141
TheMichaelFeng wants to merge 4 commits into
fiorastudio:masterfrom
TheMichaelFeng:feat/stat-point-allocation

Conversation

@TheMichaelFeng

Copy link
Copy Markdown

stat_points_available was already a column in the DB schema but was never wired up. This activates it end-to-end:

  • awardXp now grants 10 stat points per level gained
  • new buddy_allocate tool lets you spend points on a specific stat (DEBUGGING, PATIENCE, CHAOS, WISDOM, SNARK), capped at 100
  • buddy_status appends a notice when unspent points are available

stat_points_available was already a column in the DB schema but was
never wired up. This activates it end-to-end:

- awardXp now grants 10 stat points per level gained
- new buddy_allocate tool lets you spend points on a specific stat
  (DEBUGGING, PATIENCE, CHAOS, WISDOM, SNARK), capped at 100
- buddy_status appends a notice when unspent points are available
@fiorastudio

Copy link
Copy Markdown
Owner

thank you @TheMichaelFeng for the submission!

Per codex:

The allocation path is currently broken:
The schema uses stat_debugging, stat_patience, etc., but the handler reads/updates debugging, patience, etc. A valid allocation throws no such column: debugging.
Rescued/legacy companions have NULL persisted stat columns. Even after fixing the prefix, NULL + 1 remains NULL while the point is deducted. Initialize from loadCompanion(...).stats[stat] or use an appropriate fallback atomically.
points must be validated as a positive integer; the current number schema permits fractional and non-positive values.
Reload the companion and update the status file after awarding or allocating points; current refresh logic uses stale point data.
Please add tests covering fresh companions, rescued companions with null stat columns, stat caps, invalid point amounts, and multi-level gains.

TheMichaelFeng and others added 3 commits July 4, 2026 00:39
…, status refresh

Four bugs addressed per maintainer review:

1. Wrong column names: handler read/wrote `debugging` instead of `stat_debugging`.
   Fix: extract applyStatAllocation to lib/allocate.ts which uses the correct
   stat_ prefix throughout.

2. NULL arithmetic: rescued companions have NULL stat columns; SQL `NULL + n = NULL`
   silently wiped points. Fix: resolve current value via loadCompanion() (which
   applies the bones fallback), then write a concrete integer.

3. Non-positive/fractional points: schema used type:"number" allowing 1.5 or -3.
   Fix: schema uses type:"integer" + minimum:1; applyStatAllocation also validates
   and returns { ok:false, reason:'invalid_points' } for early rejection.

4. Status file stale after allocation: writeBuddyStatus() was never called.
   Fix: called immediately after the companion is reloaded post-update.

Adds 16 tests in src/__tests__/allocate.test.ts covering fresh companions,
rescued companions with NULL stat columns, stat caps, clamping, invalid point
amounts, and multi-level point gains.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ite atomic

Follow-up to the previous commit after a closer look:

- applyStatAllocation() now re-validates `stat` against STAT_NAMES itself
  instead of trusting the caller. It interpolates the stat name into a SQL
  column reference (stat_${stat.toLowerCase()}), so leaving that check only
  in the MCP handler meant any other future caller of this now-exported lib
  function could hand it an unvalidated string straight into SQL. Adds
  'invalid_stat' to AllocateResult.

- Wraps the SELECT -> compute -> UPDATE -> SELECT sequence in db.transaction(),
  matching the existing repairDuplicateCompanions convention in db/schema.ts.
  The maintainer's review asked for the NULL fallback to be applied
  "atomically"; the previous commit fixed the NULL bug but left the four
  statements unwrapped. The MCP server and the UserPromptSubmit hook both
  hold this DB file open, so this closes a real (if narrow) cross-process
  race window, not just a theoretical one.

- index.ts: removed the now-duplicated stat/points validation ahead of the
  call (applyStatAllocation does it internally) and replaced the two-if
  fallback with an exhaustive switch over all 5 AllocateResult reasons.

- Tests: added invalid-stat-name coverage, including a SQL-injection-shaped
  stat string, verified to be rejected without mutating the row or dropping
  the table. Also fixed a latent flakiness bug in the original test file —
  several tests picked an arbitrary stat and asserted success without
  pinning its starting value, which breaks deterministically for any userId
  whose rarity roll happens to land its peak stat at the 100 cap (this is
  exactly what "multilevel-jump" did on first run). Fresh/multi-level tests
  now pin the target stat before allocating; rescued-companion tests dynamically
  pick a stat with headroom instead, since pinning would defeat the point of
  testing the NULL-column fallback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…DIATE for allocation

Completes the "after awarding" half of the review's point 4 — the previous
commits only fixed the allocating half:

- awardXpAndRefresh built the companion by patching xp/level/mood onto the
  caller's pre-award row snapshot, so on the turn a level-up granted stat
  points, companion.availablePoints was stale (usually 0). observe and pet
  then wrote that stale value into the status file. Now re-reads the row
  after awarding so the returned companion — and the status file written
  from it — reflect the freshly granted points. Exported for testing (same
  precedent as recalcMood / self-healing.test.ts).

- applyStatAllocation now runs its transaction as BEGIN IMMEDIATE. With two
  processes on one WAL DB (MCP server + UserPromptSubmit hook), a deferred
  read-then-write transaction can fail its write upgrade with
  SQLITE_BUSY_SNAPSHOT instead of waiting on busy_timeout; taking the write
  lock up front avoids that. Also reworded the transaction comment, which
  referenced a function that only exists on the fix/hatch branch.

- Tests: two awardXpAndRefresh cases (multi-level jump grants
  levelsGained*10 fresh points; pre-existing unspent points survive a
  no-level-up award), plus an afterAll wipe so the xp_events rows these
  tests insert don't leak into later test files that clean up with a bare
  DELETE FROM companions (foreign_keys is ON; leaking broke doctor-insight,
  mode-orthogonality, and respawn-cleanup in the shared-DB suite run).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@TheMichaelFeng

Copy link
Copy Markdown
Author

Pushed a fix for all 4 points. Thanks for the detailed review. :-)

  1. Column prefix: handler was reading/writing debugging instead of stat_debugging. Fixed, and pulled the logic into lib/allocate.ts (applyStatAllocation) so it's independently testable.

  2. NULL stat columns: rescued companions have NULL there, so NULL + 1 was silently eating points. Now resolves the current value through loadCompanion()'s bones fallback and writes a concrete number instead of col = col + ?.

  3. points validation, schema is now type: "integer", minimum: 1, plus a runtime check, so fractional/negative/zero values are rejected before touching the DB.

  4. Stale status file after awarding/allocating, turned out this was two separate bugs. buddy_allocate now calls writeBuddyStatus() after the update. And on closer look, awardXpAndRefresh (shared by observe/pet) was also stale, it patched xp/level onto the pre-award row instead of re-reading, so on the exact turn a level-up granted points, the status file got written with the old point count. Fixed by re-reading the row after the award.

Also went back and hardened a couple things you didn't call out but seemed worth closing while I was in there: applyStatAllocation now validates stat against the known list itself (not just the MCP handler), since it interpolates that value into the UPDATE column list, and the read-check-write now runs as BEGIN IMMEDIATE so it's actually atomic across the MCP server + hook process, not just "probably fine because JS is single-threaded."

22 new tests in src/__tests__/allocate.test.ts, fresh companions, rescued/NULL columns, stat caps + clamping, invalid stat/point inputs (including a SQL-injection-shaped stat string), multi-level gains, and the awarding-side freshness bug. Full suite's green except for the same 9 pre-existing failures that are on master independent of this branch.

Let me know if anything's still off :)

@TheMichaelFeng

TheMichaelFeng commented Jul 3, 2026 via email

Copy link
Copy Markdown
Author

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants