diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index 85f7c6e7..5f65e99a 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -147,6 +147,9 @@ pub struct Shared { /// ui-ota: the folder the server serves the dashboard from (None = the files embedded in the binary) ui_dir: Mutex>, pub started: Instant, + /// first-block-21: the run's start on the wall clock (unix s), for the dashboard's "a card is for this run" rule; + /// `started` is monotonic and stops across a sleep, so the two are kept apart + pub started_unix: f64, engine_log: Mutex>, /// the app log's path: the one the uploader sends (main.rs names it; the engine must not guess the stamp) pub log_path: PathBuf, @@ -163,7 +166,12 @@ impl Shared { let settings_path = runtime.app_dir.join("settings.json"); let wallet_path = runtime.app_dir.join("wallet.json"); let ladder_path = runtime.app_dir.join("ladder.json"); - let ladder = crate::ladder::Ladder::load(&ladder_path); + // first-block-21: a block card is for the run that raised it, and the first-block flag is reconciled with + // the persisted lifetime count (a record from before the flag whose first block already came takes it) + let mut ladder = crate::ladder::Ladder::load(&ladder_path); + if ladder.start_run(settings.accepted_total) { + ladder.save(&ladder_path); + } let mut st = State { version: VERSION.into(), ..Default::default() }; st.network = runtime.network.clone(); st.chain = if runtime.network == "devnet" { "devnet v4".into() } else { format!("{} (private)", runtime.network) }; @@ -201,6 +209,7 @@ impl Shared { cmd_tx: Mutex::new(cmd_tx), ui_dir: Mutex::new(None), started: Instant::now(), + started_unix: crate::platform::unix_now_f(), engine_log: Mutex::new(engine_log), log_path, port: std::sync::atomic::AtomicU16::new(0), @@ -274,6 +283,7 @@ impl Shared { let mut st = self.state.lock().unwrap().clone(); st.now = crate::platform::unix_now_f(); st.uptime_s = self.started.elapsed().as_secs(); + st.started_at = self.started_unix; st.events = self.rings.lock().unwrap().events.iter().cloned().collect(); st.ladder = self.ladder.lock().unwrap().state(st.now, LADDER_GAP_S); match serde_json::to_value(st) { diff --git a/app/igneum-app/src/ladder.rs b/app/igneum-app/src/ladder.rs index 99486799..38e41400 100644 --- a/app/igneum-app/src/ladder.rs +++ b/app/igneum-app/src/ladder.rs @@ -106,6 +106,15 @@ pub struct Ladder { /// node-accepted blocks waiting for the miner's own ACCEPTED line (nonce -> hash, daa), and the reverse case #[serde(default)] pending: Vec<(String, String, u64)>, + /// first-block-21 (the project lead, 7 October 2026: "the 'you got your first block' situation only shows for the miner's + /// first ever block"): set the moment the first-block card is raised, when the lifetime count the engine + /// persists (settings.json accepted_total) crosses from 0 to 1. Kept across restarts, updates and a kept data + /// directory; never cleared by a key changing identities or a card being swapped (the count is the machine's, + /// not a key's or a card's). A count that starts over on a record carrying the flag (settings.json lost, the + /// ladder kept) raises no first card again; a record without the flag whose first block is older than this + /// version (count already 1 or more, or first_block_at set) takes the flag at `start_run`. + #[serde(default)] + pub first_block_shown: bool, } /// What the dashboard reads (api/state.ladder): the record above summarised at `now`. @@ -133,6 +142,8 @@ pub struct LadderState { pub milestone: Option, pub first_synced_at: f64, pub first_mining_at: f64, + /// first-block-21: the first-block card has been raised once on this machine (see `Ladder::first_block_shown`) + pub first_block_shown: bool, } pub fn day_of(unix: f64) -> u64 { @@ -152,6 +163,25 @@ impl Ladder { } } + /// The engine's run begins over this record (first-block-21, 7 October 2026). `accepted_total` is the lifetime + /// count settings.json holds. A block card is for the run that raised it: a milestone persisted by an earlier run + /// is dropped here, so a restart or an update never shows "your first block" (or block 100) a second time. A + /// record from before the flag existed whose first block already came (the count at 1 or more, or first_block_at + /// set) takes the flag now, since that card was shown by the version that raised it. Returns true when the + /// record changed and wants saving. + pub fn start_run(&mut self, accepted_total: u64) -> bool { + let mut dirty = false; + if self.milestone.is_some() { + self.milestone = None; + dirty = true; + } + if !self.first_block_shown && (accepted_total >= 1 || self.first_block_at > 0.0) { + self.first_block_shown = true; + dirty = true; + } + dirty + } + /// The node validated a block (its own log line): remembered by nonce until the miner's line claims it. pub fn on_node_accepted(&mut self, hash: &str, daa: u64, nonce: &str) { let nonce = norm_nonce(nonce); @@ -214,7 +244,13 @@ impl Ladder { } self.blocks.insert(0, BlockFound { hash: hash.clone(), daa, nonce, at: now, card: card.to_string(), card_name: card_name.to_string(), count: total }); self.blocks.truncate(KEEP_BLOCKS); - let kind = if total == 1 { "first" } else if total == 100 { "hundred" } else if total == 1_000 { "thousand" } else if total >= 10_000 && total % 10_000 == 0 { "ten_thousand" } else if new_card { "card" } else { "" }; + // the first-block card: once in the app's life, when the persisted lifetime count crosses from 0 to 1 and the + // flag is unset (first-block-21); a count that starts over on a record already shown raises nothing here + let first = total == 1 && !self.first_block_shown; + if first { + self.first_block_shown = true; + } + let kind = if first { "first" } else if total == 100 { "hundred" } else if total == 1_000 { "thousand" } else if total >= 10_000 && total % 10_000 == 0 { "ten_thousand" } else if new_card && total > 1 { "card" } else { "" }; if kind.is_empty() { return None; } @@ -308,6 +344,7 @@ impl Ladder { milestone: self.milestone.clone(), first_synced_at: self.first_synced_at, first_mining_at: self.first_mining_at, + first_block_shown: self.first_block_shown, } } } @@ -511,4 +548,104 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); assert_eq!(Ladder::load(&dir.join("missing.json")).votes_signed, 0); } + + // ---- first-block-21 (the project lead, 7 October 2026): the first-block card once in the app's life ---- + + /// The record as 0.3.20 wrote it after the first block: the milestone persisted, no flag field. + const RECORD_0320: &str = r#"{"first_block_at":1791140918.5,"first_block_card":"nvidia:0:RTX 4070","first_block_hash":"28abaa8c28ffb4c3eab96b5dc202f8328df7038dc7b2653c9e4b7dd6369ae5ce","first_block_daa":38637,"days":[{"day":20730,"blocks":1}],"cards_first":{"nvidia:0:RTX 4070":1791140918.5},"blocks":[{"hash":"28abaa8c28ffb4c3eab96b5dc202f8328df7038dc7b2653c9e4b7dd6369ae5ce","daa":38637,"nonce":"267dd27200a91c80","at":1791140918.5,"card":"nvidia:0:RTX 4070","card_name":"NVIDIA GeForce RTX 4070","count":1}],"votes_signed":2,"last_vote_index":8496,"last_vote_at":1791140950.0,"signed_open":[8496],"last_signed_locked":0,"signed_locked_count":0,"streak_since":1791140930.0,"shards":[],"milestone":{"kind":"first","count":1,"card":"nvidia:0:RTX 4070","card_name":"NVIDIA GeForce RTX 4070","hash":"28abaa8c28ffb4c3eab96b5dc202f8328df7038dc7b2653c9e4b7dd6369ae5ce","daa":38637,"at":1791140918.5},"first_synced_at":1791140000.0,"first_mining_at":1791140100.0,"pending":[]}"#; + + #[test] + fn known_failed_a_second_run_with_the_lifetime_count_at_1_showed_the_first_card_again() { + // the known-failed case: before this change a second run loaded the persisted milestone and the dashboard + // showed "your first block" again (every restart, every update, until the dismiss in that window's storage) + let mut l: Ladder = serde_json::from_str(RECORD_0320).unwrap(); + assert_eq!(l.milestone.as_ref().map(|m| m.kind.as_str()), Some("first"), "the fixture carries the persisted card"); + assert!(!l.first_block_shown, "the 0.3.20 record has no flag"); + assert!(l.start_run(1), "the record changes and wants saving"); + let s = l.state(1791150000.0, 600.0); + assert!(s.milestone.is_none(), "a card raised by an earlier run is never shown again"); + assert!(s.first_block_shown); + assert_eq!(s.first_block_at, 1791140918.5, "the ladder's first-block facts stay"); + } + + #[test] + fn the_first_ever_block_raises_the_card_once_and_sets_the_flag() { + let mut l = Ladder::default(); + assert!(!l.start_run(0), "a fresh record changes nothing at start"); + let m = l.on_block(100.0, "a", "RTX 4070", "0x1", 1).unwrap(); + assert_eq!(m.kind, "first"); + assert!(l.first_block_shown); + let s = l.state(101.0, 600.0); + assert!(s.first_block_shown); + assert_eq!(s.milestone.as_ref().unwrap().kind, "first"); + } + + #[test] + fn a_restart_with_the_count_at_1_shows_nothing_and_block_2_raises_no_card() { + let mut run1 = Ladder::default(); + run1.on_block(100.0, "a", "RTX 4070", "0x1", 1); + let dir = std::env::temp_dir().join(format!("igneum-ladder-first-{}", std::process::id())); + let path = dir.join("ladder.json"); + run1.save(&path); + let mut run2 = Ladder::load(&path); + assert!(run2.first_block_shown, "the flag survives the save"); + assert!(run2.start_run(1), "the persisted card is dropped"); + assert!(run2.state(200.0, 600.0).milestone.is_none()); + assert!(run2.on_block(300.0, "a", "RTX 4070", "0x2", 2).is_none(), "block 2 is an ordinary block"); + assert_eq!(run2.blocks[0].count, 2); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn an_update_with_the_count_at_1_takes_the_flag_from_the_older_record_shape() { + // the new version opens a 0.3.20 record: the flag is set from the count, the card dropped, and the flag + // survives the next save in the new shape + let mut l: Ladder = serde_json::from_str(RECORD_0320).unwrap(); + assert!(l.start_run(1)); + assert!(l.first_block_shown); + assert!(l.milestone.is_none()); + let mut back: Ladder = serde_json::from_str(&serde_json::to_string(&l).unwrap()).unwrap(); + assert!(back.first_block_shown); + assert!(back.on_block(1791150000.0, "nvidia:0:RTX 4070", "NVIDIA GeForce RTX 4070", "0x3", 2).is_none()); + // a 0.3.20 record whose count is still 0 but whose first_block_at is set (settings.json rewritten) is shown too + let mut l2: Ladder = serde_json::from_str(RECORD_0320).unwrap(); + assert!(l2.start_run(0)); + assert!(l2.first_block_shown); + } + + #[test] + fn a_kept_data_directory_with_the_count_at_0_and_the_flag_unset_shows_it_on_the_first_block() { + // a kept directory from a machine that synced and voted but never found a block + let mut l = Ladder::default(); + l.mark("synced", 10.0); + l.mark("mining", 20.0); + l.on_vote(30.0, 5, 600.0); + assert!(!l.start_run(0), "nothing to reconcile: no block yet"); + assert!(!l.first_block_shown); + let m = l.on_block(100.0, "a", "RTX 4070", "0x1", 1).unwrap(); + assert_eq!(m.kind, "first"); + assert!(l.first_block_shown); + } + + #[test] + fn a_count_that_starts_over_on_a_record_already_shown_raises_no_first_card() { + // settings.json lost (the count back at 0), the ladder kept with the flag: the next block is total 1 again + let mut l = Ladder::default(); + l.on_block(100.0, "a", "RTX 4070", "0x1", 1); + assert!(l.on_block(200.0, "a", "RTX 4070", "0x2", 1).is_none(), "no second first-block card"); + assert!(l.on_block(300.0, "b", "RTX 5090", "0x3", 1).is_none(), "nor a new-card card at a count of 1"); + assert!(l.first_block_shown); + assert_eq!(l.state(301.0, 600.0).blocks.len(), 3, "the blocks are still recorded"); + } + + #[test] + fn block_2_is_an_ordinary_block_with_no_card() { + let mut l = Ladder::default(); + l.on_block(100.0, "a", "RTX 4070", "0x1", 1); + assert!(l.on_block(130.0, "a", "RTX 4070", "0x2", 2).is_none()); + let s = l.state(131.0, 600.0); + assert_eq!(s.blocks[0].count, 2); + assert_eq!(s.blocks_today, 2); + assert_eq!(s.milestone.as_ref().unwrap().count, 1, "the first card of this run stays until the run ends"); + } } diff --git a/app/igneum-app/src/state.rs b/app/igneum-app/src/state.rs index 78956c8d..dafcce49 100644 --- a/app/igneum-app/src/state.rs +++ b/app/igneum-app/src/state.rs @@ -556,6 +556,8 @@ pub struct State { pub jobs: JobsState, pub events: Vec, pub uptime_s: u64, + /// first-block-21: when this engine run began (unix s, the wall clock; uptime_s is monotonic and stops across a sleep) + pub started_at: f64, pub now: f64, pub quitting: bool, pub live_page: String, diff --git a/app/igneum-app/ui/app.js b/app/igneum-app/ui/app.js index f3dd7f37..ce2ef54e 100644 --- a/app/igneum-app/ui/app.js +++ b/app/igneum-app/ui/app.js @@ -1046,8 +1046,9 @@ var View = (function () { function ladderRungs(s, chain, now) { s = s || {}; var m = s.mining || {}, ld = s.ladder || {}, pv = s.proving || {}, c = chainNums(chain), rungs = []; var best = c.best, inTable = !!best, blocks = inTable ? best.blocks : 0, found = m.accepted_total || 0; - // 0. first block - var first = found > 0 || blocks > 0; + // 0. first block (first-block-21: a machine whose first block came, on any run, never reads "waiting" again, + // whatever the lifetime count says after a settings reset) + var first = found > 0 || blocks > 0 || ld.first_block_at > 0 || !!ld.first_block_shown; rungs.push({ id: 'first', name: 'First block', state: first ? 'done' : 'now', line: first ? 'First block' + (ld.first_block_at > 0 && now ? ' ' + ago(now - ld.first_block_at) : '') + (ld.first_block_card ? ', ' + cardName(s, ld.first_block_card) : '') + '.' : 'Waiting for your first block.', sub: inTable ? withCommas(blocks) + ' block' + (blocks === 1 ? '' : 's') + ' with your key in the window' : (found ? withCommas(found) + ' found by this machine' : ''), field: inTable ? FIELDS.blocks : FIELDS.found, progress: -1 }); // 1. the vote var dust = c.dust, voter = inTable && best.voter; @@ -1074,8 +1075,25 @@ var View = (function () { function currentRung(rungs) { for (var i = 0; i < rungs.length; i++) if (rungs[i].state === 'now') return rungs[i]; for (var j = rungs.length - 1; j >= 0; j--) if (rungs[j].state === 'done') return rungs[j]; return rungs[0]; } // the block card: m = state.ladder.milestone, s = state, chain, now. {kind, eyebrow, title, when, cardLine, ignLine, // rankLine, hash, url, fields} + // the Poisson count-up card shows only while this machine waits for its first ever block (first-block-21): never + // once the ladder records one, whatever the lifetime count says after a settings reset + function firstWait(s) { + s = s || {}; var m = s.mining || {}, ld = s.ladder || {}; + return m.state === 'mining' && !(m.accepted_total > 0) && !(ld.first_block_at > 0) && !ld.first_block_shown; + } + // a card is for the run that raised it (first-block-21, the project lead, 7 October 2026: "the 'you got your first block' + // situation only shows for the miner's first ever block"). The engine drops a persisted milestone at start + // (ladder.rs start_run) and reports the run's start as started_at; against an older engine that still sends one, a + // milestone raised before this run began is refused here (a minute of slack; uptime_s stands in when the engine + // has no started_at). So a restart, an update or a reinstall over a kept state never shows the card twice. + function staleCard(m, s, now) { + if (!m || !(m.at > 0) || !s) return false; + var started = s.started_at > 0 ? s.started_at : (s.uptime_s >= 0 && now > 0 ? now - s.uptime_s : 0); + return started > 0 && m.at + 60 < started; + } function blockCard(m, s, chain, now) { if (!m || !(m.count > 0)) return null; + if (staleCard(m, s, now)) return null; var c = chainNums(chain), cards = (s.mining || {}).cards || [], cd = cards.filter(function (x) { return x.key === m.card; })[0] || {}; var name = m.card_name || cd.name || 'your card', mhs = cd.hash_avg > 0 ? cd.hash_avg : cd.hash_now || 0; var eyebrow = m.kind === 'first' ? 'your first block' : m.kind === 'hundred' ? 'block 100 of this machine' : m.kind === 'thousand' ? 'block 1,000 of this machine' : m.kind === 'ten_thousand' ? 'block ' + withCommas(m.count) + ' of this machine' : 'first block on ' + name; @@ -1174,7 +1192,7 @@ var View = (function () { } function addressAsk(a) { return 'Pay ' + shortHex(a, 6, 4) + ' from the next block? Mining restarts.'; } function trustAsk(on) { return on ? 'Include proofs the node never checked? Devnet only. The node restarts.' : 'Check proofs again? The node restarts.'; } - return { VENDORS: VENDORS, MARKS: MARKS, WELL_ALPHA_LIGHT: WELL_ALPHA_LIGHT, WELL_ALPHA_DARK: WELL_ALPHA_DARK, vendorOf: vendorOf, seriesLine: seriesLine, vendorMark: vendorMark, markHtml: markHtml, nameHtml: nameHtml, rowHead: rowHead, vendorContrast: vendorContrast, contrast: contrast, hexRgb: hexRgb, PAGES: PAGES, page: page, withCommas: withCommas, compact: compact, rel: rel, ago: ago, shortHex: shortHex, kindWord: kindWord, shownCards: shownCards, present: present, cardTitle: cardTitle, cardRow: cardRow, rowWord: rowWord, driverWords: driverWords, vendorWord: vendorWord, tuneWord: tuneWord, tuneEta: tuneEta, tuningCard: tuningCard, poundsPerDay: poundsPerDay, money: money, effOf: effOf, effText: effText, wattsTotal: wattsTotal, noReading: noReading, cells: cells, nextCheck: nextCheck, tunePoint: tunePoint, tunable: tunable, tuneWords: tuneWords, schedule: schedule, GOALS: GOALS, GOAL_NAMES: GOAL_NAMES, goalWords: goalWords, ign: ign, balanceWords: balanceWords, fleetWatts: fleetWatts, fleetPounds: fleetPounds, isMine: isMine, liveNote: liveNote, toggle: toggle, nodeWords: nodeWords, nodeLine: nodeLine, skewWord: skewWord, peersLine: peersLine, heightLine: heightLine, tipAge: tipAge, nodeSource: nodeSource, finalityWords: finalityWords, nextSwitch: nextSwitch, switchLine: switchLine, proveWords: proveWords, verifierWords: verifierWords, proveTier: proveTier, proveInsteadWords: proveInsteadWords, proveLine: proveLine, devFeeText: devFeeText, devFeeLine: devFeeLine, jobsNote: jobsNote, eventLine: eventLine, plainEvent: plainEvent, pill: pill, beforeOf: beforeOf, ember: ember, fleetSaving: fleetSaving, addressAsk: addressAsk, trustAsk: trustAsk, interfaceWords: interfaceWords, shouldReload: shouldReload, FIELDS: FIELDS, fmtIgn: fmtIgn, pct: pct, gapWords: gapWords, spanWords: spanWords, chainNums: chainNums, poisson: poisson, ignPerDay: ignPerDay, windowWords: windowWords, daysIn: daysIn, ladderRungs: ladderRungs, currentRung: currentRung, blockCard: blockCard, earningsLines: earningsLines, cardIgnDay: cardIgnDay, timeline: timeline, profileWords: profileWords, REGIONS: REGIONS, CURRENCIES: CURRENCIES, RESTRICTED_SENTENCE: RESTRICTED_SENTENCE, regionOf: regionOf, currencyOf: currencyOf, setRegion: setRegion, restrictedLine: restrictedLine, priceNote: priceNote, RENT: RENT, rentLine: rentLine, heatLine: heatLine, heatRowWords: heatRowWords }; + return { VENDORS: VENDORS, MARKS: MARKS, WELL_ALPHA_LIGHT: WELL_ALPHA_LIGHT, WELL_ALPHA_DARK: WELL_ALPHA_DARK, vendorOf: vendorOf, seriesLine: seriesLine, vendorMark: vendorMark, markHtml: markHtml, nameHtml: nameHtml, rowHead: rowHead, vendorContrast: vendorContrast, contrast: contrast, hexRgb: hexRgb, PAGES: PAGES, page: page, withCommas: withCommas, compact: compact, rel: rel, ago: ago, shortHex: shortHex, kindWord: kindWord, shownCards: shownCards, present: present, cardTitle: cardTitle, cardRow: cardRow, rowWord: rowWord, driverWords: driverWords, vendorWord: vendorWord, tuneWord: tuneWord, tuneEta: tuneEta, tuningCard: tuningCard, poundsPerDay: poundsPerDay, money: money, effOf: effOf, effText: effText, wattsTotal: wattsTotal, noReading: noReading, cells: cells, nextCheck: nextCheck, tunePoint: tunePoint, tunable: tunable, tuneWords: tuneWords, schedule: schedule, GOALS: GOALS, GOAL_NAMES: GOAL_NAMES, goalWords: goalWords, ign: ign, balanceWords: balanceWords, fleetWatts: fleetWatts, fleetPounds: fleetPounds, isMine: isMine, liveNote: liveNote, toggle: toggle, nodeWords: nodeWords, nodeLine: nodeLine, skewWord: skewWord, peersLine: peersLine, heightLine: heightLine, tipAge: tipAge, nodeSource: nodeSource, finalityWords: finalityWords, nextSwitch: nextSwitch, switchLine: switchLine, proveWords: proveWords, verifierWords: verifierWords, proveTier: proveTier, proveInsteadWords: proveInsteadWords, proveLine: proveLine, devFeeText: devFeeText, devFeeLine: devFeeLine, jobsNote: jobsNote, eventLine: eventLine, plainEvent: plainEvent, pill: pill, beforeOf: beforeOf, ember: ember, fleetSaving: fleetSaving, addressAsk: addressAsk, trustAsk: trustAsk, interfaceWords: interfaceWords, shouldReload: shouldReload, FIELDS: FIELDS, fmtIgn: fmtIgn, pct: pct, gapWords: gapWords, spanWords: spanWords, chainNums: chainNums, poisson: poisson, ignPerDay: ignPerDay, windowWords: windowWords, daysIn: daysIn, ladderRungs: ladderRungs, currentRung: currentRung, firstWait: firstWait, staleCard: staleCard, blockCard: blockCard, earningsLines: earningsLines, cardIgnDay: cardIgnDay, timeline: timeline, profileWords: profileWords, REGIONS: REGIONS, CURRENCIES: CURRENCIES, RESTRICTED_SENTENCE: RESTRICTED_SENTENCE, regionOf: regionOf, currencyOf: currencyOf, setRegion: setRegion, restrictedLine: restrictedLine, priceNote: priceNote, RENT: RENT, rentLine: rentLine, heatLine: heatLine, heatRowWords: heatRowWords }; })(); /* ---------- Ember Tune: the card row's tuning line (pure; tune-line.test.mjs loads this block) ---------- One line per card from the card state (src/state.rs, src/ember.rs): running (the phase and the step), tuned @@ -1995,7 +2013,7 @@ if (typeof document !== 'undefined') (function () { var m = s.mining, now = s.now, ld = s.ladder || {}; if (m.state === 'mining' && m.hash_total > 0) { if (!miningSince) miningSince = now; } else miningSince = 0; var c = View.chainNums(chain), p = View.poisson(m.hash_total, c.hps, miningSince ? now - miningSince : 0, c.bps, (m.cards || []).filter(function (x) { return x.state === 'mining'; }).length > 1); - var waiting = !(m.accepted_total > 0) && m.state === 'mining'; + var waiting = View.firstWait(s); setHidden('first-wait', !waiting); if (waiting) { if (p) { setText('first-wait-line', p.line); setText('first-wait-sub', p.pool || 'The chance counts up from the first job. The line gives way to the block card.'); setText('first-wait-pct', p.chanceText); $('first-wait-arc').style.strokeDashoffset = (97.4 * (1 - p.chance)).toFixed(1); } diff --git a/app/igneum-app/ui/view.test.mjs b/app/igneum-app/ui/view.test.mjs index 628d3f37..6227f01d 100644 --- a/app/igneum-app/ui/view.test.mjs +++ b/app/igneum-app/ui/view.test.mjs @@ -638,6 +638,58 @@ test('a Devnet 2 key walks every rung on screen, and every rung names its RPC fi assert.equal(V.ladderRungs(stateOf(), chainOf(), T5)[6].line, 'Proving is off.'); }); +test('first-block-21 (the project lead, 7 October 2026): the first-block card shows once in the app’s life, never on a later run', () => { + const m1 = { kind: 'first', count: 1, card: 'nvidia:0:RTX 4070', card_name: 'NVIDIA GeForce RTX 4070', hash: '9f3a' + 'ab'.repeat(28) + 'c21e', daa: 1284117, at: T5 - 60 }; + const c = chainOf({ mine: [key({ blocks: 1, rank: 4 })], best: key({ blocks: 1, rank: 4 }) }); + const afterFirst = (over, ladderOver) => stateOf({ mining: { ...stateOf().mining, accepted_total: 1, accepted_session: 0, found: [] }, ladder: { ...stateOf().ladder, first_block_at: T5 - 7200, first_block_card: 'nvidia:0:RTX 4070', first_block_hash: m1.hash, first_block_daa: 1284117, days_mined_30: 1, blocks_30d: 1, blocks_today: 1, first_block_shown: true, ...ladderOver }, ...over }); + // KNOWN-FAILED FIRST: a second run (the engine up 30 s, the lifetime count already 1, the card raised two hours ago + // on the first run and still in the record an older engine sends) showed "your first block" again before this change + const stale = { ...m1, at: T5 - 7200 }; + let s = afterFirst({ uptime_s: 30, started_at: T5 - 30 }, { milestone: stale }); + assert.equal(V.blockCard(stale, s, c, T5), null, 'a second run with the count at 1 shows no first-block card'); + assert.equal(V.staleCard(stale, s, T5), true); + // the first ever block: the card, in the run that raised it + s = stateOf({ mining: { ...stateOf().mining, accepted_total: 1, accepted_session: 1, found: [T5 - 60] }, ladder: { ...stateOf().ladder, first_block_at: T5 - 60, first_block_card: 'nvidia:0:RTX 4070', first_block_hash: m1.hash, first_block_daa: 1284117, days_mined_30: 1, blocks_30d: 1, blocks_today: 1, first_block_shown: true, milestone: m1 } }); + s.started_at = T5 - 1800; + const bc = V.blockCard(m1, s, c, T5); + assert.equal(bc.eyebrow, 'your first block'); + assert.equal(bc.title, 'Block 1,284,117 is yours.'); + assert.equal(V.staleCard(m1, s, T5), false); + assert.equal(V.firstWait(s), false, 'the count-up gives way to the card'); + // a restart with the count at 1: the engine dropped the milestone (ladder.rs start_run); nothing to show, the + // first rung stays done and the count-up never comes back + s = afterFirst({ uptime_s: 12, started_at: T5 - 12 }, { milestone: null }); + assert.equal(V.blockCard(s.ladder.milestone, s, c, T5), null); + assert.equal(V.ladderRungs(s, c, T5)[0].state, 'done'); + assert.equal(V.firstWait(s), false); + // an update with the count at 1: a newer version, the same rule; a stale card an older engine still sends is refused + s = afterFirst({ uptime_s: 45, started_at: T5 - 45, version: '0.3.21' }, { milestone: stale }); + assert.equal(V.blockCard(stale, s, c, T5), null); + // an engine without started_at (older): uptime_s stands in + s = afterFirst({ uptime_s: 45, started_at: 0 }, { milestone: stale }); + assert.equal(V.blockCard(stale, s, c, T5), null); + // a kept data directory with the count at 0 and the flag unset: the count-up shows, then the first block raises the card + s = stateOf({ uptime_s: 300, started_at: T5 - 300, ladder: { ...stateOf().ladder, first_block_shown: false, first_synced_at: T5 - 86400 * 3, first_mining_at: T5 - 86400 * 3, votes_signed: 40 } }); + assert.equal(V.firstWait(s), true); + assert.equal(V.ladderRungs(s, chainOf(), T5)[0].line, 'Waiting for your first block.'); + s = stateOf({ uptime_s: 400, started_at: T5 - 400, mining: { ...stateOf().mining, accepted_total: 1, accepted_session: 1, found: [T5 - 5] }, ladder: { ...stateOf().ladder, first_block_at: T5 - 5, first_block_shown: true, first_synced_at: T5 - 86400 * 3, first_mining_at: T5 - 86400 * 3, votes_signed: 40, milestone: { ...m1, at: T5 - 5 } } }); + assert.equal(V.blockCard(s.ladder.milestone, s, c, T5).eyebrow, 'your first block'); + assert.equal(V.firstWait(s), false); + // a count that started over (settings.json lost, the ladder kept with the flag): no "waiting", no card + s = stateOf({ mining: { ...stateOf().mining, accepted_total: 0, found: [] }, ladder: { ...stateOf().ladder, first_block_at: T5 - 86400, first_block_shown: true, milestone: null } }); + assert.equal(V.firstWait(s), false); + assert.equal(V.ladderRungs(s, chainOf(), T5)[0].state, 'done'); + assert.equal(V.blockCard(null, s, c, T5), null); + // block 2: the ordinary accepted pulse only (the block on the strip's list, the activity line, the run count), no card + s = afterFirst({ uptime_s: 1800, started_at: T5 - 1800, mining: { ...stateOf().mining, accepted_total: 2, accepted_session: 1, found: [T5 - 2] } }, { milestone: null, blocks_30d: 2, blocks_today: 2 }); + assert.equal(V.blockCard(s.ladder.milestone, s, c, T5), null); + assert.equal(V.plainEvent('block accepted by the node (NVIDIA GeForce RTX 4070)').text, 'Block found by NVIDIA GeForce RTX 4070'); + assert.equal(V.earningsLines(s, c, 28, T5).run.text, '1 block this run'); + assert.equal(V.earningsLines(s, c, 28, T5).lifetime.text, '2 blocks'); + assert.deepEqual(s.mining.found, [T5 - 2], 'the strip flashes the block that just arrived'); + assert.equal(V.ladderRungs(s, c, T5)[0].state, 'done'); +}); + test('the rungs under mainnet-sized params: 100 blocks to a vote, 30 days to full weight; the relay and the cap are named', () => { const s = stateOf({ mining: { ...stateOf().mining, accepted_total: 61, found: [] }, ladder: { ...stateOf().ladder, first_block_at: T5 - 23 * 86400, days_mined_30: 23, blocks_30d: 61 } }); const c = chainOf({ source: 'observer', keys_listed: 64, keys_cap: 64, voters: 1204, mine: [key({ blocks: 61, rank: 41 })], best: key({ blocks: 61, rank: 41 }) }, MAINNET); diff --git a/tools/ui-mock/server.mjs b/tools/ui-mock/server.mjs index 81134e94..165fb840 100644 --- a/tools/ui-mock/server.mjs +++ b/tools/ui-mock/server.mjs @@ -20,6 +20,8 @@ // job a remote job running // firstwait miner-ui-5: mining, no block yet (the Poisson count-up), the ladder's first rung // firstblock miner-ui-5: the first block a minute ago (the block card), the key under the dust line +// secondblock first-block-21: block 2 two seconds ago, ten minutes after the first; the ordinary accepted pulse (the +// block on the strip, the activity line, "2 blocks this run"), no card; ladder.first_block_shown true // ladder miner-ui-5: every rung walked (vote, signature, full window, rank 3, streak, a paid shard) // drivers driver-check: the 5090 fine, the 9070 XT and the Arc with an Install offer (also drivers-running, // drivers-reboot, drivers-error, drivers-mac) @@ -119,10 +121,12 @@ function liveNow(windowS) { } // miner-ui-5: this machine's ladder record (state.ladder, src/ladder.rs) per scenario function ladderNow(t) { - const base = { first_block_at: 0, first_block_card: '', first_block_hash: '', first_block_daa: 0, days_mined_30: 0, blocks_30d: 0, blocks_today: 0, days: [], cards_first: {}, blocks: [], votes_signed: 0, last_vote_index: 0, last_vote_at: 0, last_signed_locked: 0, signed_locked_count: 0, streak_s: 0, shards: [], milestone: null, first_synced_at: t0 - 2800, first_mining_at: t0 - 2700 }; + const base = { first_block_at: 0, first_block_card: '', first_block_hash: '', first_block_daa: 0, days_mined_30: 0, blocks_30d: 0, blocks_today: 0, days: [], cards_first: {}, blocks: [], votes_signed: 0, last_vote_index: 0, last_vote_at: 0, last_signed_locked: 0, signed_locked_count: 0, streak_s: 0, shards: [], milestone: null, first_synced_at: t0 - 2800, first_mining_at: t0 - 2700, first_block_shown: false }; const hash = '9f3a7c1e5d2b4a6f8e0c1d3b5a7f9e2c4d6b8a0f1e3c5d7b9a2f4e6c8d0b1a3c21e'; - if (scenario === 'firstblock') return { ...base, first_block_at: t - 70, first_block_card: recState.mining.cards[0].key, first_block_hash: hash, first_block_daa: 266454, days_mined_30: 1, blocks_30d: 1, blocks_today: 1, blocks: [{ hash, daa: 266454, nonce: '267dd27200a91c80', at: t - 70, card: recState.mining.cards[0].key, card_name: recState.mining.cards[0].name, count: 1 }], votes_signed: 2, last_vote_index: 8496, last_vote_at: t - 20, milestone: { kind: 'first', count: 1, card: recState.mining.cards[0].key, card_name: recState.mining.cards[0].name, hash, daa: 266454, at: t - 70 } }; - if (scenario === 'ladder') return { ...base, first_block_at: t - 7300, first_block_card: recState.mining.cards[0].key, first_block_hash: hash, first_block_daa: 259200, days_mined_30: 1, blocks_30d: 61, blocks_today: 61, votes_signed: 240, last_vote_index: 8495, last_vote_at: t - 12, last_signed_locked: 8495, signed_locked_count: 238, streak_s: 7100, shards: [{ block: 160390, shard: 3, wei: '1150000000000000000', at: t - 400 }], milestone: { kind: 'hundred', count: 100, card: recState.mining.cards[0].key, card_name: recState.mining.cards[0].name, hash, daa: 266400, at: t - 50 } }; + const hash2 = '4c1d9e7a2b5f8c0e3d6a9b1c4e7f0a2d5b8c1e4f7a0b3d6c9e2f5a8b1d4c7e0f3a2'; + if (scenario === 'secondblock') return { ...base, first_block_at: t - 602, first_block_card: recState.mining.cards[0].key, first_block_hash: hash, first_block_daa: 266454, days_mined_30: 1, blocks_30d: 2, blocks_today: 2, cards_first: { [recState.mining.cards[0].key]: t - 602 }, blocks: [{ hash: hash2, daa: 267054, nonce: '3a9c0d11e2f4b5a6', at: t - 2, card: recState.mining.cards[0].key, card_name: recState.mining.cards[0].name, count: 2 }, { hash, daa: 266454, nonce: '267dd27200a91c80', at: t - 602, card: recState.mining.cards[0].key, card_name: recState.mining.cards[0].name, count: 1 }], votes_signed: 20, last_vote_index: 8516, last_vote_at: t - 8, milestone: null, first_block_shown: true }; + if (scenario === 'firstblock') return { ...base, first_block_at: t - 70, first_block_card: recState.mining.cards[0].key, first_block_hash: hash, first_block_daa: 266454, days_mined_30: 1, blocks_30d: 1, blocks_today: 1, blocks: [{ hash, daa: 266454, nonce: '267dd27200a91c80', at: t - 70, card: recState.mining.cards[0].key, card_name: recState.mining.cards[0].name, count: 1 }], votes_signed: 2, last_vote_index: 8496, last_vote_at: t - 20, milestone: { kind: 'first', count: 1, card: recState.mining.cards[0].key, card_name: recState.mining.cards[0].name, hash, daa: 266454, at: t - 70 }, first_block_shown: true }; + if (scenario === 'ladder') return { ...base, first_block_at: t - 7300, first_block_card: recState.mining.cards[0].key, first_block_hash: hash, first_block_daa: 259200, days_mined_30: 1, blocks_30d: 61, blocks_today: 61, votes_signed: 240, last_vote_index: 8495, last_vote_at: t - 12, last_signed_locked: 8495, signed_locked_count: 238, streak_s: 7100, shards: [{ block: 160390, shard: 3, wei: '1150000000000000000', at: t - 400 }], milestone: { kind: 'hundred', count: 100, card: recState.mining.cards[0].key, card_name: recState.mining.cards[0].name, hash, daa: 266400, at: t - 50 }, first_block_shown: true }; return base; } // miner-ui-5: GET /api/ladder, in src/chainfacts.rs's shape: the devnet's finality params (api/live, 7 Oct 2026), @@ -130,6 +134,7 @@ function ladderNow(t) { function ladderReply(t) { const mine = scenario === 'firstblock' ? { id: '8fafda27', blocks: 1, voter: false, participation: 0, stripped_until_daa: 0, revealed: false, rank: 4 } : scenario === 'ladder' ? { id: '8fafda27', blocks: 61, voter: true, participation: 1, stripped_until_daa: 0, revealed: true, rank: 3 } + : scenario === 'secondblock' ? { id: '8fafda27', blocks: 2, voter: false, participation: 0, stripped_until_daa: 0, revealed: false, rank: 4 } : scenario === 'firstwait' ? null : { id: '8fafda27', blocks: 251, voter: true, participation: 1, stripped_until_daa: 0, revealed: true, rank: 2 }; return { ok: true, source: 'observer', read_at: t, cached_age_s: 0, rpc: { weights: 'getFinalityWeights', checkpoints: 'getFinalityCheckpoints', stats: '/api/stats', relay: 'igneum.network/api/live (the observer\'s copy of the node\'s reply)' }, params: { dust: 5, weight_window: 7200, presence_window: 20, checkpoint_interval: 30, min_daa: 7200 }, checkpoint_index: 8495, daa_score: 266454, latest_locked_index: 8495, next_index: 8496, finality_active: true, @@ -145,7 +150,9 @@ function stateNow() { s.installed_at = t0 - 3000; if (scenario === 'firstwait') { s.mining.accepted_total = 0; s.mining.accepted_session = 0; s.mining.cards[0].accepted = 0; s.mining.found = []; s.finality = { ...s.finality, last_lock: 0, votes: 0 }; } if (scenario === 'firstblock') { s.mining.accepted_total = 1; s.mining.accepted_session = 1; s.mining.cards[0].accepted = 1; } + if (scenario === 'secondblock') { s.mining.accepted_total = 2; s.mining.accepted_session = 2; s.mining.cards[0].accepted = 2; } s.uptime_s = recState.uptime_s + Math.floor(t - t0); + s.started_at = t0 - recState.uptime_s; // first-block-21: the run's start on the wall clock (every card here is this run's) s.mining.found = recState.mining.found.map(x => x + shift).filter(x => t - x < 3600); s.events = recState.events.map(e => ({ ...e, t: e.t + shift })); s.finality.last_lock_at += shift; s.finality.age_s = t - s.finality.last_lock_at; @@ -159,9 +166,10 @@ function stateNow() { const wob = Math.sin((t - t0) / 7) * 1.4; s.mining.hash_total = +(16.9 + wob).toFixed(2); s.mining.cards[0].hash_now = s.mining.hash_total; // a fresh block every ~9 s on average (none before the first block) - const extra = []; if (scenario !== 'firstwait' && scenario !== 'firstblock') for (let k = t0; k < t; k += 9.3) extra.push(k + 0.4); + const extra = []; if (scenario !== 'firstwait' && scenario !== 'firstblock' && scenario !== 'secondblock') for (let k = t0; k < t; k += 9.3) extra.push(k + 0.4); if (scenario === 'firstwait') { s.mining.found = []; s.mining.accepted_total = 0; s.mining.accepted_session = 0; s.mining.cards[0].accepted = 0; s.events = s.events.filter(e => e.kind !== 'block'); } if (scenario === 'firstblock') { s.mining.found = [t - 70]; s.events = [{ kind: 'block', t: t - 70, text: 'first block: found by ' + s.mining.cards[0].name + ' (block card)' }, { kind: 'block', t: t - 70, text: 'block accepted by the node (' + s.mining.cards[0].name + ')' }].concat(s.events.filter(e => e.kind !== 'block')).slice(0, 40); } + if (scenario === 'secondblock') { s.mining.found = [t - 602, t - 2]; s.events = [{ kind: 'block', t: t - 2, text: 'block accepted by the node (' + s.mining.cards[0].name + ')' }, { kind: 'block', t: t - 602, text: 'first block: found by ' + s.mining.cards[0].name + ' (block card)' }, { kind: 'block', t: t - 602, text: 'block accepted by the node (' + s.mining.cards[0].name + ')' }].concat(s.events.filter(e => e.kind !== 'block')).slice(0, 40); } if (scenario === 'ladder') { s.settings.prove = true; s.proving = { ...s.proving, enabled: true, available: true, status: 'idle', paid: 1, paid_wei: '1150000000000000000', last_paid: 'block 160390 shard 3' }; s.events = [{ kind: 'proving', t: t - 400, text: 'block 160390 shard 3 paid 1.15 IGN' }, { kind: 'block', t: t - 50, text: 'block 100 of this machine: found by ' + s.mining.cards[0].name + ' (block card)' }].concat(s.events).slice(0, 40); } s.mining.found = s.mining.found.concat(extra).filter(x => t - x < 3600).sort((a, b) => a - b); s.mining.accepted_session += extra.length; s.mining.accepted_total += extra.length; s.mining.cards[0].accepted += extra.length;