- 4 tasks: failing test, helper implementation, vaultLoop switch, smoke check - New helper fetchLiveVaultBalance(string $user): ?int in includes/utilities.php - vaultLoop prefers the live value, falls back to MAX(running_balance) - Per-request cache to avoid N HTTP calls - Test hook via $GLOBALS['liveBalanceTestHook'] for unit testing - Falls back gracefully on API failure
14 KiB
Live Vault Balance From /money Endpoint — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace MAX(running_balance) (which uses stale /log data) with the live /money endpoint's vault field for the balances shown in the right-side panel. Falls back to the SQL MAX when the API fails.
Architecture: New fetchLiveVaultBalance(string $user): ?int helper in includes/utilities.php. Hits https://api.torn.com/v2/user?selections=money with the user's API key. Returns the money.vault integer or null on failure. In-memory static cache so multiple calls per request reuse one fetch.
Tech Stack: PHP 8.1+, SQLite3 via PDO, cURL. No new dependencies.
Global Constraints
- PHP 8.1+ syntax only.
- API authentication:
Authorization: ApiKey <key>header. - Idempotency: every change must be safe to re-run.
- The
running_balancecolumn, the "Balance After" column inbuildTable, and thebackfillRunningBalanceshelper are unchanged. This plan only changes the displayed balance (the right-side panel), not the per-entry historical record. - Falls back gracefully when the API fails — the page must still render.
- Commits per task. Do not push.
- All existing helpers outside the rewritten ones stay byte-identical.
Task 1: Write the failing test for fetchLiveVaultBalance
Files:
- Create:
tests/live_balance_test.php
Interfaces (the test exercises these):
fetchLiveVaultBalance(string $user): ?int- On success, returns the integer
vaultamount from/money. - On API failure (network error, bad JSON, missing field), returns null.
- Caches the result within a single PHP request — multiple calls for the same user do not re-fetch.
- On success, returns the integer
The test does NOT need a live API call. It exercises the helper by either:
- Mocking at the
executeApiCallboundary (preferred — test swaps in a stub function), OR - Mocking the API response via a wrapper.
Choose whichever is cleanest. Since executeApiCall is a plain PHP function (not a class method), wrapping it for testing is awkward. Instead, design the helper to accept an optional override URL, OR refactor the helper to internally call a swappable transport function.
Recommended approach: Add a static class property or file-static variable $liveBalanceTestHook in includes/utilities.php that defaults to null. If set, the helper calls it instead of executeApiCall. The test sets the hook before calling the helper, then unsets it.
This is a 2-line addition to the helper and makes the test trivial. Don't add a sophisticated test harness.
- Step 1: Create the test file
Create tests/live_balance_test.php:
<?php
/**
* Test for fetchLiveVaultBalance() — the live vault balance helper.
*
* Uses a swappable test hook so we don't make real API calls.
*
* Run: php tests/live_balance_test.php
* Exit code 0 = pass.
*/
declare(strict_types=1);
if (!defined('USER_KEYS')) {
define('USER_KEYS', [
'zarathos' => 'test-key-not-used',
'symos' => 'test-key-not-used',
]);
}
require_once __DIR__ . '/../includes/exceptions.php';
require_once __DIR__ . '/../includes/utilities.php';
function fail(string $message): void {
fwrite(STDERR, "FAIL: $message\n");
exit(1);
}
function assertSame($expected, $actual, string $label): void {
if ($expected !== $actual) {
$exp = var_export($expected, true);
$act = var_export($actual, true);
fail("$label: expected $exp, got $act");
}
}
// Test hook: when set, fetchLiveVaultBalance calls this closure instead of
// executeApiCall. The closure receives ($url, $apiKey) and returns the
// decoded response array (or throws).
$GLOBALS['liveBalanceTestHook'] = null;
function liveBalanceHook($url, $apiKey) {
$hook = $GLOBALS['liveBalanceTestHook'] ?? null;
if ($hook === null) {
fail('Test hook not set — fetchLiveVaultBalance called executeApiCall');
}
return $hook($url, $apiKey);
}
// --- Test 1: success path returns the vault amount as int ---
$GLOBALS['liveBalanceTestHook'] = function ($url, $apiKey) {
assertSame('zarathos', $apiKey === 'test-key-not-used' ? 'zarathos' : 'symos', 'api key routed');
// (just verify the hook got called)
return ['money' => ['vault' => 160627669, 'wallet' => 4000]];
};
$balance = fetchLiveVaultBalance('zarathos');
assertSame(160627669, $balance, 'success returns vault amount');
// --- Test 2: failure path returns null ---
$GLOBALS['liveBalanceTestHook'] = function () {
throw new CurlErrorException('connection refused');
};
$balance = fetchLiveVaultBalance('zarathos');
assertSame(null, $balance, 'failure returns null');
// --- Test 3: missing money.vault returns null ---
$GLOBALS['liveBalanceTestHook'] = function () {
return ['error' => ['code' => 4]];
};
$balance = fetchLiveVaultBalance('zarathos');
assertSame(null, $balance, 'missing field returns null');
// --- Test 4: cache: second call does not invoke the hook twice ---
$invocations = 0;
$GLOBALS['liveBalanceTestHook'] = function () use (&$invocations) {
$invocations++;
return ['money' => ['vault' => 100]];
};
fetchLiveVaultBalance('symos');
fetchLiveVaultBalance('symos');
fetchLiveVaultBalance('symos');
assertSame(1, $invocations, 'cache prevents repeated fetches');
echo "OK: fetchLiveVaultBalance tests passed.\n";
The test sets $GLOBALS['liveBalanceTestHook'] and the helper must check this global and call it instead of executeApiCall. This is the swappable-transport mechanism described above.
- Step 2: Run the test and verify it fails
Run: php tests/live_balance_test.php
Expected: failure with Test hook not set (because the helper doesn't exist yet, and even if it did, the test hook mechanism isn't there yet).
- Step 3: Commit the failing test
git add tests/live_balance_test.php
git commit -m "Add failing test for fetchLiveVaultBalance"
Task 2: Implement fetchLiveVaultBalance with test hook
Files:
- Modify:
includes/utilities.php(append new function + a tiny test-hook shim)
Interfaces:
-
fetchLiveVaultBalance(string $user): ?int- Returns
inton success (themoney.vaultvalue). - Returns
nullon any failure (no exception is raised). - Per-request in-memory cache: subsequent calls for the same user return the cached value without hitting the API.
- Returns
-
Step 1: Add the test-hook shim
At the very top of includes/utilities.php, immediately after the require_once lines that pull in exceptions, add (only if the helper is being unit-tested):
// Test hook for fetchLiveVaultBalance(). When set, this closure is called
// instead of executeApiCall. Set $GLOBALS['liveBalanceTestHook'] to a
// closure($url, $apiKey): array in test code. Production code leaves it null.
$GLOBALS['liveBalanceTestHook'] = $GLOBALS['liveBalanceTestHook'] ?? null;
(If the file already requires things at the top, just place the line after the existing requires. The point is: the helper checks this global.)
- Step 2: Append the new function
Append this function to includes/utilities.php (after consoleLog):
/**
* Fetch the live vault balance for a user from the v2 /money endpoint.
*
* Returns the `money.vault` value as an int, or null if the API call
* fails, returns invalid JSON, or doesn't include `money.vault`. The
* result is cached for the duration of the PHP request so multiple
* callers (e.g., `vaultLoop` for one user, then the all-users sum)
* don't re-fetch.
*
* @param string $user The user whose vault balance to fetch.
*
* @return int|null The vault amount in pennies, or null on failure.
*/
function fetchLiveVaultBalance($user) {
static $cache = [];
if (!array_key_exists($user, $cache)) {
if (!array_key_exists($user, USER_KEYS)) {
$cache[$user] = null;
return null;
}
$url = 'https://api.torn.com/v2/user?selections=money';
try {
$hook = $GLOBALS['liveBalanceTestHook'] ?? null;
$responseData = $hook !== null
? $hook($url, USER_KEYS[$user])
: executeApiCall($url, USER_KEYS[$user]);
if (!isset($responseData['money']['vault'])) {
$cache[$user] = null;
return null;
}
$cache[$user] = (int)$responseData['money']['vault'];
} catch (Exception $e) {
$cache[$user] = null;
}
}
return $cache[$user];
}
- Step 3: Run the test and verify it passes
Run: php tests/live_balance_test.php
Expected: OK: fetchLiveVaultBalance tests passed.
- Step 4: Run the existing golden-file test to ensure no regression
Run: php tests/process_log_entries_test.php
Expected: OK: processLogEntries v2 golden-file test passed (100 entries).
- Step 5: Verify the file parses
Run: php -l includes/utilities.php
Expected: No syntax errors detected.
- Step 6: Commit
git add includes/utilities.php
git commit -m "Add fetchLiveVaultBalance helper using v2 /money endpoint"
Task 3: Update vaultLoop to use the live balance with fallback
Files:
- Modify:
includes/utilities.php(vaultLooponly)
Interfaces:
-
vaultLoop($name=null): returns the live vault balance (fromfetchLiveVaultBalance) when available; falls back toMAX(running_balance)when the helper returns null. Behavior contract unchanged: returns int, or 0 when nothing applies. -
Step 1: Replace
vaultLoop
In includes/utilities.php, replace the existing vaultLoop function (at the SQL-based implementation from the previous balance-fix plan) with:
function vaultLoop ($name=null) {
$liveBalance = $name === null
? null
: fetchLiveVaultBalance($name);
if ($liveBalance !== null) {
return $liveBalance;
}
// Fallback: derive from stored entries. Used when the live /money call
// fails or is not configured for the user.
$pdo = getDatabaseConnection();
if ($name === null) {
$stmt = $pdo->query(
'SELECT COALESCE(SUM(max_balance), 0) FROM '
. '(SELECT MAX(running_balance) AS max_balance FROM vault GROUP BY user)'
);
return (int)$stmt->fetchColumn();
}
$stmt = $pdo->prepare('SELECT MAX(running_balance) FROM vault WHERE user = :user');
$stmt->bindValue(':user', $name);
$stmt->execute();
return (int)$stmt->fetchColumn();
}
The all-users path ($name === null) skips the live helper entirely and goes straight to the SQL fallback — because the live helper is per-user; aggregating live values would require N HTTP calls, which we don't want on every page load. The all-users total displayed in the right-side panel will be the sum of per-user MAX(running_balance) fallback values — fine for the migration window and can be improved in a follow-up if it ever matters.
- Step 2: Run the existing test to ensure no regression
Run: php tests/process_log_entries_test.php
Expected: OK: processLogEntries v2 golden-file test passed (100 entries).
- Step 3: Run the new test to ensure it still passes
Run: php tests/live_balance_test.php
Expected: OK: fetchLiveVaultBalance tests passed.
- Step 4: Verify the file parses
Run: php -l includes/utilities.php
Expected: No syntax errors detected.
- Step 5: Commit
git add includes/utilities.php
git commit -m "vaultLoop: prefer live /money balance, fall back to MAX(running_balance)"
Task 4: End-to-end smoke check
Files: No code changes. Verification only.
- Step 1: Run the test suite
Run both tests:
cd "C:\Users\ksolo\Projects\Games\Torn\Torn Vault Tracker"
php tests/process_log_entries_test.php
php tests/live_balance_test.php
Expected: both print OK.
- Step 2: Verify the live DB state
cd "C:\Users\ksolo\Projects\Games\Torn\Torn Vault Tracker"
php -r "
\$db = new PDO('sqlite:data/vault.db');
foreach(\$db->query('SELECT user, COUNT(*) AS n, MAX(running_balance) AS b FROM vault GROUP BY user') as \$r) {
echo \$r['user'] . ': ' . \$r['n'] . ' rows, MAX(running_balance)=\$' . number_format(\$r['b']) . PHP_EOL;
}
"
Expected: per-user row counts unchanged; MAX(running_balance) values may match the new live balance or not depending on API state.
- Step 3: Probe the test instance
curl -s http://127.0.0.1:8001/ | grep -E 'class=\"(user|vault)\"|<h2>|<h3>'
Expected: the right-side "Zarathos balance:" and "Symos balance:" values should be the LIVE values from the API. They will match the /money endpoint's vault field for the appropriate API key. If the keys in config.php are swapped (a separate concern the user is aware of), the displayed values will be reversed from the user's actual balance — that is expected behavior pending the user's manual config edit.
- Step 4: Verify no v1 references remain
cd "C:\Users\ksolo\Projects\Games\Torn\Torn Vault Tracker"
grep -n "firstRun\|?key=" functions.php index.php includes/utilities.php includes/exceptions.php
Expected: no matches.
- Step 5: Commit if any incidental fixes were needed
If Step 1, 2, 3, or 4 produced any required fixes, commit them. Otherwise, no commit.
Self-Review Notes
Spec coverage:
| Requirement | Task |
|---|---|
Use live /money endpoint for displayed balances |
Task 2 (helper) + Task 3 (vaultLoop) |
| Fall back gracefully when API fails | Task 2 (try/catch) + Task 3 (fallback path) |
| Per-request cache to avoid N HTTP calls | Task 2 (static $cache) |
| Test the helper without making live API calls | Task 1 (test hook) |
| Existing tests still pass | Tasks 2 & 3 (re-run both tests after each) |
| End-to-end smoke check | Task 4 |
Placeholder scan: No "TODO", "TBD", "implement later".
Type consistency: fetchLiveVaultBalance(string $user): ?int returns int|null. vaultLoop consumes ?int and returns int. generateBalance/getSpace wrap vaultLoop and continue to work.
Risks addressed:
- API failure → null → fallback to MAX(running_balance) → page renders. No change in failure mode.
- Hook abuse → only affects test execution. In production,
$GLOBALS['liveBalanceTestHook']is null (set by the one-line shim) andexecuteApiCallruns. - All-users path uses SQL fallback, not live; document this in the function code so future readers understand.