// ISOLATION: can a NON-INDEXER peer read committed gamestate via sb.get()? This is the
// exact path the game uses (router.js state.get → sb.get). v2 showed the writer C reading
// its own room's c:score as null; this pins down WHY by testing two non-indexer readers
// side by side:
// R = PURE READER — never writes (the real "non-indexer player just observing state").
// W = WRITER-READER — writes its OWN key first (like v2's C: an optimistic writer), then
// tries to read A's committed key + its own.
//
// A = genesis admin = sole indexer (quorum 1) writes c:score. If R sees it but W doesn't,
// C's null was an OPTIMISTIC-WRITER view artifact, not a general non-indexer read gap →
// pure players read fine and rotation stands. If R ALSO reads null, there's a real
// non-indexer read-visibility gap (bigger than the promotion work).
//
// PASS =
// 1 A (sole indexer) wrote + reads c:score
// 2 R (pure reader, never wrote) reads c:score ← the decisive assertion
// 3 W becomes an admitted writer and reads its OWN write
// 4 W reads A's committed c:score ← reproduces/【dis】proves the C=null case
// 5 A reads W's write (round-trip)
import { StateBaseV3 } from '../lib/state-base-v3.js'
import createTestnet from 'hyperdht/testnet'
import crypto from 'hypercore-crypto'
import b4a from 'b4a'
import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'
const ROOT = path.join(os.tmpdir(), 'sbv3-read-' + Bare.pid)
try { fs.rmSync(ROOT, { recursive: true, force: true }) } catch {}
const hex = (b) => b4a.toString(b, 'hex')
const kpA = crypto.keyPair(); const kpR = crypto.keyPair(); const kpW = crypto.keyPair()
const members = [
{ key: hex(kpA.publicKey), role: 1 }, // genesis admin = sole indexer
{ key: hex(kpR.publicKey), role: 3 }, // pure reader (member)
{ key: hex(kpW.publicKey), role: 3 } // writer-reader (member)
]
let pass = 0; let fail = 0
const ok = (c, m) => { if (c) { pass++; console.log(' ok ' + m) } else { fail++; console.log(' FAIL ' + m) } }
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
const scoreOn = async (sb) => { try { const v = await sb.get('c:score'); return v && v.pts } catch { return 'ERR' } }
let A, R, W
const live = () => [A, R, W].filter(x => x && x.base && !x._closing)
let hb = 0
// ONLY A writes (drives finalization as sole indexer). R NEVER writes. W writes only when
// explicitly told (writeW flag). Everyone calls update() so views advance.
const settle = async (predicate, maxMs, writeW = false) => {
const deadline = Date.now() + maxMs
while (Date.now() < deadline) {
if (A.base.writable) { try { await A.set('__hbA__', hb++) } catch {} }
if (writeW && W.base.writable) { try { await W.set('__hbW__', hb++) } catch {} }
for (const x of live()) await x.base.update().catch(() => {})
await sleep(150)
if (await predicate()) return true
}
return await predicate()
}
let stage = 'init'
const watchdog = setTimeout(() => { console.log(' [WATCHDOG] stuck at stage=' + stage + ' — exiting to flush'); Bare.exit(3) }, 150000)
async function main () {
stage = 'testnet'
const testnet = await createTestnet(3)
const bootstrap = testnet.bootstrap
const roomSeed = crypto.randomBytes(32)
const opt = (kp) => ({ roomSeed, encryptionKey: roomSeed, network: true, swarmOpts: { bootstrap }, selfKeyPair: kp, selfKey: kp.publicKey, getMembers: async () => members, promoteRetryMs: 9999999 })
stage = 'A'; A = new StateBaseV3(path.join(ROOT, 'A'), crypto.randomBytes(32), opt(kpA))
await A.ready(); await A.projectMembers(members)
stage = 'R'; R = new StateBaseV3(path.join(ROOT, 'R'), A._roomKey, opt(kpR)); await R.ready()
stage = 'W'; W = new StateBaseV3(path.join(ROOT, 'W'), A._roomKey, opt(kpW)); await W.ready()
await settle(async () => (R.base.length || 0) > 0 && (W.base.length || 0) > 0, 45000)
// A (sole indexer) writes committed gamestate.
stage = 'A.write'; await A.set('c:score', { pts: 100 })
ok(await settle(async () => (await scoreOn(A)) === 100, 30000), '1. A (sole indexer) wrote + reads c:score')
// DECISIVE: R never wrote anything — can it read the committed value?
stage = 'R.read'; const rSees = await settle(async () => (await scoreOn(R)) === 100, 45000)
console.log(' [diag] c:score seen by A=%s R=%s W=%s', await scoreOn(A), await scoreOn(R), await scoreOn(W))
ok(rSees, '2. PURE READER R (never wrote) reads committed c:score=100')
// W writes its own key → becomes an optimistic/admitted writer (like v2's C).
stage = 'W.write'; await W.set('w:hello', { hi: true })
ok(await settle(async () => (await W.effectiveWritable()) === true, 40000, true), '3a. W admitted as a writer')
const wOwn = await (async () => { try { const v = await W.get('w:hello'); return v && v.hi === true } catch { return false } })()
ok(wOwn, '3b. W reads its OWN write')
// The C=null reproduction: can an optimistic WRITER read A's committed key?
stage = 'W.read'; const wSees = await settle(async () => (await scoreOn(W)) === 100, 45000, true)
console.log(' [diag] after W wrote: c:score A=%s R=%s W=%s', await scoreOn(A), await scoreOn(R), await scoreOn(W))
ok(wSees, '4. WRITER-READER W reads A\'s committed c:score=100 (if FAIL → optimistic-writer view artifact = the C=null cause)')
// Round-trip: A reads W's write.
stage = 'A.readW'; const aSeesW = await settle(async () => { try { const v = await A.get('w:hello'); return v && v.hi === true } catch { return false } }, 30000, true)
ok(aSeesW, '5. A reads W\'s write (round-trip)')
clearTimeout(watchdog)
console.log('\n RESULT: ' + pass + ' passed, ' + fail + ' failed')
Bare.exit(fail === 0 ? 0 : 1)
}
main().catch((e) => { console.log(' FATAL ' + (e && e.stack || e)); Bare.exit(2) })