soma.cell

Pity banner — the 90-pull guarantee that cannot be skipped

Gacha games promise a "pity timer": a guaranteed top-rarity drop by pull 90. When the counter resets wrongly — and players HAVE caught smaller gachas dropping pity across banner switches, via crowd-sourced drop statistics — it is a consumer-protection violation: people paid for odds they did not get. Here the promise is not in the drop code. The counter lives behind `invariant pity <= 89`: a 90th consecutive miss CANNOT BE STORED. The only write that survives at 89 misses is the reset that arrives together with the guaranteed legendary.

A Soma program from the games_economy part of the verified corpus: 73 lines, cells Banner, BannerTests. It passes soma check with soma 3.2.4.

How it is built

The cell Banner declares 4 signals in its face: pull_once(player: String, rolled_legendary: Bool) -> Map, dry_pulls(player: String, n: Int) -> Int, pity_of(player: String) -> Int and legendaries_of(player: String) -> Int; keeps 2 memory slots: pity (Map<String, Int>, persistent and consistent) under the invariant pity >= 0 && pity <= 89 and legendary (Map<String, Int>, persistent and consistent) under the invariant legendary >= 0. Handlers: pull_once (line 29), dry_pulls (line 43), pity_of (line 48) and legendaries_of (line 49).

The file also holds 1 test cell (BannerTests) with 12 assertions, run by soma test.

Generated from soma describe on this file.

The source

pity_banner.cell

// ============================================================================
// PITY BANNER — the 90-pull guarantee that cannot be skipped
// ============================================================================
// Gacha games promise a "pity timer": a guaranteed top-rarity drop by pull
// 90. When the counter resets wrongly — and players HAVE caught smaller
// gachas dropping pity across banner switches, via crowd-sourced drop
// statistics — it is a consumer-protection violation: people paid for odds
// they did not get. Here the promise is not in the drop code. The counter
// lives behind `invariant pity <= 89`: a 90th consecutive miss CANNOT BE
// STORED. The only write that survives at 89 misses is the reset that
// arrives together with the guaranteed legendary.
// ============================================================================

cell Banner {
    face {
        signal pull_once(player: String, rolled_legendary: Bool) -> Map
        signal dry_pulls(player: String, n: Int) -> Int
        signal pity_of(player: String) -> Int
        signal legendaries_of(player: String) -> Int
    }

    memory {
        pity: Map<String, Int> [persistent, consistent]
        invariant pity >= 0 && pity <= 89
        legendary: Map<String, Int> [persistent, consistent]
        invariant legendary >= 0
    }

    on pull_once(player: String, rolled_legendary: Bool) {
        let misses = pity.get(player) ?? 0
        if rolled_legendary || misses == 89 {
            // the guarantee: at 89 prior misses the drop is FORCED, because
            // storing miss number 90 is impossible
            pity.set(player, 0)
            legendary.set(player, (legendary.get(player) ?? 0) + 1)
            return map("ok", true, "rarity", "legendary", "forced", misses == 89)
        }
        pity.set(player, misses + 1)
        return map("ok", true, "rarity", "common", "pity", misses + 1)
    }

    // n unlucky pulls in a row (rng said no every time); returns the counter
    on dry_pulls(player: String, n: Int) {
        for i in range(0, n) { pull_once(player, false) }
        return pity_of(player)
    }

    on pity_of(player: String) { return pity.get(player) ?? 0 }
    on legendaries_of(player: String) { return legendary.get(player) ?? 0 }
}

cell test BannerTests {
    rules {
        // 89 dry pulls: the counter climbs to its ceiling
        assert dry_pulls("kei", 89) == 89
        assert legendaries_of("kei") == 0
        // miss #90 is UNREPRESENTABLE — the ledger refuses the write
        assert_fails pity.set("kei", 90)
        // ...so the 90th pull MUST produce the legendary, and reset
        assert pull_once("kei", false).rarity == "legendary"
        assert pity_of("kei") == 0
        // the counter starts climbing again from zero
        assert pull_once("kei", false).rarity == "common"
        assert pity_of("kei") == 1
        assert legendaries_of("kei") == 1
        // a lucky early roll also resets the counter
        assert pull_once("kei", true).rarity == "legendary"
        assert pity_of("kei") == 0
        assert legendaries_of("kei") == 2
        assert_fails pity.set("kei", -1)
    }
}

What the toolchain says

Run on a fresh copy of the program at build time; the outputs are pasted unedited.

$ soma check pity_banner.cell
✓ All checks passed.
$ soma test pity_banner.cell
test BannerTests ...
  ✓ assert dry_pulls("kei", 89) == 89
  ✓ assert legendaries_of("kei") == 0
  ✓ assert_fails pity.set("kei", 90) — raised memory invariant violated on 'pity': pity >= 0 && pity <= 89 — rejected write of 90 (key "kei"); the slot is unchanged
  ✓ assert pull_once("kei", false).rarity == "legendary"
  ✓ assert pity_of("kei") == 0
  ✓ assert pull_once("kei", false).rarity == "common"
  ✓ assert pity_of("kei") == 1
  ✓ assert legendaries_of("kei") == 1
  ✓ assert pull_once("kei", true).rarity == "legendary"
  ✓ assert pity_of("kei") == 0
  ✓ assert legendaries_of("kei") == 2
  ✓ assert_fails pity.set("kei", -1) — raised memory invariant violated on 'pity': pity >= 0 && pity <= 89 — rejected write of -1 (key "kei"); the slot is unchanged

12 tests: 12 passed, 0 failed
$ soma verify pity_banner.cell
Verifying pity_banner.cell...

Banner/invariants:

  ✓ invariant pity >= 0 && pity <= 89 — writer 'pull_once' proven (writes 0)
  ✓ invariant pity >= 0 — writer 'pull_once' proven by induction (writes misses + 1)
  ⚠ invariant pity >= 0 && pity <= 89 — runtime-checked (computed values): pull_once → pity [pity <= 89] because `misses + 1` is only known to lie in [1, 90] — narrow it: `require (misses + 1) <= 89 else …`
  ✓ invariant legendary >= 0 — writer 'pull_once' proven by induction (writes (legendary.get(player) ?? 0) + 1)

3 passed, 1 warnings, 0 failures
Temporal: 0 passed, 0 failed
VERIFY OK

Builtins used

map, range

Source file: /corpus/games_economy/pity_banner.cell · examples/corpus/games_economy/pity_banner.cell