-
Notifications
You must be signed in to change notification settings - Fork 0
feat(mobile): v1.18.0 "Dormant" - monetization scaffold #107
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
07f5052
feat(mobile): v1.18.0 "Dormant" - monetization scaffold wired into bo…
doublegate 8cbf858
fix(ios): merge mobile+monetization xcframeworks to avoid modulemap c…
doublegate 2d0847f
fix(mobile): clamp pre-1970 clock in iOS scaffold call, fix doc mismatch
doublegate File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| [package] | ||
| name = "rustysnes-monetization" | ||
| description = "RustySNES: dormant entitlement/ad-pacing policy scaffold (mobile-only, never a dependency of the deterministic core)" | ||
| version = "1.17.0" | ||
| edition.workspace = true | ||
| rust-version.workspace = true | ||
| license.workspace = true | ||
| authors.workspace = true | ||
| repository.workspace = true | ||
| keywords = ["snes", "emulator", "mobile", "uniffi"] | ||
| categories = ["emulators", "games"] | ||
|
|
||
| [lib] | ||
| # Same shape as `rustysnes-mobile`'s identical rationale: `cdylib` for Android's JNI loader and | ||
| # iOS's `.xcframework` packaging, `staticlib` for iOS static linking, `rlib` so `cargo test` on | ||
| # the host works without any mobile toolchain. Unlike `rustysnes-android`/`rustysnes-ios`, this | ||
| # crate has zero platform-specific dependencies (no wgpu/JNI/UIKit) -- every target here, | ||
| # including the host, builds and tests for real in this environment. | ||
| crate-type = ["cdylib", "staticlib", "rlib"] | ||
|
|
||
| [features] | ||
| # Same opt-in shape as `rustysnes-mobile`'s identical `bindgen` feature -- see that crate's | ||
| # Cargo.toml comment for the full rationale (keeps `uniffi/cli` off every real mobile build). | ||
| bindgen = ["uniffi/cli"] | ||
|
|
||
| [[bin]] | ||
| name = "uniffi-bindgen" | ||
| required-features = ["bindgen"] | ||
|
|
||
| [lints] | ||
| workspace = true | ||
|
|
||
| [dependencies] | ||
| uniffi = "0.29" | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| //! Standalone binding-generator binary — `cargo run -p rustysnes-monetization --features bindgen | ||
| //! --bin uniffi-bindgen -- generate --library <path-to-cdylib> --language kotlin --out-dir <dir>` | ||
| //! produces the Kotlin (or Swift) bindings for [`rustysnes_monetization`]'s `#[uniffi::export]` | ||
| //! surface. Matches `rustysnes-mobile`'s identical binary exactly — see that crate's own doc | ||
| //! comment for the full rationale. | ||
|
|
||
| fn main() { | ||
| uniffi::uniffi_bindgen_main(); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,178 @@ | ||
| //! Dormant entitlement/ad-pacing policy scaffold (`v1.18.0 "Dormant"`, Mobile Phase 5) — the | ||
| //! monetization crate RustyNES's own mobile track already ships as a dormant module, ported here | ||
| //! as a policy-*shape* template only. **Never a dependency of the deterministic core** — | ||
| //! `rustysnes-core`/`rustysnes-cpu`/`rustysnes-ppu`/`rustysnes-apu`/`rustysnes-cart` never depend | ||
| //! on this crate, and this crate never depends on them; it has no knowledge of `EmuCore` at all. | ||
| //! It is wired into both mobile shells (`android/`, `ios/`) as an inert dependency only: compiled | ||
| //! in and called once at startup (logged, not gating anything), no real RevenueCat/AppLovin (or | ||
| //! equivalent) SDK calls, no purchase/paywall UI shown. | ||
| //! | ||
| //! # Why "dormant" | ||
| //! | ||
| //! Unlike RustyNES's own module (which ships a committed pricing figure), every concrete number | ||
| //! here is an explicit placeholder default — [`AdPacingPolicy`]'s field values are a template | ||
| //! for a *later* maintainer decision, not a real pricing/pacing commitment. | ||
| //! `docs/mobile-readiness.md`'s standing "Mobile Phase 6" store-launch gate is where that decision | ||
| //! (and actually wiring a real store SDK) would happen — not here, not automatically. | ||
| //! | ||
| //! # Determinism discipline (same boundary as the deterministic core, extended here on principle) | ||
| //! | ||
| //! Every function in this crate is pure: no wall-clock reads, no OS RNG, no hidden state. Time | ||
| //! enters only as an explicit `now_unix_secs: u64` parameter the caller supplies (matching | ||
| //! `docs/adr/0004`'s "host-injected timestamps" convention already used elsewhere in this | ||
| //! project) — this crate never feeds anything back into `EmuCore`, so nothing here is subject to | ||
| //! the core's own byte-identical-replay contract, but keeping the same discipline anyway means | ||
| //! every function here is trivially unit-testable without mocking a clock. | ||
|
|
||
| uniffi::setup_scaffolding!(); | ||
|
|
||
| /// Whether the user currently has the paid unlock. | ||
| #[derive(uniffi::Record, Debug, Clone, Copy, PartialEq, Eq)] | ||
| pub struct Entitlement { | ||
| /// `true` if paid features should be unlocked. | ||
| pub unlocked: bool, | ||
| } | ||
|
|
||
| /// Placeholder ad-pacing knobs — see the module doc for why every field here is an explicit | ||
| /// placeholder, not a committed figure. | ||
| #[derive(uniffi::Record, Debug, Clone, Copy, PartialEq, Eq)] | ||
| pub struct AdPacingPolicy { | ||
| /// Minimum seconds between two ad presentations. | ||
| pub min_interval_secs: u64, | ||
| /// How many play sessions must complete before the very first ad is eligible at all. | ||
| pub sessions_before_first_ad: u32, | ||
| } | ||
|
|
||
| impl AdPacingPolicy { | ||
| /// Placeholder values only -- not a committed pacing figure, see the module doc. The single | ||
| /// source of truth both [`Default::default`] and [`default_ad_pacing_policy`] return, so the | ||
| /// two can never drift apart. | ||
| const PLACEHOLDER_DEFAULT: Self = Self { | ||
| min_interval_secs: 300, | ||
| sessions_before_first_ad: 3, | ||
| }; | ||
| } | ||
|
|
||
| impl Default for AdPacingPolicy { | ||
| fn default() -> Self { | ||
| Self::PLACEHOLDER_DEFAULT | ||
| } | ||
| } | ||
|
|
||
| /// The default, dormant ad-pacing policy. | ||
| /// | ||
| /// Entitlement checks always report unlocked (no paywall active), and ad pacing uses | ||
| /// [`AdPacingPolicy::default`]'s placeholder figures. A mobile shell calling this and logging the | ||
| /// result (not gating any UI on it) is exactly the "inert dependency" integration this rung wires | ||
| /// up — see `android/`/`ios/`'s own call sites. | ||
| #[uniffi::export] | ||
| #[must_use] | ||
| pub const fn default_ad_pacing_policy() -> AdPacingPolicy { | ||
| AdPacingPolicy::PLACEHOLDER_DEFAULT | ||
| } | ||
|
|
||
| /// Check entitlement for `now_unix_secs`. | ||
| /// | ||
| /// Dormant: always reports unlocked (no active paywall) regardless of the timestamp — a real | ||
| /// store-backed check (RevenueCat/StoreKit/Play Billing) is a `docs/mobile-readiness.md` | ||
| /// "Mobile Phase 6" store-launch decision, not implemented here. `now_unix_secs` is accepted (and | ||
| /// ignored, via `let _ = ...` rather than an underscore-prefixed parameter name -- `#[uniffi:: | ||
| /// export]`'s generated glue references the parameter by name, which trips | ||
| /// `clippy::used_underscore_binding` on a `_`-prefixed one) rather than dropped from the | ||
| /// signature, so the real check this eventually becomes doesn't need a breaking signature change | ||
| /// across the FFI boundary. | ||
| #[uniffi::export] | ||
| #[must_use] | ||
| pub const fn check_entitlement(now_unix_secs: u64) -> Entitlement { | ||
| let _ = now_unix_secs; | ||
| Entitlement { unlocked: true } | ||
| } | ||
|
|
||
| /// Pure ad-pacing decision: should an ad be shown right now? | ||
| /// | ||
| /// - `now_unix_secs`: the current time, host-injected (see the module doc). | ||
| /// - `last_ad_unix_secs`: when the last ad was shown, or `None` if none has ever been shown this | ||
| /// install. | ||
| /// - `completed_sessions`: how many play sessions have completed so far. | ||
| /// - `policy`: the pacing knobs to evaluate against (typically [`default_ad_pacing_policy`]'s | ||
| /// result, but passed explicitly rather than read from hidden state, keeping this function pure | ||
| /// and directly unit-testable). | ||
| #[uniffi::export] | ||
| #[must_use] | ||
| pub fn should_show_ad( | ||
| now_unix_secs: u64, | ||
| last_ad_unix_secs: Option<u64>, | ||
| completed_sessions: u32, | ||
| policy: AdPacingPolicy, | ||
| ) -> bool { | ||
| if completed_sessions < policy.sessions_before_first_ad { | ||
| return false; | ||
| } | ||
| last_ad_unix_secs | ||
| .is_none_or(|last| now_unix_secs.saturating_sub(last) >= policy.min_interval_secs) | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
|
|
||
| #[test] | ||
| fn entitlement_is_always_unlocked_while_dormant() { | ||
| assert!(check_entitlement(0).unlocked); | ||
| assert!(check_entitlement(u64::MAX).unlocked); | ||
| } | ||
|
|
||
| #[test] | ||
| fn ad_pacing_withholds_the_first_ad_until_enough_sessions_completed() { | ||
| let policy = AdPacingPolicy::default(); | ||
| assert!(!should_show_ad(1_000_000, None, 0, policy)); | ||
| assert!(!should_show_ad( | ||
| 1_000_000, | ||
| None, | ||
| policy.sessions_before_first_ad - 1, | ||
| policy | ||
| )); | ||
| } | ||
|
|
||
| #[test] | ||
| fn ad_pacing_allows_the_first_ad_once_the_session_threshold_is_met() { | ||
| let policy = AdPacingPolicy::default(); | ||
| assert!(should_show_ad( | ||
| 1_000_000, | ||
| None, | ||
| policy.sessions_before_first_ad, | ||
| policy | ||
| )); | ||
| } | ||
|
|
||
| #[test] | ||
| fn ad_pacing_enforces_the_minimum_interval_between_ads() { | ||
| let policy = AdPacingPolicy::default(); | ||
| let last_ad = 1_000_000; | ||
| assert!(!should_show_ad( | ||
| last_ad + policy.min_interval_secs - 1, | ||
| Some(last_ad), | ||
| policy.sessions_before_first_ad, | ||
| policy | ||
| )); | ||
| assert!(should_show_ad( | ||
| last_ad + policy.min_interval_secs, | ||
| Some(last_ad), | ||
| policy.sessions_before_first_ad, | ||
| policy | ||
| )); | ||
| } | ||
|
|
||
| #[test] | ||
| fn ad_pacing_saturates_instead_of_panicking_on_a_time_regression() { | ||
| // `now < last_ad_unix_secs` (a clock rollback) must not panic via unsigned subtraction | ||
| // underflow -- `saturating_sub` clamps to 0, correctly withholding the ad. | ||
| let policy = AdPacingPolicy::default(); | ||
| assert!(!should_show_ad( | ||
| 0, | ||
| Some(1_000_000), | ||
| policy.sessions_before_first_ad, | ||
| policy | ||
| )); | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.