diff --git a/app/igneum-app/src/ladder.rs b/app/igneum-app/src/ladder.rs index 38e41400..a4e7a1a4 100644 --- a/app/igneum-app/src/ladder.rs +++ b/app/igneum-app/src/ladder.rs @@ -107,14 +107,24 @@ pub struct Ladder { #[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`. + /// first ever block"; main's reading: SEEN once): set when a window has shown the first-block card (the page + /// reports it through POST /api/card/seen on display or dismiss), never when it is raised. The card is raised when + /// the lifetime count the engine persists (settings.json accepted_total) crosses from 0 to 1 and waits, across + /// restarts and updates, until a window shows it: a block found overnight or across an auto-update greets the + /// miner the first time they open the app, and after that never again. 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 0.3.20 record (schema 0, no flag) whose first block already + /// came (count 1 or more, or first_block_at set) takes the flag at `start_run`, since 0.3.20 showed it every run. #[serde(default)] pub first_block_shown: bool, + /// a window has shown the current `milestone` (set with it by `card_seen`); a seen card is dropped at the next + /// `start_run`, an unseen one waits for its window + #[serde(default)] + pub milestone_seen: bool, + /// the record's shape: 0 = written by 0.3.20 (no first-block flag), 1 = first-block-21 + #[serde(default)] + pub schema: u32, } /// What the dashboard reads (api/state.ladder): the record above summarised at `now`. @@ -158,24 +168,51 @@ impl Ladder { if let Some(d) = path.parent() { let _ = std::fs::create_dir_all(d); } - if let Ok(t) = serde_json::to_string_pretty(self) { + let mut me = self.clone(); + me.schema = 1; + if let Ok(t) = serde_json::to_string_pretty(&me) { let _ = std::fs::write(path, t); } } /// 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. + /// count settings.json holds. A block card a window has shown (`milestone_seen`) is dropped here, so a restart + /// or an update never shows "your first block" (or block 100) a second time; a card no window has shown yet + /// waits (found overnight, or across an auto-update restart). A 0.3.20 record (schema 0) carries no flag and + /// showed its card on every run, so one whose first block already came (the count at 1 or more, or + /// first_block_at set) takes the flag now and its card is dropped. 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; + if self.schema == 0 { + if accepted_total >= 1 || self.first_block_at > 0.0 { + self.first_block_shown = true; + self.milestone = None; + } + self.schema = 1; dirty = true; } - if !self.first_block_shown && (accepted_total >= 1 || self.first_block_at > 0.0) { + if self.milestone.is_some() && self.milestone_seen { + self.milestone = None; + self.milestone_seen = false; + dirty = true; + } + dirty + } + + /// A window showed (or the user dismissed) the card for the milestone at `count` and `at` (first-block-21): the + /// card is spent, and a first-block card sets the lifetime flag. Returns true when the record changed. + pub fn card_seen(&mut self, count: u64, at: f64) -> bool { + let m = match self.milestone.as_ref() { + Some(m) if m.count == count && (m.at - at).abs() < 1.0 => m, + _ => return false, + }; + let mut dirty = false; + if !self.milestone_seen { + self.milestone_seen = true; + dirty = true; + } + if m.kind == "first" && !self.first_block_shown { self.first_block_shown = true; dirty = true; } @@ -232,6 +269,9 @@ impl Ladder { let n = self.days.len() - KEEP_DAYS as usize; self.days.drain(0..n); } + // the first-block card: once in the app's life, when the persisted lifetime count crosses from 0 to 1 on a + // record with no block and no flag (first-block-21); a count that starts over raises nothing here + let first = total == 1 && !self.first_block_shown && self.first_block_at == 0.0; if self.first_block_at == 0.0 { self.first_block_at = now; self.first_block_card = card.to_string(); @@ -244,18 +284,14 @@ 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); - // 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; } let m = Milestone { kind: kind.into(), count: total, card: card.into(), card_name: card_name.into(), hash, daa, at: now }; self.milestone = Some(m.clone()); + self.milestone_seen = false; + self.schema = 1; Some(m) } @@ -549,11 +585,22 @@ mod tests { 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 ---- + // ---- first-block-21 (the project lead, 7 October 2026): the first-block card SEEN once in the app's life ---- - /// The record as 0.3.20 wrote it after the first block: the milestone persisted, no flag field. + /// The record as 0.3.20 wrote it after the first block: the milestone persisted, no flag field, no schema. 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":[]}"#; + fn round_trip(l: &Ladder) -> Ladder { + // one directory per call: the tests run in parallel and a shared path let one test's remove wipe another's file + static N: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let dir = std::env::temp_dir().join(format!("igneum-ladder-first-{}-{}", std::process::id(), N.fetch_add(1, std::sync::atomic::Ordering::SeqCst))); + let path = dir.join("ladder.json"); + l.save(&path); + let back = Ladder::load(&path); + let _ = std::fs::remove_dir_all(&dir); + back + } + #[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 @@ -561,39 +608,64 @@ mod tests { 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_eq!(l.schema, 0); 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.milestone.is_none(), "a card 0.3.20 showed on every 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"); + assert_eq!(l.schema, 1); } #[test] - fn the_first_ever_block_raises_the_card_once_and_sets_the_flag() { + fn the_first_ever_block_raises_the_card_and_a_window_showing_it_sets_the_flag() { let mut l = Ladder::default(); - assert!(!l.start_run(0), "a fresh record changes nothing at start"); + l.start_run(0); let m = l.on_block(100.0, "a", "RTX 4070", "0x1", 1).unwrap(); assert_eq!(m.kind, "first"); + assert!(!l.first_block_shown, "raised, not yet seen"); + assert!(!l.card_seen(7, 100.0), "another card's report changes nothing"); + assert!(l.card_seen(1, 100.0)); assert!(l.first_block_shown); + assert!(!l.card_seen(1, 100.0), "a second report (the dismiss) changes nothing"); let s = l.state(101.0, 600.0); assert!(s.first_block_shown); - assert_eq!(s.milestone.as_ref().unwrap().kind, "first"); + assert_eq!(s.milestone.as_ref().unwrap().kind, "first", "the card stays on screen through this run"); + } + + #[test] + fn a_first_block_found_with_no_window_greets_the_miner_at_the_first_open_and_never_again() { + // main's case: the block overnight (no window), an engine restart (an auto-update), the first open shows the + // card once; a second open (the next run) does not + let mut night = Ladder::default(); + night.start_run(0); + night.on_block(100.0, "a", "RTX 4070", "0x1", 1); + let mut morning = round_trip(&night); + assert!(!morning.start_run(1), "an unseen card waits: nothing changes at start"); + let s = morning.state(30_000.0, 600.0); + assert_eq!(s.milestone.as_ref().unwrap().kind, "first", "the first open shows the card"); + assert!(!s.first_block_shown); + assert!(morning.card_seen(1, 100.0)); + assert!(morning.first_block_shown); + let mut later = round_trip(&morning); + assert!(later.start_run(1), "the seen card is dropped"); + assert!(later.state(60_000.0, 600.0).milestone.is_none(), "a second open shows nothing"); + assert!(later.first_block_shown); + assert!(later.on_block(60_100.0, "a", "RTX 4070", "0x2", 2).is_none()); } #[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!(run1.card_seen(1, 100.0), "the window was open"); + let mut run2 = round_trip(&run1); assert!(run2.first_block_shown, "the flag survives the save"); - assert!(run2.start_run(1), "the persisted card is dropped"); + assert_eq!(run2.schema, 1); + assert!(run2.start_run(1), "the seen 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] @@ -604,13 +676,16 @@ mod tests { 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(); + let mut back = round_trip(&l); assert!(back.first_block_shown); + assert_eq!(back.schema, 1); + assert!(!back.start_run(1), "a new-shape record with nothing pending changes nothing"); 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); + assert!(l2.milestone.is_none()); } #[test] @@ -620,11 +695,13 @@ mod tests { 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(); + let mut kept = round_trip(&l); + assert!(!kept.start_run(0), "nothing to reconcile: no block yet"); + assert!(!kept.first_block_shown); + let m = kept.on_block(100.0, "a", "RTX 4070", "0x1", 1).unwrap(); assert_eq!(m.kind, "first"); - assert!(l.first_block_shown); + assert!(kept.card_seen(1, 100.0)); + assert!(kept.first_block_shown); } #[test] @@ -632,16 +709,23 @@ mod tests { // 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); + l.card_seen(1, 100.0); 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"); + // the same with the card still unseen: the waiting card stays the one card, no second is raised + let mut u = Ladder::default(); + u.on_block(100.0, "a", "RTX 4070", "0x1", 1); + assert!(u.on_block(200.0, "a", "RTX 4070", "0x2", 1).is_none()); + assert_eq!(u.milestone.as_ref().unwrap().at, 100.0); } #[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); + l.card_seen(1, 100.0); 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); diff --git a/app/igneum-app/src/server.rs b/app/igneum-app/src/server.rs index 8fc2617e..0c068d9a 100644 --- a/app/igneum-app/src/server.rs +++ b/app/igneum-app/src/server.rs @@ -467,6 +467,15 @@ fn api_post(shared: &Arc, path: &str, body: Value) -> Result/cards/ and the folder opened for the user. The body is {name, png: "data:image/png;base64,..."}. + // first-block-21: the page showed (or the user dismissed) the block card for the milestone at {count, at}; + // the card is spent for later runs and a first-block card sets the lifetime flag (src/ladder.rs card_seen) + "/api/card/seen" => { + let count = body.get("count").and_then(|v| v.as_u64()).unwrap_or(0); + let at = body.get("at").and_then(|v| v.as_f64()).unwrap_or(0.0); + let changed = shared.ladder.lock().unwrap().card_seen(count, at); + if changed { shared.save_ladder(); } + Ok(json!({ "ok": true, "changed": changed })) + } "/api/card" => { let name = body.get("name").and_then(|v| v.as_str()).unwrap_or("block"); let png = body.get("png").and_then(|v| v.as_str()).ok_or("png missing")?; diff --git a/app/igneum-app/ui/app.js b/app/igneum-app/ui/app.js index ce2ef54e..938c65ae 100644 --- a/app/igneum-app/ui/app.js +++ b/app/igneum-app/ui/app.js @@ -1081,16 +1081,21 @@ var View = (function () { 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. + // a card is seen once (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"; main's reading: seen once). An engine of 0.3.21 or later owns the rule: it + // keeps a card no window has shown across restarts and updates (a block found overnight greets the miner at the + // first open) and drops a seen one at its next start (ladder.rs start_run, the page's POST api/card/seen), so + // whatever such an engine sends is shown. A 0.3.20 engine (its ladder has no first_block_shown field) re-sends the + // persisted card on every run: against it 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). function staleCard(m, s, now) { if (!m || !(m.at > 0) || !s) return false; + if (s.ladder && s.ladder.first_block_shown !== undefined) 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; } + // the body the page sends to api/card/seen for a card it displays or the user dismisses (null for no card) + function cardSeenBody(bc) { return bc && bc.count > 0 ? { count: bc.count, at: bc.when || 0 } : null; } function blockCard(m, s, chain, now) { if (!m || !(m.count > 0)) return null; if (staleCard(m, s, now)) return null; @@ -1192,7 +1197,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, 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 }; + 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, cardSeenBody: cardSeenBody, 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 @@ -1960,7 +1965,15 @@ if (typeof document !== 'undefined') (function () { disclosure('tl-toggle', 'tl-details'); $('s-profile').addEventListener('change', function () { var on = this.checked; api('api/settings', { profile_public: on }).then(function (r) { if (r.ok) toast(on ? 'Your page is public from the next site read' : 'Your page is unlisted again'); else { toast(r.error || 'could not change'); $('s-profile').checked = !on; } }).catch(function () { toast('This engine has no profile switch yet'); $('s-profile').checked = !on; }); }); // the block card's buttons - $('bc-close').addEventListener('click', function () { cardDismissed = $('block-card').dataset.key || ''; store('igneum.card.dismissed', cardDismissed); $('block-card').hidden = true; }); + // first-block-21: the card is spent once a window has shown it. The first render in a visible window reports it to + // the engine (api/card/seen: the lifetime flag, the drop at the engine's next start); the dismiss reports it too; and + // the window hiding to the tray or menu bar with the card on screen counts as the dismiss, so a second open in the + // same run shows nothing either. + var cardSeenKey = ''; + function reportCardSeen(bc) { if (!bc || cardSeenKey === bc.key) return; cardSeenKey = bc.key; var b = View.cardSeenBody(bc); if (b) api('api/card/seen', b).catch(function () { }); } + function dismissCard() { cardDismissed = $('block-card').dataset.key || ''; store('igneum.card.dismissed', cardDismissed); $('block-card').hidden = true; } + $('bc-close').addEventListener('click', function () { if (state && state.ladder) reportCardSeen(View.blockCard(state.ladder.milestone, state, chain, state.now)); dismissCard(); }); + document.addEventListener('visibilitychange', function () { if (document.hidden && !$('block-card').hidden && $('block-card').dataset.key) dismissCard(); }); $('bc-open').addEventListener('click', function () { var u = $('block-card').dataset.url; if (u) api('api/open', { url: u }); else toast('The hash is not known yet'); }); $('bc-copy').addEventListener('click', function () { var u = $('block-card').dataset.url; if (u) copyText(u, 'Link copied'); else toast('The hash is not known yet'); }); $('bc-save').addEventListener('click', function () { @@ -2024,6 +2037,7 @@ if (typeof document !== 'undefined') (function () { var show = !!bc && bc.key !== cardDismissed; setHidden('block-card', !show); if (show) { + if (!hiddenNow()) reportCardSeen(bc); card.dataset.key = bc.key; card.dataset.url = bc.url; setText('bc-eyebrow', bc.eyebrow); setText('bc-when', bc.when > 0 ? clock(bc.when) : ''); setText('bc-title', bc.title); $('bc-card').innerHTML = View.markHtml(bc.card, 'mini') + '' + esc(bc.cardLine) + ''; $('bc-card').title = bc.fields.card; diff --git a/app/igneum-app/ui/view.test.mjs b/app/igneum-app/ui/view.test.mjs index 6227f01d..b9865af5 100644 --- a/app/igneum-app/ui/view.test.mjs +++ b/app/igneum-app/ui/view.test.mjs @@ -638,41 +638,52 @@ 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', () => { +test('first-block-21 (the project lead, 7 October 2026): the first-block card is seen 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 }; + // KNOWN-FAILED FIRST: a second run against a 0.3.20 engine (its ladder has no first_block_shown field; the engine up + // 30 s, the lifetime count already 1, the card raised two hours ago on the first run and re-sent from its record) + // showed "your first block" again before this change let s = afterFirst({ uptime_s: 30, started_at: T5 - 30 }, { milestone: stale }); + delete s.ladder.first_block_shown; 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; + // the first ever block: the card, in the run that raised it, and the body the page reports when it displays it + s = stateOf({ started_at: T5 - 1800, 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: false, milestone: m1 } }); 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.deepEqual(V.cardSeenBody(bc), { count: 1, at: T5 - 60 }); + assert.equal(V.cardSeenBody(null), null); 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 + // seen once (main's reading): a block found overnight with no window, the engine restarted since (an auto-update): + // a 0.3.21 engine keeps the unseen card and the first open shows it, however old it is + s = afterFirst({ uptime_s: 30, started_at: T5 - 30 }, { first_block_shown: false, milestone: stale }); + assert.equal(V.staleCard(stale, s, T5), false, 'a 0.3.21 engine owns the rule'); + assert.equal(V.blockCard(stale, s, c, T5).eyebrow, 'your first block'); + // a second open after it was seen: the engine dropped the card at its start (ladder.rs start_run); 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 + // a restart with the count at 1 and the card seen: nothing to show + s = afterFirst({ uptime_s: 45, started_at: T5 - 45 }, { milestone: null }); + assert.equal(V.blockCard(s.ladder.milestone, s, c, T5), null); + // an update with the count at 1 from 0.3.20: the engine takes the flag from the old record and sends no card; a + // 0.3.20 engine without started_at re-sending its card is refused through uptime_s + s = afterFirst({ uptime_s: 45, started_at: T5 - 45, version: '0.3.21' }, { milestone: null }); + assert.equal(V.blockCard(s.ladder.milestone, s, c, T5), null); s = afterFirst({ uptime_s: 45, started_at: 0 }, { milestone: stale }); + delete s.ladder.first_block_shown; 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 } } }); + 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: false, 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 diff --git a/tools/ui-mock/server.mjs b/tools/ui-mock/server.mjs index 165fb840..a708a704 100644 --- a/tools/ui-mock/server.mjs +++ b/tools/ui-mock/server.mjs @@ -19,7 +19,7 @@ // fresh setup not done (welcome, cards, address screens) // 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 +// firstblock miner-ui-5: the first block a minute ago (the block card, not yet seen by a window), 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) @@ -125,7 +125,7 @@ function ladderNow(t) { const hash = '9f3a7c1e5d2b4a6f8e0c1d3b5a7f9e2c4d6b8a0f1e3c5d7b9a2f4e6c8d0b1a3c21e'; 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 === '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: false }; 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; }