Skip to content

chars.js refresh from mod meta report + slot-aware slice + TODO rebuild#1

Open
Hungrr13 wants to merge 67 commits intomainfrom
claude/friendly-stonebraker-e36642
Open

chars.js refresh from mod meta report + slot-aware slice + TODO rebuild#1
Hungrr13 wants to merge 67 commits intomainfrom
claude/friendly-stonebraker-e36642

Conversation

@Hungrr13
Copy link
Copy Markdown
Owner

Summary

  • Refreshed src/data/chars.js from swgoh.gg's /stats/mod-meta-report/ (277 of 325 entries updated). Rewrote tools/verify-chars-vs-swgoh.js to parse the single-page meta report (explicit set icons + multi-primary tolerance) instead of per-character pages.
  • Added slot-aware mod badges on the Slice tab (Primary + Set match, Better/Same/Worse fit verdict via countAlignedForMatch, set filtering). Cache key bumped v2 -> v3.
  • Rebuilt docs/TODO.docx (35 rows) from the authoritative paste, restoring launch-prep, Comlink follow-up, branch hygiene, and tier-ladder items. Added tools/build-todo-doc.js so the docx is regenerable.
  • Worker scrape allow-list extended to cover /stats/mod-meta-report/. Added .cache/ and *.bak to .gitignore.

Test plan

  • node tools/verify-chars-vs-swgoh.js reports 0 mismatches
  • Slice tab shows Primary/Set match badges + Better/Same/Worse verdict on an owned character
  • Roster refresh clears stale v2 cache and repopulates under v3
  • docs/TODO.docx opens in Word/Google Docs with all 35 rows and status colors
  • Install the new build on-device and confirm no regressions on the overlay flow

Generated with Claude Code

Hungrr13 and others added 30 commits April 20, 2026 23:07
Card title already says "In your roster", so the green pill was noise.
Only the "Not unlocked" state needs explicit badging.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
A rounded Diamond with a set icon can smooth mask-only into Circle:0.97
stronglyRound=true with observed dCorner=0.92 — indistinguishable from
the Grievous-Circle case on those signals alone. The `outer` contour
candidate is the tiebreaker: a real Circle's outer trace scores
Circle >= 0.6 with circularity >= 0.70, while a Diamond's outer trace
sees the corners and scores Circle <= 0.35 with circularity <= 0.55.

New high-priority rescue fires before the winnerStronglyRound-gated
rules so over-smoothed Diamonds aren't blocked.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…oster re-ranking

Overlay character list now shows explicit per-character status:
  - "Not unlocked" when not in roster (free + premium)
  - "Owned" when in roster but mod data unavailable
  - "X/6 · Empty slot" when mod slots are incomplete
  - "6/6 · Upgrade (N↑)" when fully modded with upgrade candidates
  - "6/6 · Maxed" otherwise

Ownership lookup now fires on any loaded roster (free tier sees
owned/not-unlocked); mod-data badges still premium-gated since only
premium performs the roster sync.

Your Roster card in Slicer now numbers matches 1..N based on how many
roster characters matched, instead of inheriting the global rank from
Best Characters (e.g. #2 in Best becomes #1 in Your Roster).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Tapping the ally code input or Load button while roster lookup is
locked now opens an Alert explaining the feature requires a rewarded
ad or Premium, with a Watch Ad shortcut. Prevents the load from
running without an unlock.

Also unifies App.js mod-status gating: any loaded roster (ad-unlock
or Premium) gets ownership + mod badges. Truly free users don't have
a roster loaded and see no badges.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Popup title is now "Unlock Premium Features" with a body that lists
what gets unlocked (roster lookup, ownership badges, mod-status
recommendations), so users understand the value before tapping
Watch Ad.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Scaffolds premium GAC team-recommender feature end-to-end on the
infra side (no UI yet).

Worker (tools/roster-worker/worker.js):
  * New gacProbe=1 diagnostic that hits every candidate swgoh.gg GAC
    meta endpoint and reports shape/status, so we can pick whichever
    upstream actually serves squad win-rates.
  * New gac=3v3|5v5 route that returns the first candidate endpoint
    yielding usable data.

Client (src/services/gacMetaService.js):
  * fetchGacMeta(bracket) with 12h AsyncStorage cache.
  * Defensive normalizer accepts multiple possible upstream shapes so
    the worker can swap endpoints without client changes.
  * recommendSquads(payload, ownedBaseIds) ranks by
    (winRate * 0.7 + coverage * 0.3) and splits into offense / defense
    buckets, filtering out squads with <60% roster coverage.
  * probeGacEndpoints(bracket) passthrough for dev.

State (src/services/gacMetaState.js):
  * Pub/sub wrapper per bracket, matching rosterState pattern.

premiumState.FEATURES adds GAC_META key for future gating.

Next steps:
  * wrangler deploy, hit /?gacProbe=1&bracket=5v5 to confirm live shape.
  * Build GacScreen UI with 3v3/5v5 toggle + offense/defense split.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The worker now parses /gac/squads/ and /gac/who-to-attack/ HTML tables
(swgoh.gg has no JSON API for GAC meta) and walks back a few season_ids
until it finds a page matching the requested 3v3 or 5v5 bracket. Output
feeds gacMetaService.normalizeGacData and recommendSquads unchanged.

GacScreen adds a new tab gated behind premium / GAC_META rewarded unlock.
Renders top defense or offense squads for the chosen bracket, marks
missing roster members in red, and ranks by win-rate * coverage when a
roster is linked.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Locked users now see only a feature-list gate card ("what you unlock")
instead of the bracket/role toggles and an empty squad area. Upstream
fetch is skipped entirely until unlocked.

Also drops the "Source: swgoh.gg" footer and the swgoh.gg mention from
the subtitle — the data provider is an implementation detail.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Your Roster and Best Fit were showing the same characters when a roster
was linked — Best Fit was rendering the full matchedCharacters list, of
which the top rows are almost always owned, so the two cards were
visually identical. Best Fit now filters to non-owned matches so the
two cards carry disjoint, complementary information.

Mod status badges now key on the scanned shape instead of aggregating
all six slots. modSummary(roster, baseId, shape) returns slotMod /
slotEmpty / slotUpgradeable, which the overlay and Slice cards use to
render "Empty Circle" / "Upgrade Circle" / "Circle maxed" instead of
the generic "4/6 · Empty slot". Scanning a Circle now tells you about
the circle slot, not the whole loadout.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Characters owned but below mod level (or otherwise never modded) came
back with mods=[], which the slot-aware path demoted to "· Owned" —
unhelpful, since any scanned mod is by definition an upgrade over
nothing. Now a zero-mod unit reports slotEmpty=true for the scanned
shape, so the overlay + Slice screens render "Empty Circle slot" and
rank them correctly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…o 20

Free users who haven't linked a roster now see a premium pitch on the
overlay characters panel instead of a misleading top-6 list (we can't
score "upgrade over empty circle" without their roster). Also bumped
the cap from 6 to 20 — with ~330 characters in SWGOH, 6 is too tight to
surface real alternates.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace flat 'Speed › Offense% › Crit Chance%' line on each matched
character with colored chips that mark which priority slots the scanned
mod actually hit — green ✓ for aligned secondaries, purple ★ for a
primary-stat match, muted for unhit slots. Makes it obvious why the
engine ranked each character (which combines primary, set, and
secondary weights already).

Engine now returns alignedPriorityIndices + primaryPriorityIndex on
each match so the UI doesn't have to re-run the matching logic.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Tanks like Gamorrean Guard and Vandor Chewbacca have two builds: a
main (Speed-first) and an alt/tank build (Health% > Defense% > ... >
Speed). The engine picks whichever variant fits the scanned shell
better — correct behavior — but compact match rows hid the variant
label, making a legitimate alt-build pick look like wrong data.

Show 'Main build' / 'Alt build' on every row, and tag overlay entries
with '(alt)' when applicable.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Alt builds (buSecs) are now derived at runtime from SEC_FOCUS usage
research instead of hand-curated strings. Strategy: keep positions #1
and #2 from the main build, then append stats #5 and #6 from the full
usage-sorted list. Speed is locked — if the main build has Speed at
position N, alt keeps Speed at N. Characters whose research shows
Speed outside the top-6 (naturally slow) are respected; Speed is not
force-injected. Falls back to manual buSecs if SEC_FOCUS is missing.

Also adds a -12 finalScore penalty (with reason line) when the mod has
3+ revealed secondaries and none of them is Speed. Almost every
character wants Speed first; a speed-less mod should rank last even if
the shell otherwise matches.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds Recent-changes sections for the three April 2026 slice/overlay
updates, plus two new follow-up items (scanner dismiss on resume,
stat-comparative slot badges).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Priority-list matching now promotes flat stats to their % variant via a
new normalizePriorityName helper. A character priority of Health (flat)
is satisfied by a scanned Health% secondary, and vice versa — % is
strictly better than flat and should always count as aligned.

Derived alt builds coalesce flat + % usage counts per character before
picking positions 5/6, so alt priorities consistently emit the %
variant instead of leaking flat entries from SEC_FOCUS.

scoreEnteredSecondaries already applies FLAT_TIEBREAKER_MULTIPLIER
(0.25) when a scanned flat stat supports a % plan, so the inverse case
(flat scanned vs % priority) remains down-weighted as a 'shitty match'.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Premium users with a linked roster now see a single full-width Your
Roster card instead of the Your Roster + Best Fit split. Best Fit
(non-owned matches) added noise for users who specifically opted into
roster-filtered slice advice. Full-width also lets the match rows drop
out of compact mode so variant label + full priority-chip list render.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Rewrite src/data/chars.js against swgoh.gg/stats/mod-meta-report/ (277
  entries updated, 47 already correct, Cobb Vanth left as-is — not yet in
  the meta table).
- Add tools/verify-chars-vs-swgoh.js: fetches the meta report through our
  Cloudflare Worker, parses set icons + per-shape primary columns, diffs
  vs chars.js; supports multi-primary tolerance lists.
- Extend worker scrape allow-list to include /stats/.
- Slice tab: replace score-based Upgrade/Sidegrade/Downgrade with a
  count-based Better/Same/Worse fit that compares priority-aligned
  secondaries on scanned vs equipped mod. New countAlignedForMatch()
  export in sliceEngine.js.
- Move the priority-star indicator into dedicated Primary-stat-match
  (purple) + Set-match (yellow, substring-matched) badges under the
  character name. Priority chips now uniformly green when aligned.
- Filter character recommendations by the scanned mod's set (substring).
- Bump rosterService cache key v2 → v3 to invalidate caches predating
  mod primary/secondaries normalization.
- OverlayCaptureService tweaks + .gitignore for .cache/ and *.bak.
Flip three existing OPEN rows to DONE based on what shipped this session:
- Roster / premium: slot-aware empty/upgrade badges
- Slice screen layout: Your Roster card alongside Best Characters
- Slice screen scoring: secondaries weighted via aligned-priority count

Add five new DONE rows for the session-specific work: chars.js refresh
from swgoh.gg mod meta report, Primary stat + Set match badges,
Better/Same/Worse fit verdict, set-filtered recommendations, roster
cache-key bump.
Restores launch-prep items, Comlink-related context, branch hygiene,
tier-ladder slicing, and additional DONE entries that the prior
single-session edit had dropped. Adds build-todo-doc.js driver so the
docx is regenerable.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
compareScannedVsEquipped() already scores scanned vs equipped mod
against match priorities and emits Upgrade/Sidegrade/Downgrade +
scoreDelta + per-stat deltas.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
buildLadderPlan() walks the scanned mod's current tier through 6E using
rolled secondaries as signal. Emits one of four verdicts:

  USABLE        — worth slicing to 6E (mat cost justified)
  CAP_AT_5A     — finish the cheap 5A levels, skip 6-dot mats
  SELLABLE      — bail now, mats would be wasted
  NOT_SLICEABLE — already 6E, no tier, or no build uses this shell

Pre-5A steps each burn mats, so no-priority + no-Speed or minimal
priority quality triggers an early bail. The 5A→6E step also burns mats;
we require Speed evidence (3 rolls or speed arrow) or a high-SLICE_GAIN
priority stat rolled ≥65% quality to justify continuing.

SliceScreen renders a new ladder verdict card above the Decision card.
TODO row flipped to DONE.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Previously a 5B/5C mod with decent stats but no 6-dot catalyst fell
into Cap at 5A, which misleadingly suggested finishing the climb —
but 5B→5A still burns tier mats. Fixed:

  - Cap at 5A now only fires when tier === '5A' (money-only levels).
  - New FILLER verdict for pre-5A mods with decent fit: equip as-is,
    skip the tier-slice mats, replace when better lands.
  - Sellable reserved for genuinely weak rolls.

User-reported 5B case (Off%1r, Ten%2r, Prot%3r, Speed1r) now returns
Filler instead of the wrong Cap at 5A.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Defense% weight 5.0 -> 4.0 (community consensus ~3-4, was biasing tanks)
- strongUpside SLICE_GAIN cutoff 0.5 -> 0.3 (brings Protection% into the
  6E-catalyst bucket so Prot%-primary mods with strong Prot% rolls can
  earn a Usable verdict)
- hasDecentFit floor finalScore 40 -> 50 (Grandivory HOLD is 60; tighter
  floor avoids keeping mediocre mods as Filler too eagerly)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- READMEBEFOREEDITING: new sections covering the 5-state tier-ladder
  verdict (Usable / Cap at 5A / Filler / Sellable / Not sliceable) and
  the community-guidance tuning pass (Defense% 5.0 -> 4.0,
  strongUpside SLICE_GAIN cutoff 0.5 -> 0.3, hasDecentFit finalScore
  floor 40 -> 50).
- TODO: flip the tier-progression row to reflect the 5-state verdict,
  add a new DONE row for the community-tuning audit.
- Rebuild docs/TODO.docx from tools/build-todo-doc.js.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two related fixes for the slice verdict flow.

1. New SLICE_NEXT verdict (cyan) for pre-5A mods with catalyst
   potential. Instead of projecting an end-state verdict from
   current rolls, the engine recommends "slice one tier, re-check"
   — which matches how slicing actually works. Each tier slice
   adds one random roll across the 4 revealed secondaries, so a
   5C mod with Speed at 1 roll shouldn't be pre-judged against
   the 5A end-state. Gate: Speed secondary with rolls<5 OR a
   priority stat with SLICE_GAIN >= 0.3. Firing order puts
   SLICE_NEXT after the definitive-Usable checks so clear-cut
   "worth 6-dot" mods still return USABLE.

2. Tier letter OCR. extractModTier() in modCaptureParser.js
   reads E/D/C/B/A from the mod-card OCR (patterns: "Tier C",
   "LVL 15 · C", "Level 15 A", plus a standalone-letter fallback
   requiring a nearby LVL/LEVEL token). Threaded through
   parsed.modTier -> App.js slicePrefill.tier -> SliceScreen so
   the tier pill auto-selects from the scan. When OCR misses,
   SliceScreen now clears tier to '' on prefill instead of
   leaving the previous '5A' default — the ladder plan falls
   through to "Not sliceable — No tier selected" and the user
   picks manually. Prior behaviour silently showed wrong 5A
   verdicts on every scan.

6E (6-dot) isn't auto-detected yet (needs native pip-count
detection) — user taps 6E manually on 6-dot mods. Flagged as
OPEN follow-up in TODO.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Each SWGOH tier slice adds one random roll to one secondary, so the
physical max rolls per secondary is 5E=1, 5D=2, 5C=3, 5B=4, 5A=5.
The previous estimateRolls() used a hard cap of 5 and ignored the
scanned tier, which produced impossible outputs like "Health% at
5 rolls" on a 5C scan.

- maxRollsForTier() export in overlayRecommendation.js.
- estimateRolls(stat, value, dotLevel, tier) respects the tier cap
  and also rejects outright when the value exceeds tier*max*1.02
  (OCR-misread guard: e.g. Health% with value 30 can't fit any
  roll count, so null is preferable to a confidently wrong
  estimate).
- App.js prefill now clamps explicit OCR rolls to tier ceiling and
  passes the scanned tier into estimateRolls.
- ModOverlayCaptureService now dumps the full OCR text to
  <externalFilesDir>/ocr-debug-last.txt on each capture so
  scan-miss diagnostics (tier letter missed, stat misread) can be
  retrieved via adb pull without waiting for another misfire.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Hungrr13 and others added 30 commits April 22, 2026 11:57
Two OPEN items queued for future work: (1) scans miss the tier letter
on some cards despite the existing extractModTier patterns -- need
fresh per-tier OCR dumps to audit which patterns are failing; (2)
investigate whether the ladder should nudge toward B->A slicing when
speed has been missed on consecutive prior slices.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two more OPEN items: (1) some scanned secondaries don't make it to the
Slice tab even when the tier is picked up correctly -- need a trace on
a failing ocr-debug-last.txt to find where rows are being dropped;
(2) add a Mine/All toggle on the GAC tab so users can see the full
meta regardless of roster coverage, not just what they can field.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a third toggle row (shown when a roster is linked) that switches
between the existing coverage-filtered ranking (>=60% members owned)
and a full-meta view sorted by raw win-rate. All-meta mode still
computes per-squad ownedCount + coverage so missing-member red chips
keep working. No-roster case collapses into the same code path via
showAll = !hasRoster || view === 'all'.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- modCaptureParser: detect standalone tier letter (D/C/B/A) above
  PRIMARY STAT so 5D arrow dumps mark tier correctly.
- modCaptureParser: pre-clean OCR "0" -> "O" on stat name boundary so
  "0ffense" parses as Offense.
- modCaptureParser: dedup secondaries by post-promotion canonical stat
  name via willPromoteToPercent() so flat Health (N) and Health% can
  coexist instead of the second entry getting dropped.
- overlayRecommendation: use ladderPlan verdict/desc as source of
  truth, matching Slice tab. Fixes overlay saying FILLER ONLY while
  Slice tab says Slice to 5C on the same mod.
- charBaseIds: Crosshair (Scarred) maps to CROSSHAIRS3 (swgoh.gg's
  real base id) so GAC meta list renders the name instead of the id.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- SlicerWhyPanel: per-secondary rows now include numeric value and
  Good/Great/Max thresholds so the separate Stat Quality card can go
  away. One card instead of two for the same data.
- SliceScreen: drop standalone Stat Quality card and hide the Analysis
  (reasonLines) card; the headline reason already renders on the
  verdict card and the full list was making the page too long.
- sliceEngine.getNextHitNarrative: worst-case next hit now uses the
  same targetWeight * upsidePct formula as best-case. Prior version
  picked by lowest targetWeight alone, which surfaced Defense% as
  worst even when flat Health / flat Offense were clearly lower-
  ceiling re-roll outcomes.
- modCaptureParser.extractModTier: widen the standalone tier-letter
  scan window to everything before "SECONDARY STATS" (was stopping at
  "PRIMARY STAT") so layouts where a primary stat renders above the
  tier letter still pick up the tier.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- SlicerWhyPanel: Per-Secondary section is now collapsed by default
  with a chevron toggle, so the page isn't dominated by the expanded
  per-stat rows.
- sliceEngine.getNextHitNarrative: worst-case next hit now prefers
  flat stats that don't match the character's build (isFlatTieBreaker)
  when any are present. A flat re-roll is always a worse outcome than
  a %-stat re-roll, regardless of pure targetWeight/upside math.
- overlayRecommendation: pass parsed.modTier to every evaluateSliceMod
  call. Without it, the overlay's ladder plan fell through to
  "No tier selected" even when the Slice tab was correctly showing
  the ladder verdict for the same mod.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The icon classifier occasionally misreads Diamond as Circle (and
similar pairs). When that happens the parser has an independent
signal — the primary stat — that the Circle call is impossible:
Circle only allows Health%/Protection%, so a Defense%-primary mod
can't be a Circle.

chooseShape() now walks ranked classifier candidates and picks the
best one that's compatible with the OCR'd primary, falling back to
the primary's only-allowed shape (Diamond for Defense%, Square for
Offense%) when no candidate is compatible.

Also lower the Defense% → Diamond inference threshold from >=15% to
>=8% so 5-dot Diamond Defense% primaries (11.75%) trigger the
Diamond inference instead of falling through.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Primary-value-by-slot is brittle data to bake in, and Arrow/Triangle/Cross
can all roll Defense% primary. Keep the primary-shape constraint (Circle/
Square still rejected when Defense% is the primary) and rely on the icon
classifier's ranked candidates to pick between Arrow/Triangle/Cross/Diamond.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
SWGOH primary stat values are deterministic per slot × dot-tier. Some values
uniquely identify a shape (11.75% Defense = 5-dot Diamond, 15% Defense =
6-dot Arrow, 20% Defense = 6-dot Diamond). When the OCR'd value matches one
of these, trust the inference over the icon classifier — the value table is
authoritative, the classifier is probabilistic.

Ambiguous values like 7.5% Defense (5-dot Arrow or 6-dot Triangle/Cross)
fall through to the classifier + primary-shape compat filter.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…c not slot

Primary stat values in SWGOH are determined by mod dot-tier (1→6 dots), not
by slot. 11.75% Defense is just the 5-dot primary value and can appear on
Arrow/Triangle/Cross/Diamond alike; 20% is the 6-dot value across the same
set. Value-based shape inference was wrong — relying on the primary-shape
compat filter + icon classifier ranking only.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The icon classifier evaluates four shape variants (outer/inner/unguided/
mask-only) but only the winner's top-N rankings reach JS. When the winning
variant's top pick is rejected by the primary-shape filter (e.g. a Diamond
with a rounded outer rim wins as Circle, then Circle gets rejected because
Circle can't have Defense%), falling back to the winner's #2 often picks
Cross — another wrong answer — because the outer contour is biased toward
Cross for rounded-rim Diamonds.

Now expose all variant top-N rankings to JS as JSON. When the primary filter
rejects the winner, the JS consensusShape() picks via:
  1. If mask-only has a clear top-1 (margin >= 0.08 over its #2) among
     primary-compatible shapes → trust it. mask-only reads the inner icon
     shape directly, so it nails the Diamond-with-rounded-rim case.
  2. Otherwise sum all variant scores for compatible shapes, return highest.

For the reported Diamond/Potency/Defense scan: mask-only picks Diamond 0.61
with Cross 0.49 as #2 — margin 0.12, so Diamond wins.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Subscribe to premiumState and return null when isPremium. Placeholder and
live BannerAd both bypass; no surrounding wrapper to adjust.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Their thin silhouettes read smaller than the filled shapes at the same
bounding-box size. Keep the parent wrapper at the requested size so layout
is unchanged; scale only the inner Image.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Permissions row now reflects MediaProjection grant post-install (user
verified). Missed-speed streak nudge decided against — slicing a low-Speed
mod on streak logic wastes mats; the high-Speed partial case is already
covered by SLICE_NEXT.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Dropped the speedBacked path (2 rolls + 55% quality -> Usable) and added
a Speed value floor of 14 to speedHitHard. Mods with low-value Speed on
few rolls now fall to SLICE_NEXT instead of auto-committing 6-dot mats.
Aligns with the community "Speed >= 15 before 6-dot" rule.

Also unified overlayRecommendation with SliceScreen: both now evaluate
against the full DECODED_CHARS pool so roster filtering can't produce
conflicting verdicts (overlay "don't slice" vs Slice tab "->6E").
Ownership stays visible via the per-character status badges.

GuideModal Slicer page, READMEBEFOREEDITING.md, and TODO.docx updated.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The in-app Guide Me was still showing 4 tabs. App now has 5 (Lookup,
Finder, Slice, Scanner, GAC). Added a GAC Meta page covering bracket
toggle, Mine vs All view, and the premium/rewarded-ad gate. Also
switched the Scanner eyebrow icon from the old camera glyph to the
same one the tab bar renders so the two stay in sync.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Previously all 140 matchable characters rendered in Best Characters /
Your Roster. matchedCharacters is already sorted desc by matchScore,
so slicing to 25 surfaces the strongest fits without reshuffling.
Card headers show "Top 25 of N" when truncated, plain "N" otherwise.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Classifier debug PNGs were landing at native resolution (~100KB+), which
broke Claude's Read tool on replay. Added a System.Drawing bicubic
downscale pass inside Pull-SafePng that caps the longest edge at 400px.
Typical crops drop to ~20-40KB while retaining enough detail to eyeball
shape/mask decisions. -Full switch bypasses when native res is needed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Overlay + MediaProjection rationale dialogs already fired before their
system prompts; the copy now hits every Play-reviewer checklist item
explicitly. Overlay dialog calls out user-initiated only, no foreign
app content access, no input simulation. Capture dialog calls out
user-initiated only, on-device parsing, immediate image discard, no
cloud or ad upload. Bulleted layout makes the "no automation" and
"no tracking" claims easy to verify in a review pass.

Closed the matching TODO rows: rationale UX and FOREGROUND_SERVICE
manifest declarations were already wired but the doc lagged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Delete archive/ (53 legacy files) and src/data/chars.js.bak. Pre-cleanup
  backup pushed to backup/pre-cleanup-2026-04-24 and chars.js.bak copied to
  C:/Users/Chad/my-app/backups/ before deletion.
- Extract duplicated DECODED_CHARS + ENGINE_SLICE_REF to src/data/charDecoding.js;
  overlayRecommendation.js and SliceScreen.js both import from it.
- Replace duplicated decisionDefinition() helpers with single
  getDecisionDescription() export on sliceEngine.js so the verdict wording
  matches across the Slice tab and overlay.
- Add tools/README.md cataloguing all 22 dev scripts.
- Generate light-mode shape variants. Each {shape}.png has a metallic
  frame plus dark inner cavity + dark outer aura that read as black blobs
  on a light background. New tools/gen-shape-lightmode.py rewrites pixels
  with max(R,G,B) < 55 to white, preserving alpha. Outputs
  assets/shapes/{shape}-light.png. ModShapeIcon.js reads isDark from the
  theme and switches sources accordingly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…fault

useState(true) keeps dark as the cold-start default for fresh installs.
On mount we hydrate @modforge/themeIsDark from AsyncStorage; if a
persisted value exists, swap to it (brief dark flash on launch is
acceptable, matches premiumState/rosterState pattern). toggleTheme
writes the new value to storage so a user who picks light keeps light
on next launch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Refactor appTheme to expose hydrateTheme() with module-level cache, then
await it in App.js's warm-up sequence before dismissing LoadingScreen. A
returning light-mode user no longer sees a one-frame flash of dark before
the swap — the persisted value is already in cachedIsDark by the time the
provider mounts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Android occasionally remounts the React tree inside the same JS process
(activity recreation from config changes / overlay scanner attaching),
which fires AppThemeProvider's useEffect a second time. The .then was
calling setIsDark with the value the hydrate promise originally resolved
to — i.e. the AsyncStorage value at app launch — which clobbered any
toggle the user made afterwards.

Switch the .then to read from the live cachedIsDark module variable
(toggleTheme writes to it synchronously) so remounts re-sync to the
current state instead of the launch-time state.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Slows reverse-engineering of the Kotlin overlay/IAP modules and strips
debug symbols on release. JS bundle was already protected by Hermes
bytecode (hermesEnabled=true).

  - android.enableMinifyInReleaseBuilds=true
  - android.enableShrinkResourcesInReleaseBuilds=true

ProGuard keep rules pin the surfaces R8 can't see through reflection:

  - com.hungrr13.modhelper.overlay.** + com.hungrr13.modhelper.iap.**
    so the JS bridge can find @ReactMethod methods by string lookup
  - @ReactMethod methods on ReactContextBaseJavaModule subclasses
  - java.security.** + javax.crypto.** for the IAP signature verifier
    (R8 would otherwise strip these on the assumption nothing reflects
    into the security provider)

R8 was failing the release build on missing classes referenced by the
Google Mobile Ads SDK that target API 35+ (current compileSdkVersion
is 34). Suppressed with -dontwarn:

  - android.media.LoudnessCodecController
  - android.media.LoudnessCodecController$OnLoudnessCodecUpdateListener

These are guarded at runtime by SDK_INT checks inside AdMob, so the
suppression is safe. Add new entries here as R8 reports more.

Verified on R5CX10W4LJY (SM-S928U): release build installs cleanly,
JS bundle loads ("Running main"), no ClassNotFoundException /
NoSuchMethodError / UnsatisfiedLinkError in PID-scoped logcat.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a Kotlin native module that verifies Google Play purchase
signatures against the Play License public key. RSA-SHA1 is Google
Play Billing's documented signing algorithm, so we use
Signature.getInstance("SHA1withRSA") + X509EncodedKeySpec on the
Base64-decoded SubjectPublicKeyInfo bytes; the receipt JSON is
signed as raw UTF-8. Fail-closed on malformed Base64.

JS wrapper at src/services/iapVerifier.js handles both
react-native-iap field shapes (dataAndroid/originalJson +
signatureAndroid/signature) and exposes verifyPurchase(purchase)
returning {ok, verified} or {ok:false, reason:...}.

Public key slot lives at src/config/playLicense.js, currently empty.
While empty, isConfigured() returns false and callers fall open --
acceptable for dev/sideload, NOT for prod. Before producing the
Play release build, paste the Base64 RSA public key from
Play Console -> Monetize -> Monetization setup.

Threat model: this raises the bar against Frida/Xposed hooks that
intercept react-native-iap and emit synthetic purchase events. It
does NOT make client-side gating cryptographically secure --
attackers with root can still patch the JS bundle or call
premiumState.setPremium(true) directly. Defense-in-depth, not
a single perimeter.

Native module is registered in MainApplication.kt alongside
ModOverlayCapturePackage; @ReactMethod surface is preserved by
the keep rules already in proguard-rules.pro.

NOTE: react-native-iap is not currently installed (lazy require
in iap.js fails open), so this verifier is inert until the
dependency is added. See TODO doc.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds reconcileWithPlay({timeoutMs}) to src/services/iap.js. On every
cold start, App.js warm-up queries getAvailablePurchases through
react-native-iap, filters to PREMIUM_SKU, and overwrites
premiumState to match Play's source of truth -- correcting:

  - manual AsyncStorage flag flips on rooted devices
  - sideloaded patched APKs that pre-populate the cache
  - emulator runs that bypass the original purchase
  - Google-side refunds and chargebacks (premiumState gets
    revoked on next launch)

Behaviour:
  - Play says owned        -> setPremium(true)
  - Play says not owned    -> setPremium(false), revoking cache
  - IAP unavailable / Play
    offline / query timeout -> leave cache alone (don't punish
                              offline users on flaky connections)

3-second timeout via Promise.race so warm-up never stalls.

Also wires signature verification into the purchaseUpdatedListener
and restorePurchases paths via a new findVerifiedPremium() helper.
When iapVerifier.isConfigured() (Play License key populated), every
PREMIUM_SKU candidate must verify against the key before unlocking.
Invalid signatures drop the event WITHOUT finishTransaction so Play
retries -- if it's genuine, the next cold-start reconcile picks it
up and re-verifies. When the key is empty (current shipped state),
the verifier falls open so dev/sideload builds keep working.

Verified on R5CX10W4LJY (SM-S928U): cold start with Premium owned
keeps Premium unlocked; cold start with Premium not owned keeps it
locked. No FATAL, no Unhandled Promise Rejection in PID-scoped
logcat. (react-native-iap is not yet installed, so reconcile
returns iap-unavailable immediately and the verifier is unreached
-- the wiring is forward-compatible for when the dep is added.)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
READMEBEFOREEDITING.md:
  - New "Premium hardening" section under Recent changes covering
    cold-start reconcile, native RSA-SHA1 verifier, Play license
    key slot, R8 + ProGuard enablement, R8 dontwarn for AdMob
    missing classes, and the threat model
  - New "Theme persistence" section documenting the hydrateTheme()
    gate, the live-cache useEffect that survives Android Activity
    recreation remounts, and the related commits 5e90623 / 248b270
    / 44d5451
  - Follow-ups list updated: populate PLAY_LICENSE_PUBLIC_KEY before
    Play release; remaining blocker (R8 missing classes) marked as
    resolved by the dontwarn entries

tools/build-todo-doc.js + docs/TODO.docx (regenerated):
  - DONE: Theme persistence (hydration gate + live cache)
  - DONE: Premium cold-start reconcile
  - DONE: Premium signature verification
  - DONE: Premium R8 + ProGuard enablement
  - DONE: Repo hygiene -- mod-source-html/_files/ already removed
    from working tree and not tracked on either branch (user kept
    external backup)
  - OPEN: react-native-iap dependency installation -- without it
    the IAP hardening pipeline is inert; iap.isAvailable() returns
    false because the lazy require fails. Plan: npx expo install
    react-native-iap (v12+ for the requestPurchase shape iap.js
    already uses), wire BILLING permission, validate with a real
    Play Console internal-test purchase
  - OPEN: Populate PLAY_LICENSE_PUBLIC_KEY in src/config/playLicense.js
    before Play release; consider a Gradle assertion that fails the
    release build if the constant is empty

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… live

react-native-iap was missing from the dep graph, so the lazy require()
pattern in src/services/iap.js was always falling open: iap.isAvailable()
returned false, reconcileWithPlay() short-circuited with iap-unavailable,
and ModForgeIapVerifier was unreachable. The hardening pipeline was inert.

Wire it up:
- Pin react-native-iap@^12.16.4 (matches the requestPurchase({sku, skus:[]})
  / getProducts shape iap.js was already written for; v15 ships Nitro
  Modules with renamed APIs and would mean rewriting iap.js + flipping
  newArchEnabled, much bigger change).
- AndroidManifest.xml: add com.android.vending.BILLING. The library does
  NOT auto-add it via manifest merge.
- android/app/build.gradle: pin missingDimensionStrategy 'store', 'play'.
  react-native-iap ships dual play/amazon flavors and Gradle errors out
  on ambiguity without an explicit pick.

Patch RN 0.81 currentActivity break:
- ReactContextBaseJavaModule was converted Java->Kotlin in RN 0.81. The
  Java-getter-as-Kotlin-property syntax (currentActivity) stopped
  resolving for downstream Kotlin modules; only getCurrentActivity()
  works now.
- Both react-native-iap and react-native-google-mobile-ads tripped on
  this. Patches via patch-package@^8.0.1 + postinstall hook so they
  survive npm install. ~7 sites total across RNIapModule.kt,
  ReactNativeGoogleMobileAdsModule.kt, FullScreenAdModule.kt.

Bump Gradle daemon JVM:
- -Xmx4g / -XX:MaxMetaspaceSize=1024m (up from 2g/512m). Adding iap to
  the classpath pushed Kotlin-compile + R8 metadata over the old 512m
  ceiling. Symptom was ugly: daemon stayed alive at full CPU running
  endless GC cycles, no APK output, only visible by tailing
  ~/.gradle/daemon/8.14.3/daemon-<pid>.out.log.

Verified on R5CX10W4LJY (SM-S928U) 2026-04-25:
- BUILD SUCCESSFUL in 2m 28s, installRelease landed clean.
- Cold-start logcat shows three RNIapModule: responseCode: 0 lines
  within 3s of bundle load (initConnection, getProducts,
  getAvailablePurchases = reconcileWithPlay).
- No FATAL EXCEPTION, no stripped-class errors, no unhandled rejections.

PLAY_LICENSE_PUBLIC_KEY remains empty pending Play Console identity
verification; signature verifier still fails open in the meantime.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…TODO

README:
- Premium hardening section now reflects the live state: react-native-iap
  is installed and reconciling against Play (responseCode: 0 in logcat),
  R8 builds cleanly with the LoudnessCodecController -dontwarn pair,
  Gradle daemon JVM bumped to 4g/1g to survive Kotlin+R8 with the
  expanded classpath.
- Drop the "known build blocker" bullet (resolved).
- Follow-ups: drop the R8 entry, soften the license-key entry to note
  Play Console identity verification is the gating step.

TODO.docx:
- Premium / react-native-iap dependency: OPEN -> DONE with the install
  details (flavor pin, BILLING perm, currentActivity patches, JVM bump,
  on-device verification).
- Build / R8 missing classes: BLOCKED -> DONE (-dontwarn entries).
- New row: Build / Gradle JVM heap + metaspace (records the 2g/512m ->
  4g/1g bump and the silent-OOM symptom for next time).
- Premium / Play License key: still OPEN, with a note that Play Console
  identity verification is currently gating it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant