From a0bca188cebcdf57a6d91625ec905c80f32c7fd1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 10:41:19 +0000 Subject: [PATCH 1/7] Plan Phase 7: engine accuracy, abilities/items, BST-aware suggestions Adds docs/plan/phase-7-accuracy-and-customization.md, the executable plan for the next phase, plus its pointers in CLAUDE.md and docs/plan/README.md. The plan opens with an audit measured against the pinned dataset revision, not recalled: - The suggestion ranking degenerates to "lowest Pokedex id" once a team's coverage is complete: every candidate ties on the composite score, so the entryId tie-break decides. That is exactly the reported Raticate/Persian/Kangaskhan output. - PokemonEntry.defaultAbility carries the raw PokeAPI slug, so the same ability renders as "sap-sipper" in the field and "Sap Sipper" in the picker one row below. - prettify() cannot produce correct English names (Double-Edge, U-turn, Well-Baked Body); only ability_names.csv / move_names.csv can. - Ten defensive abilities that change type effectiveness are unmodelled, carried by 26 catalogue forms, 16 of them as the slot-1 default; dry-skin is modelled only halfway; AbilityEffectSide.OFFENSIVE is declared but never constructed or consumed. - Held items are absent from the whole app, and Showdown import discards them. - findEntry() is a linear scan run once per candidate. The plan then specifies the dataset additions (four CSVs, 212,818 to 578,165 bytes, measured), the per-generation BST rule with worked examples and the Gen-I five-stat decision, the canonical-plus-custom ability picker, the defensive-item subset with an explicit application order, the BST tie-break (score formula and its shared weights untouched), the 5-10 suggestion-count setting, Room v3, and the test suite that has to prove it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011j5jieoE4nqU2ZBSM7USHc --- CLAUDE.md | 6 + docs/plan/README.md | 1 + .../phase-7-accuracy-and-customization.md | 870 ++++++++++++++++++ 3 files changed, 877 insertions(+) create mode 100644 docs/plan/phase-7-accuracy-and-customization.md diff --git a/CLAUDE.md b/CLAUDE.md index c482656..7f67751 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,6 +16,9 @@ before editing anything, then read the phase plan you are executing. - [`docs/test-plan.md`](docs/test-plan.md) — manual, on-device verification. One new section per phase; one "Known regressions" entry per real bug found. - [`CHANGELOG.md`](CHANGELOG.md) — one entry per release, updated as you go. +- [`docs/plan/phase-7-accuracy-and-customization.md`](docs/plan/phase-7-accuracy-and-customization.md) + — the next phase's plan: what is wrong with the engines today (measured, + with the dataset evidence) and exactly what to build. ## What this project is @@ -69,6 +72,9 @@ the app is native-only from here on. - **Phase 4 — Suggestions and generator**: ✅ done - **Phase 5 — Import/export and settings**: ✅ done - **Phase 6 — Release**: ✅ done +- **Phase 7 — Engine accuracy, abilities/items, BST ranking**: 📋 planned, + not started — see + [`docs/plan/phase-7-accuracy-and-customization.md`](docs/plan/phase-7-accuracy-and-customization.md) Tick these off as phases land — here and in [`docs/plan/README.md`](docs/plan/README.md). Do not implement anything not diff --git a/docs/plan/README.md b/docs/plan/README.md index 9c9a093..dfd2717 100644 --- a/docs/plan/README.md +++ b/docs/plan/README.md @@ -23,6 +23,7 @@ says so explicitly and tells you what to do about it. | 4 | [`phase-4-suggestions-and-generator.md`](phase-4-suggestions-and-generator.md) | Suggestion engine, composite scoring, Surprise Me generator | | 5 | [`phase-5-import-export-and-settings.md`](phase-5-import-export-and-settings.md) | Showdown import/export, settings, theme and language, local backup | | 6 | [`phase-6-release.md`](phase-6-release.md) | Signing, release pipeline, docs rewrite, `legacy-web/` deleted | +| 7 | [`phase-7-accuracy-and-customization.md`](phase-7-accuracy-and-customization.md) | **Planned, not started.** Base stats + correct English ability/move names in the dataset, canonical-vs-custom ability picking, held items (defensive subset), BST tie-break for suggestions, configurable suggestion count, and the ability-effect gaps the Phase 7 audit found | Read before starting any phase: [`../../CLAUDE.md`](../../CLAUDE.md), [`native-spec.md`](native-spec.md), this file, the phase file itself, and diff --git a/docs/plan/phase-7-accuracy-and-customization.md b/docs/plan/phase-7-accuracy-and-customization.md new file mode 100644 index 0000000..724285b --- /dev/null +++ b/docs/plan/phase-7-accuracy-and-customization.md @@ -0,0 +1,870 @@ +# Phase 7 — Engine accuracy, ability/item modelling, BST-aware suggestions + +**Status:** planned, not started. +**Executed by:** one agent session, in the task order below. +**Read first:** [`../../CLAUDE.md`](../../CLAUDE.md), [`README.md`](README.md) +(working rules), [`native-spec.md`](native-spec.md), +[`reference-pokedata.md`](reference-pokedata.md), +[`../post-migration-review.md`](../post-migration-review.md). + +This is the first phase after the six-phase native rewrite closed. Unlike +Phases 0–6 it has no `legacy-web/` oracle: that directory was deleted in +Phase 6, and several items here are **deliberate corrections of ported +behaviour**, not ports. Where this file and a Phase 0–6 doc disagree, this +file wins, and the divergence must be written into +[`../implementation-decisions.md`](../implementation-decisions.md) under a +new "Phase 7" heading as you go. + +--- + +## 0. Why this phase exists — the pre-plan audit + +Everything in this section was **measured against the pinned dataset +revision `d4f9a4af58ade123fbc0558f68b1c69daa97d9e4`** during planning, not +recalled. The numbers are reproducible with `curl` against +`https://raw.githubusercontent.com/PokeAPI/pokeapi//data/v2/csv/`. + +### 0.1 The suggestion list degenerates to "lowest Pokédex id" + +`domain/suggestion/SuggestionEngine.kt`'s ranking comparator is: + +```kotlin +compareByDescending { it.bestScore } + .thenByDescending { it.isFinal } + .thenBy { it.entryId } +``` + +and `bestScore` is `Scoring.kt`'s composite: + +``` +offensiveGain − 0.5 × newWeaknesses.size − 1.0 × aggravatedWeaknesses.size +``` + +When a full team already covers all 18 types, `offensiveGain` is `0` or +negative **for every candidate**, so the score collapses to +*"minus the number of distinct types that hit the candidate for more than +1×"*. The best possible score therefore belongs to whatever typing has the +fewest weakness types — pure Normal has exactly one (Fighting) — and every +pure-Normal final evolution ties at `−0.5`. The `thenBy { entryId }` +tie-break then sorts those ties by ascending Pokédex id. + +That is exactly the screenshot the user reported: Raticate (id 20), Persian +(53), Kangaskhan (115) — the three lowest-id pure-Normal final evolutions, +all at `Punteggio: −0,5`. The second reported case ("it suggested another +Water/Ground instead of Dragonite") is the same mechanism: Water/Ground has +a single weakness type (Grass), so it ties at the top of the penalty term. + +Two further consequences worth knowing before touching this code: + +- **`weaknesses()` counts types, not magnitude.** A 4× weakness and a 2× + weakness both contribute exactly `1` to the penalty. Water/Ground's ×4 + Grass weakness scores identically to a plain ×2. This is ported + behaviour; **do not change the formula in this phase** — see §5. +- **`Suggestion.gain` means different things in the two modes** + (`newlyCovered.size` in addition mode, `offensiveGain` in replacement + mode). Left alone here; recorded in §7.4 as a follow-up. + +The fix in this phase is a **tie-break only** (decision taken with the +repository owner): the composite score and its `0.5`/`1.0` weights — shared +with `domain/generator/TeamGenerator.kt` and asserted by +`ScoringTest`/`SuggestionEngineTest` — are untouched. See §5. + +### 0.2 Abilities are stored and displayed as raw PokéAPI slugs + +`domain/pokeapi/DatasetAssembly.kt`'s `resolveDefaultAbility()` returns +`abilityIdentifierById[row.abilityId]` — the raw `abilities.csv` +`identifier`, e.g. `"sap-sipper"`, `"damp"`. That value is written to +`PokemonEntry.defaultAbility`, copied into `TeamMember.ability` by the slot +editor and by `AnalysisViewModel.applySuggestion()`, and rendered verbatim +by `ui/team/analysis/PerPokemonCard.kt:115` and by the slot editor's text +field. The ability *picker* meanwhile offers `AbilityEntry.displayName` +(`prettify(identifier)`), so the same ability appears as `sap-sipper` in +the field and `Sap Sipper` one row below it — both screenshots the user +sent show this. + +### 0.3 `prettify()` cannot produce correct English names + +`prettify()` splits on `-` and joins with a space, so it is wrong for every +name whose canonical form keeps a hyphen or a symbol: + +| identifier | `prettify()` | correct (`*_names.csv`, `local_language_id = 9`) | +|---|---|---| +| `well-baked-body` | Well Baked Body | **Well-Baked Body** | +| `soul-heart` | Soul Heart | **Soul-Heart** | +| `double-edge` | Double Edge | **Double-Edge** | +| `u-turn` | U Turn | **U-turn** | +| `will-o-wisp` | Will O Wisp | **Will-O-Wisp** | +| `self-destruct` | Self Destruct | **Self-Destruct** | +| `x-scissor` | X Scissor | **X-Scissor** | +| `freeze-dry` | Freeze Dry | **Freeze-Dry** | + +The only correct source is PokéAPI's own `ability_names.csv` / +`move_names.csv`. §3.1 adds them. + +### 0.4 The ability effect table is materially incomplete + +`domain/ability/AbilityEffects.kt` models 14 abilities. Cross-checked +against `ability_prose.csv` (`local_language_id = 9`, the English +`short_effect` column) at the pinned revision, these **defensive** +abilities alter type effectiveness and are entirely absent: + +| ability | PokéAPI `short_effect` (verbatim) | forms | is slot-1 default for | +|---|---|---:|---:| +| `heatproof` | "Halves damage from Fire moves and burns." | 5 | 0 | +| `water-bubble` | "Halves damage from Fire moves, doubles damage of Water moves, and prevents burns." | 3 | 3 | +| `purifying-salt` | "Protects from status conditions and halves damage from Ghost-type moves." | 3 | 3 | +| `filter` | "Decreases damage taken from super-effective moves by 1/4." | 4 | 1 | +| `solid-rock` | "Decreases damage taken from super-effective moves by 1/4." | 4 | 2 | +| `prism-armor` | "Reduces super-effective damage to 0.75×." | 3 | 3 | +| `primordial-sea` | "…causes damaging Fire moves to fail." | 1 | 1 | +| `desolate-land` | "…causes damaging Water moves to fail." | 1 | 1 | +| `delta-stream` | "…causes moves to never be super effective against Flying Pokémon." | 1 | 1 | +| `tera-shell` | "All damage-dealing moves that hit the Pokémon when its HP is full will not be very effective." | 1 | 1 | + +**26 catalogue forms carry one of these; 16 of them carry it as their +slot-1 default ability**, i.e. as the value CoverDex auto-fills. For +comparison, the 12 abilities currently modelled cover 230 forms. + +One modelled ability is also **incomplete**: `dry-skin` is entered as a +Water immunity only. Its `short_effect` reads "Increases damage from Fire +moves to 1.25×, but absorbs Water moves" — the Fire ×1.25 half is missing. +7 forms have Dry Skin. + +On the **offensive** side, `AbilityEffectSide.OFFENSIVE` is declared in the +sealed hierarchy and **never constructed or consumed anywhere in the +codebase** — `offensiveCoverageForMember()` does not take an `ability` +parameter at all. Abilities that genuinely change what a Pokémon covers: + +| ability | effect on coverage | forms | +|---|---|---:| +| `scrappy` | Normal and Fighting moves hit Ghost | 15 | +| `minds-eye` | same, plus accuracy clauses | 1 | +| `refrigerate` | Normal moves become Ice | 3 | +| `pixilate` | Normal moves become Fairy | 3 | +| `aerilate` | Normal moves become Flying | 2 | +| `galvanize` | Normal moves become Electric | 3 | +| `normalize` | all moves become Normal | 2 | +| `liquid-voice` | sound moves become Water | 3 | + +`tinted-lens` (0.5× → 1×) and `neuroforce` (SE × 1.25) are **correctly +absent**: neither moves a multiplier across the ≥2× threshold +`offensiveCoverageForMember()` tests, so neither changes coverage. Say so +in the code rather than leaving their absence to look like an oversight. + +### 0.5 Held items are not modelled anywhere + +There is no item concept in the app at all: no field on +`domain/model/TeamMember.kt`, no column on `team_member` or +`custom_pokemon`, and `domain/showdown/ShowdownFormat.kt` exports `"@ "` +with an empty right-hand side and discards the item on import +(`line.substringBefore("@")`). The repository owner asked for the +**defensive subset only** — §4. + +### 0.6 Generational type-chart and typing changes are not modelled + +`type_efficacy_past.csv` (6 rows) and `pokemon_types_past.csv` (36 rows) +exist at the pinned revision and are not read. They encode, among others, +Gen-1 Ghost↔Psychic, Gen-1 Bug↔Poison, and the pre-Gen-6 Steel/Dark +resistance to Ghost, plus the Fairy retcons (Clefairy et al. were Normal +through Gen 5). **Out of scope for this phase** — the app has no "which +game am I playing" concept and adding one is a larger change than anything +here. Record it in `ROADMAP.md` under "Ideas not yet committed to" rather +than half-implementing it. + +### 0.7 Performance defect found while auditing + +`SuggestionEngine.findEntry()` is a linear `pool.find { … }` over ~1351 +entries and is called **once per candidate** (plus once per team member in +the legendary pre-filter). With the full pool that is on the order of +1.8 million string comparisons per suggestion recomputation, on every team +edit, toggle flip and filter change. Fix it in §5.4. + +--- + +## 1. Scope + +**In scope** + +1. Dataset: base stats (current and historical) and correct English + ability/move names (§2, §3.1). +2. Correct, capitalised ability names everywhere; canonical-vs-custom + ability selection per species (§3). +3. A held-item field, limited to items that change type effectiveness (§4). +4. BST as the suggestion tie-break, generation-aware (§5). +5. A configurable number of displayed suggestions, 5–10, default 5 (§6). +6. Closing the ability-effect gaps of §0.4 and adding the regression tests + that prove it (§7). + +**Explicitly out of scope** — do not implement, do not ask: + +- Generational type charts and generational typings (§0.6). +- Changing the composite score formula or its `0.5`/`1.0` weights (§0.1). +- Species/form *display* names: `prettify()` stays for those. Correcting + them needs `pokemon_species_names.csv` **and** `pokemon_form_names.csv` + and a join this phase does not need; "Goodra Hisui" is acceptable, while + `sap-sipper` in a text field is not. +- Localised (Italian) ability/move names. `ability_names.csv` and + `move_names.csv` do carry `local_language_id = 8` rows; store English + only and note in `implementation-decisions.md` that the data is already + on disk if this is ever wanted. +- The Surprise Me generator's own ranking. It shares `Scoring.kt` but not + the comparator; leave `domain/generator/TeamGenerator.kt` alone except + where §7 fixes a shared engine it calls. +- Damage calculation, battle simulation, EV/IV, legality. Still out + (`native-spec.md`). + +--- + +## 2. Dataset — four new pinned CSVs + +`data/pokeapi/PokeDataClient.kt`'s `DatasetFile` enum grows from 8 to 12. +Do **not** bump `DATASET_REVISION`; these files are read at the same pinned +commit as the existing eight. + +| new file | bytes @ pinned rev | why | +|---|---:|---| +| `pokemon_stats.csv` | 94,392 | current base stats → BST | +| `pokemon_stats_past.csv` | 3,046 | historical base stats → per-generation BST | +| `ability_names.csv` | 65,239 | correct English ability names | +| `move_names.csv` | 202,670 | correct English move names | + +Measured totals: **212,818 B today → 578,165 B** across 12 files. Update +`reference-pokedata.md` §2's table and its headline figure in the same +commit — `CLAUDE.md` quotes "~208 KB" and must be corrected too. + +`move_names.csv` alone is 203 KB, more than a third of the new total, and +buys only correct move capitalisation. It is included because the +repository owner asked for it explicitly; if the download cost is ever +judged not worth it, dropping this one file and keeping `prettify()` for +moves is a self-contained reversal. + +`HttpURLConnection` still negotiates gzip transparently — do **not** add an +`Accept-Encoding` header (`CLAUDE.md`, "Known gotchas"). These are the raw +byte counts, not the wire cost. + +### 2.1 Parsers — `domain/pokeapi/DatasetParsers.kt` + +Add, in the existing one-row-type-plus-one-function style, reading every +column by name: + +```kotlin +data class PokemonStatCsvRow(val pokemonId: Int, val statId: Int, val baseStat: Int) +fun parsePokemonStats(csv: String): List + +data class PokemonStatPastCsvRow(val pokemonId: Int, val generationId: Int, val statId: Int, val baseStat: Int) +fun parsePokemonStatsPast(csv: String): List + +/** Only `local_language_id == 9` (English) rows are kept. */ +data class LocalizedNameCsvRow(val id: Int, val name: String) +fun parseAbilityNames(csv: String): List // ability_id,local_language_id,name +fun parseMoveNames(csv: String): List // move_id,local_language_id,name +``` + +Stat ids are **hardcoded**, matching how `damage_class_id` (1/2/3) and the +type ids are already hardcoded in `assembleDataset`: `1` hp, `2` attack, +`3` defense, `4` special-attack, `5` special-defense, `6` speed, `9` the +Gen-1-only combined special. Do not download `stats.csv` for this. + +`move_names.csv` has 937 English rows against `moves.csv`'s full move list; +`ability_names.csv` has 374 English rows against 374 abilities. A move or +ability with **no** English name row keeps `prettify(identifier)` as its +display name — never blank, never a crash. + +### 2.2 BST derivation — the exact rule + +`pokemon_stats_past` semantics, verified during planning against a known +case: a row `(pokemonId, generationId = g, statId, baseStat)` means *"this +stat had this value in every generation up to and including `g`"*. +Butterfree (`pokemon_id = 12`) has `12,5,4,80` and a current +`special-attack` of 90 — i.e. 80 through Gen V, 90 from Gen VI. That +matches the documented Gen-VI Butterfree change. + +``` +fun statAt(pokemonId, statId, generation): + candidates = past rows for (pokemonId, statId) with generationId >= generation + return candidates.minBy { generationId }?.baseStat ?: currentStat(pokemonId, statId) + +fun bstAt(pokemonId, generation): + if generation == 1: + # Gen I has no Sp. Atk / Sp. Def split; stat id 9 ("special") is the + # single stat, and the canonical Gen-I base stat total is the sum of + # FIVE stats, not six. + return statAt(hp) + statAt(attack) + statAt(defense) + statAt(speed) + statAt(special) + else: + return sum of statAt(hp, attack, defense, special-attack, special-defense, speed) +``` + +**The Gen-I five-stat convention is a decision, not an accident.** The +alternative (mirroring `special` into both Sp. Atk and Sp. Def, giving a +six-stat total) inflates Gen-I totals and changes the *ordering* among +special-heavy species. Worked examples under the chosen rule, for the +regression tests: + +| form | gen 1 | gen 5 | gen 9 | +|---|---:|---:|---:| +| Alakazam (65) | 405 | 490 | 500 | +| Gengar (94) | 425 | 500 | 500 | +| Raticate (20) | 343 | 413 | 413 | +| Butterfree (12) | 305 | 385 | 395 | + +A Gen-I total is therefore **on a different scale** from a Gen-II+ total +and the two must never be compared. That invariant holds for free in this +app: BST is only ever used to order candidates *within one value of the +generation filter*, and when the filter is `null` every candidate is scored +at the latest generation (§5.2). State this in the KDoc of whatever +function computes it — it is the one way this feature can go quietly wrong. + +Only **200** forms have any `pokemon_stats_past` row, and only **69** have +one that is not the Gen-1 `special` stat. Historical BST is a small +correction, not a broad one; do not over-engineer it. + +### 2.3 Assembly — `domain/pokeapi/DatasetAssembly.kt` + +`assembleDataset(...)` grows four parameters (keep the existing +one-CSV-per-parameter shape; do not introduce a map). It must now produce: + +- `PokemonEntry.baseStatTotal: Int` — the **current** (latest generation) + BST, on every entry. `0` if the form has no `pokemon_stats` rows at all; + never null, never negative. +- `ParsedDataset.pastBst: List` where + `PastBstRow(pokemonId: Int, generationId: Int, bst: Int)` — one row per + `(form, generation)` whose BST differs from `baseStatTotal`, carrying the + BST **that held through that generation**. Emit the row for each distinct + `generationId` present in `pokemon_stats_past` for that form, plus a + Gen-1 row for every form that has a `special` (stat 9) row, since the + five-stat rule makes Gen-1 differ even when no stat value changed. + Expected magnitude: a few hundred rows, not 12k. Assert the order of + magnitude in a test rather than a brittle exact count. +- `AbilityEntry.displayName` and `MoveEntry.displayName` from the name + CSVs, falling back to `prettify(identifier)`. +- `PokemonEntry.defaultAbility` — **now the display name** + ("Sap Sipper"), not the slug. This is the §0.2 fix. +- `ParsedDataset.pokemonAbilities: List` where + `PokemonAbilityRow(pokemonId: Int, abilitySlug: String, displayName: String, isHidden: Boolean, slot: Int)` + — every row of `pokemon_abilities.csv` (2,941 at the pinned revision), + which is already downloaded and today only used to derive + `defaultAbility`. This backs §3.2's canonical ability list. + +Keep `resolveDefaultAbility`'s existing fallback (a form with no +`pokemon_abilities` row falls back to its species' default form, then to +`null`) exactly as it is; only the returned string's format changes. + +--- + +## 3. Abilities — correct names, canonical list, custom choice + +### 3.1 Display names + +Covered by §2.1–2.3. The one thing that must not break: effect lookup. + +`domain/ability/AbilityEffects.kt`'s `normalizeAbilityName()` currently +lowercases and replaces whitespace runs with `-`, so `"Sap Sipper"` → +`"sap-sipper"` resolves correctly. It also happens to work for +`"Well-Baked Body"` → `"well-baked-body"`. It does **not** work for a name +carrying a symbol the slug does not have. + +Replace it with a symbol-insensitive key, mirroring +`domain/pokeapi/searchKey()`: + +```kotlin +/** Lowercase, letters and digits only — so "Well-Baked Body", "well-baked-body" + * and "wellbakedbody" all resolve to the same effects entry. */ +fun abilityKey(name: String): String = name.lowercase().filter { it.isLetterOrDigit() } +``` + +and key `ABILITY_EFFECTS` by that. Keep `normalizeAbilityName` as a +deprecated alias only if something outside the domain layer still calls it +(`PerPokemonCard.kt` does, for its Wonder Guard check) — otherwise delete +it and update the call site. An unrecognised ability must still degrade to +"no effect", never throw. + +**Migration of already-saved data.** `team_member.ability` and +`custom_pokemon.ability` may hold slugs written by earlier builds. Do +**not** rewrite them in a Room migration — they are user data snapshots and +a ROM-hack ability the user typed by hand must survive untouched. Instead +make display tolerant: a helper in `ui/common/` that renders a stored +ability string through the cached ability catalogue when a match is found +(by `abilityKey`) and verbatim otherwise. This also makes a stored slug +from a pre-Phase-7 build render as "Sap Sipper" without touching the +database. + +### 3.2 Canonical abilities per species, plus a custom choice + +**Data.** New cache table (§8 covers the migration): + +```kotlin +@Entity(tableName = "poke_pokemon_ability", primaryKeys = ["pokemonId", "slot"]) +data class PokePokemonAbilityEntity( + val pokemonId: Int, + val slot: Int, + val abilitySlug: String, + val displayName: String, + val isHidden: Boolean, +) +``` + +Written inside `PokedexDao.replaceCache()`'s existing single transaction +and wiped by `clearCache()` — **name it explicitly in both**; +`clearAllTables()` remains banned (`CLAUDE.md`). + +`PokedexRepository` gains +`suspend fun abilitiesForSpecies(pokemonId: Int): List` +with `SpeciesAbility(displayName: String, slug: String, isHidden: Boolean, slot: Int)`, +ordered by `isHidden` then `slot`. + +**UI — `ui/team/SlotEditorScreen.kt`.** Replace the bare +`EditableComboBox` for the ability field with a two-level control: + +1. A dropdown listing, for the currently selected species: + - each canonical non-hidden ability, by display name; + - each hidden ability, suffixed with a localised "(hidden)" marker; + - a final, visually separated entry **"Custom ability…"**. +2. Selecting "Custom ability…" swaps the control for the existing + `SearchableDropdown` over the whole cached ability catalogue (374 + entries) **with free text still accepted**, exactly as the ability field + behaves today — a ROM hack can carry an ability that exists in no + PokéAPI table at all, and the app must not reject it. Offer a way back + to the canonical list (a "back to canonical" affordance or simply + reselecting a canonical entry). +3. Any ability that has an entry in `ABILITY_EFFECTS` — canonical or + custom — is marked in the list with a small badge, so the user can see + which choices actually move the weakness map. Do not filter the + non-affecting ones out: the repository owner asked for Moxie and + Intimidate to remain selectable. + +A member with **no** `pokedexId` (a hand-typed or roster Pokémon) has no +canonical list; go straight to the full picker. Same in +`ui/roster/RosterEditorScreen.kt`. + +Picking a species still resets the draft's ability to that species' +`defaultAbility` — `SlotEditorScreen`'s existing `selectPokemon` behaviour +is unchanged, only the value it writes is now a display name. + +--- + +## 4. Held items — the defensive subset + +Decision taken with the repository owner: add a real item field, but model +effects **only for items that change type effectiveness**. Do **not** +download `items.csv` (59 KB) — the modelled set is small enough to be a +hardcoded table, exactly like `ABILITY_EFFECTS`, and the field itself +accepts free text for everything else. + +### 4.1 `domain/item/ItemEffects.kt` (new) + +```kotlin +sealed interface ItemEffect { + /** Air Balloon: Ground moves miss entirely. */ + data class Immunity(val type: PokemonType) : ItemEffect + /** Iron Ball / Ring Target: cancel the holder's immunities before anything else. */ + data object GroundsHolder : ItemEffect // Iron Ball + data object RemovesTypeImmunities : ItemEffect // Ring Target + /** A resist berry: halves an incoming hit of this type, but only when it is + * already super-effective. Chilan Berry is the exception — see [alwaysApplies]. */ + data class ResistBerry(val type: PokemonType, val alwaysApplies: Boolean = false) : ItemEffect +} +``` + +Modelled items — the 17 type-resist berries plus Chilan, plus the three +immunity-shaped items: + +| item | effect | +|---|---| +| Air Balloon | `Immunity(GROUND)` | +| Iron Ball | `GroundsHolder` | +| Ring Target | `RemovesTypeImmunities` | +| Occa / Passho / Wacan / Rindo / Yache / Chople / Kebia / Shuca / Charti / Tanga / Payapa / Kasib / Haban / Colbur / Babiri / Roseli / Chilan Berry | `ResistBerry()`; Chilan is `alwaysApplies = true` (Normal) | + +Deliberately **not** modelled, with a comment saying why in the file: +Heavy-Duty Boots and Utility Umbrella (entry hazards / weather — neither +touches a type multiplier), Expert Belt and the type-boosting plates/gems +(offensive damage, not the ≥2× coverage threshold). + +### 4.2 Application order in `defensiveMultiplier` + +`domain/coverage/CoverageEngine.kt`'s `defensiveMultiplier` gains an +`item: String? = null` parameter (defaulted, so no existing call site +breaks) and applies effects in this **exact** order. Write the order into +the KDoc; it is the part a future reader will get wrong. + +1. Type-chart product across the defender's one or two types. +2. `RemovesTypeImmunities` (Ring Target): a `0.0` from step 1 becomes + `1.0`. `GroundsHolder` (Iron Ball): only a Ground-move `0.0` becomes + `1.0`, and the holder's Levitate/Earth Eater/Air Balloon Ground + immunities in steps 3–4 are skipped. +3. Ability immunities → return `0.0` (unless step 2 cancelled them). +4. Item `Immunity` (Air Balloon) → return `0.0`. +5. Ability multipliers (Thick Fat, Fluffy, Heatproof, Water Bubble, + Purifying Salt, Dry Skin's Fire ×1.25). +6. Ability super-effective reducers (Filter / Solid Rock / Prism Armor + ×0.75; Delta Stream's Flying cap) — applied only when the running value + is already `> 1.0`. +7. `ResistBerry`: ×0.5 when the running value is `> 1.0`, or + unconditionally for Chilan. + +Every function that today takes `ability` must take `item` alongside it and +thread it through: `defensiveProfile`, `sharedWeaknessCounts`, +`sharedWeaknesses`, `mostVulnerableByType`, and `Scoring.kt`'s +`weaknesses()` / `teamScoringContext()` / `computeCompositeScore()`. +Suggestion candidates built by `memberFromEntry()` have **no** item — +`null` — which keeps candidate scoring conservative and matches how the +app auto-fills only an ability. + +### 4.3 Plumbing + +- `domain/model/TeamMember.kt`: `val item: String? = null`. +- Room: `team_member.item` and `custom_pokemon.item`, nullable TEXT + (§8). +- `data/repository/TeamMappers.kt`, `Mappers.kt`: map it. +- `domain/backup/BackupPayload.kt`: add `item` to `BackupTeamMemberDto` + and to the roster DTO, and bump `CURRENT_BACKUP_FORMAT_VERSION` to `2`. + A v1 file must still restore (the field is absent → `null`); a v2 file + in an older build already fails loudly via + `BackupFormatTooNewException`. Add a test for the v1-restores-into-v2 + path. +- `domain/showdown/ShowdownFormat.kt`: export `" @ "` when + an item is set (keep the bare `"@ "` when it is not, so existing + round-trip tests still pass), and **stop discarding** the item on + import — `line.substringAfter("@").trim()` when a `@` is present. +- UI: an item field in `SlotEditorScreen` and `RosterEditorScreen`, + identical in shape to the ability field's free-text-plus-suggestions + control, suggesting the modelled items from §4.1. Show the item on + `SlotSummaryCard` and `PerPokemonCard`, and mark the ones with an + effect the same way §3.2 marks abilities. + +--- + +## 5. BST-aware suggestion ranking + +### 5.1 What changes + +**Only the tie-break.** `NEW_WEAKNESS_PENALTY`, `AGGRAVATED_WEAKNESS_PENALTY` +and `computeCompositeScore` are untouched, so `ScoringTest`, +`SuggestionEngineTest`'s score assertions and `TeamGeneratorTest` keep +passing unchanged. If any of them break, you changed something you were not +meant to. + +```kotlin +private val rankingComparator = compareByDescending { it.bestScore } + .thenByDescending { it.isFinal } + .thenByDescending { it.baseStatTotal ?: -1 } // NEW: stronger first; customs last + .thenBy { it.entryId } // unchanged final tie-break +``` + +`SuggestionEngineTest`'s "secondary sort on a compositeScore tie is by +ascending catalogue id" **will** need updating — it is now the *tertiary* +sort. Rewrite it as two tests: ties break by BST descending; ties at equal +BST still break by ascending id. + +Applied to the screenshot's team, this replaces "Raticate, Persian, +Kangaskhan" (ids 20/53/115, BST 413/440/490) with the highest-BST members +of the same tie group. Add exactly that as a regression test with a small +hand-built pool — do not assert against the real catalogue. + +### 5.2 Which BST + +Keyed off the **existing** generation dropdown in +`ui/team/analysis/SuggestionFilters.kt` — the repository owner confirmed +this is the intended control: + +- `generation = null` ("all generations") → `PokemonEntry.baseStatTotal`, + the latest-generation value. +- `generation = N` → the historical BST for generation `N` (§2.2). + +Note in the code and in `implementation-decisions.md` that this dropdown is +a **pool filter** ("only suggest species introduced in generation N"), so +selecting `N` also guarantees every candidate is a generation-`N` species — +which is precisely why the Gen-I five-stat scale never mixes with any other +(§2.2). If that filter's meaning ever changes, this coupling must be +revisited. + +`SuggestionOptions` gains nothing; the BST resolution belongs to the caller +building the pool. Pass a resolved `bstFor: (PokemonEntry) -> Int?` into +`computeSuggestions`, or resolve it into the pool entries before the call — +either is fine, but `domain/suggestion/` must not reach into Room. + +### 5.3 Showing it + +`domain/suggestion/Suggestion.kt`'s `Suggestion` gains +`val baseStatTotal: Int?`, and `ui/team/analysis/SuggestionCard.kt` renders +it as a new row (`BST: 413`) under the existing score row. A custom +Pokémon has no BST; render nothing rather than `0` or a dash-only row. + +While you are in that file: the existing `Punteggio: −0,5` row is opaque to +a user. Leave the value, but add the localised explanatory string +`suggestions_score_hint` — one short line saying the score is +*coverage gained minus weaknesses introduced*, so a negative number stops +reading like an error. + +### 5.4 The `findEntry` fix (§0.7) + +Build **two maps once** at the top of `computeSuggestions` — one keyed by +`displayName`, one by lowercased `name` — and look candidates up in them +instead of calling `pool.find { … }` per candidate. Behaviour must be +identical, including the current precedence (`displayName` match first). +This is a pure performance fix; assert nothing new about it beyond the +existing tests still passing. + +--- + +## 6. Configurable suggestion count + +- `data/settings/SettingsPreferences.kt`: add + `internal val SUGGESTION_COUNT_KEY = intPreferencesKey("suggestion_count")`, + a `val suggestionCount: Flow` that reads it, **coerces into `5..10`** + (a value outside the range, from a hand-edited store, must clamp, not + crash — the file's existing "unknown stored value falls back to the + default" habit) and defaults to `5`, plus + `suspend fun setSuggestionCount(count: Int)` that clamps on write too. +- `ui/settings/SettingsScreen.kt`: a row in the existing + `settings_section_team_suggestions` section. Reuse the `−`/`+` stepper + composable already private to `ui/surprise/SurpriseMeScreen.kt` + (around line 255) — **promote it to `ui/common/`** rather than copying + it, and update Surprise Me to use the shared one. Bounds 5 and 10, both + buttons disabled at their end. +- `ui/team/analysis/AnalysisViewModel.kt`: fold `suggestionCount` into the + `combine()`. Note that `combine` is already at its 5-argument arity in + both the `core` and the `uiState` combines — add the new flow to whichever + one keeps the code readable, creating a small holder data class in the + same style as the existing `CoreData` if you need a sixth slot. +- `AnalysisUiState` gains `val suggestionCount: Int = 5`; + `ui/team/analysis/AnalysisScreen.kt:154` becomes + `state.suggestions.take(state.suggestionCount)`. + `AnalysisUiState.suggestions` stays unsliced, as its KDoc already + promises. + +New strings in **both** `res/values/strings.xml` (Italian, default) and +`res/values-en/strings.xml`, in the same commit: +`settings_suggestion_count` (label) and `settings_suggestion_count_value` +(`%1$d`), plus everything §3, §4 and §5.3 need. + +--- + +## 7. Closing the engine gaps, and proving it + +### 7.1 Defensive abilities + +Add to `ABILITY_EFFECTS` the ten abilities of §0.4 and complete `dry-skin`. +Two new `AbilityEffect` variants are required: + +```kotlin +/** Filter / Solid Rock / Prism Armor: multiplies an already-super-effective hit. */ +data class SuperEffectiveMultiplier(val factor: Double) : AbilityEffect +/** Delta Stream: nothing is super-effective against the holder while it is Flying. */ +data object NeverSuperEffective : AbilityEffect +``` + +Mapping, each traceable to the `short_effect` quoted in §0.4: + +- `heatproof` → `Multiplier(FIRE, 0.5, DEFENSIVE)` +- `water-bubble` → `Multiplier(FIRE, 0.5, DEFENSIVE)` +- `purifying-salt` → `Multiplier(GHOST, 0.5, DEFENSIVE)` +- `dry-skin` → existing `Immunity(WATER)` **plus** + `Multiplier(FIRE, 1.25, DEFENSIVE)` +- `filter`, `solid-rock` → `SuperEffectiveMultiplier(0.75)` +- `prism-armor` → `SuperEffectiveMultiplier(0.75)` +- `primordial-sea` → `Immunity(FIRE)` +- `desolate-land` → `Immunity(WATER)` +- `delta-stream` → `NeverSuperEffective` +- `tera-shell` → keep as `BadgeOnly("Not very effective at full HP")`. + It is unconditional only at full HP, and the coverage engine has no HP + concept; modelling it as a real multiplier would be wrong more often + than right. Say that in the comment. +- `wonder-guard` → **promote from `BadgeOnly` to a real effect**: + everything that is not super-effective deals `0`. That is what the + ability does, it is Shedinja's entire identity, and leaving it as a + badge makes `defensiveProfile` actively wrong for the one Pokémon it + applies to. Add `data object OnlySuperEffective : AbilityEffect`, + applied last, and keep `PerPokemonCard`'s existing Wonder Guard badge. + +`primordial-sea`/`desolate-land` are field effects in the real games and +apply to *both* sides; modelled here as the holder's own immunity only. +Comment it. + +Update `KNOWN_ABILITIES_WITH_EFFECTS` — it is the list §3.2's badge reads +from, and it is currently in display format ("volt absorb"). Regenerate it +from `ABILITY_EFFECTS.keys` instead of maintaining a second hand-written +list that can drift. + +### 7.2 Offensive abilities + +`offensiveCoverageForMember()` gains an `ability: String?` parameter +(defaulted `null`) and applies, before the ≥2× scan: + +- `scrappy` / `minds-eye` → Normal and Fighting attacks treat Ghost as + `1.0` rather than `0.0`. This does not add a ≥2× cell, so it changes + nothing in `offensiveCoverageForMember` itself — but it **does** change + `offensiveMultipliersForMember`, which the offensive grid renders. Apply + it there and add a test; note in the code that coverage is unaffected so + the next reader does not "fix" it. +- `refrigerate` / `pixilate` / `aerilate` / `galvanize` → a Normal-type + attacking move is rewritten to Ice / Fairy / Flying / Electric. +- `normalize` → every attacking move becomes Normal. +- `liquid-voice` → out of scope: it applies to sound-based moves, and the + app has no move-flag data (`move_flags.csv` is not downloaded). Add it to + the file's "deliberately not modelled" comment. +- `tinted-lens`, `neuroforce` → deliberately not modelled, with the §0.4 + reasoning in the comment. + +Note that the `-ate` abilities only bite when move slots are enabled +(`showMoves`), since type-based coverage has no Normal move to rewrite. + +Once these exist, `AbilityEffectSide.OFFENSIVE` is finally constructed and +consumed. If you end up not needing the enum, delete it rather than leaving +a permanently-unreachable branch. + +### 7.3 Tests — this is the deliverable for point 3 + +All pure JVM (`app/src/test/`), no Robolectric except where a DAO is +involved (then `@Config(sdk = [26])`, per `CLAUDE.md`). **Plain ASCII test +method names** — a non-ASCII character in a backtick name breaks +`compileDebugUnitTestKotlin` in this sandbox's POSIX locale. + +1. **Type chart, exhaustively.** From the real `type_efficacy.csv` fixture, + assert all 18 × 18 = 324 cells are present and that every value is one + of `0.0 / 0.5 / 1.0 / 2.0`. Spot-check the classic asymmetries + (Ghost→Normal 0, Fighting→Ghost 0, Ground→Flying 0, Fairy→Dragon 2, + Steel→Fairy 2, Fire→Steel 2, Poison→Steel 0, Electric→Ground 0). +2. **Every dual-type combination.** For all 18 single types and all + 18 × 17 / 2 = 153 unordered dual typings, assert `defensiveMultiplier` + equals the product of the two chart lookups and that + `defensiveProfile` partitions all 18 attacking types with no type + appearing in two buckets and none missing. This is the "1000+ Pokémon" + guarantee, expressed at the level where it is actually decidable — 171 + typings is the complete space; enumerating 1,351 forms only re-tests the + same 171 with worse failure messages. +3. **Every catalogue form's typing is one of those 171.** A cheap dataset + test over the assembled `PokemonEntry` list: `type1 != type2`, `type1` + non-null, both in `PokemonType.entries`. +4. **One test per ability in `ABILITY_EFFECTS`**, asserting the specific + multiplier change against a typing where it is observable, including + the ten new ones and the stacking cases (Thick Fat on a Fire-weak + typing; Heatproof on a ×4 Fire weakness → ×1; Filter turning ×4 into + ×3; Wonder Guard zeroing a ×1 and a ×0.5 while leaving ×2 alone). +5. **Item tests**: Air Balloon on a Ground-weak typing; Iron Ball + cancelling Levitate; Ring Target cancelling a type immunity; each + resist berry halving only when already super-effective; Chilan always. + Plus the §4.2 ordering: Ring Target + Levitate, Air Balloon + Iron Ball. +6. **BST tests**: the four worked examples of §2.2 verbatim; a form with no + past rows returns the same value for every generation; the Gen-1 + five-stat rule; a form absent from `pokemon_stats` yields `0` and + sorts last. +7. **Ranking tests**: §5.1's two rewritten tie-break tests, plus the + Raticate/Persian/Kangaskhan regression from a hand-built pool. +8. **Name tests**: `Double-Edge`, `U-turn`, `Will-O-Wisp`, `Self-Destruct`, + `Well-Baked Body`, `Soul-Heart` come out correct; an identifier with no + English name row falls back to `prettify`. +9. **Effect-lookup robustness**: `abilityKey` resolves the slug, the + display name and a mixed-case/space variant to the same effects; an + unknown string returns `null` and never throws. + +### 7.4 Audit findings recorded but not fixed here + +Write these into `docs/post-migration-review.md` as a new "Phase 7 audit" +section (that file is already the home for this kind of finding), each with +a one-line reason for deferring: + +- `Suggestion.gain` means `newlyCovered.size` in addition mode and + `offensiveGain` in replacement mode (§0.1). +- `weaknesses()` counts weakness *types*, so ×4 and ×2 score identically + (§0.1). +- Replacement mode computes `replacementContexts[0]`'s score twice — once + to seed `bestResult`, once in the loop. +- `deduped`'s `seen.add(speciesName.lowercase())` silently drops a custom + Pokémon named after a catalogue species. +- Generational type charts and typings (§0.6) → also `ROADMAP.md`. + +--- + +## 8. Room schema v3 + +One additive migration, `MIGRATION_2_3` in +`data/local/migration/Migrations.kt`, written in the same explicit-`execSQL` +style as `MIGRATION_1_2`. `fallbackToDestructiveMigration()` stays banned. + +```sql +ALTER TABLE `team_member` ADD COLUMN `item` TEXT; +ALTER TABLE `custom_pokemon` ADD COLUMN `item` TEXT; +CREATE TABLE IF NOT EXISTS `poke_pokemon_ability` ( + `pokemonId` INTEGER NOT NULL, `slot` INTEGER NOT NULL, + `abilitySlug` TEXT NOT NULL, `displayName` TEXT NOT NULL, + `isHidden` INTEGER NOT NULL, + PRIMARY KEY(`pokemonId`, `slot`)); +CREATE TABLE IF NOT EXISTS `poke_species_bst_past` ( + `pokemonId` INTEGER NOT NULL, `generationId` INTEGER NOT NULL, + `bst` INTEGER NOT NULL, + PRIMARY KEY(`pokemonId`, `generationId`)); +ALTER TABLE `poke_species` ADD COLUMN `baseStatTotal` INTEGER NOT NULL DEFAULT 0; +``` + +Checklist, all of which cost time in this repo before: + +- Bump `@Database(version = 3)` and register `MIGRATION_2_3` in + `di/DatabaseModule.kt`. +- Commit the exported `app/schemas/…/3.json`, and verify your hand-written + SQL **byte-for-byte** against it — column order, `NOT NULL`, defaults. +- `Migration2To3Test` alongside `Migration1To2Test`, Robolectric + + `MigrationTestHelper`, `@Config(sdk = [26])`. The schema JSONs are wired + into `sourceSets["debug"].assets`, **not** `test`'s — do not "fix" that + (`CLAUDE.md`, "Known gotchas"). +- The three new cache tables/columns must be added to **both** + `PokedexDao.replaceCache()` and `PokedexDao.clearCache()`, by name. +- The `poke_cache_meta.schemaVersion` constant must be bumped so every + existing install re-syncs and picks up base stats and the per-form + ability rows. Without this the new tables stay empty until the user + manually forces a resync, and the BST tie-break silently no-ops. This + is the single most likely way to ship this phase broken. +- `data/debug/DebugSeeder.kt` still compiles and seeds valid rows. +- Never introduce a `@Insert(onConflict = REPLACE)` upsert on a table that + is the parent of a cascading foreign key (`CLAUDE.md`). + +--- + +## 9. Task order + +Each step should build and test green before the next. Commit per step. + +1. **§2** — dataset: 4 new CSVs, parsers, assembly, `PokemonEntry.baseStatTotal`, + past-BST rows, per-form ability rows, correct display names. Tests 7.3.6 + and 7.3.8. No UI yet. +2. **§8** — Room v3, migration, DAO wiring, `schemaVersion` bump, + `Migration2To3Test`. +3. **§3** — ability display names end to end, `abilityKey`, canonical + + custom ability picker in slot and roster editors. Test 7.3.9. +4. **§7.1 + §7.2** — ability effect gaps, offensive abilities, the full + engine test suite (7.3.1–7.3.4). This is point 3 of the request; it is + also the step most likely to surface a real bug, so do it before the + ranking work depends on it. +5. **§4** — item field: model, Room columns (already added in step 2), + mappers, backup v2, Showdown round-trip, UI, `ItemEffects`, tests 7.3.5. +6. **§5** — BST tie-break, `findEntry` map, suggestion card BST row and + score hint. Tests 7.3.7. +7. **§6** — the 5–10 suggestion-count setting, shared stepper. +8. **Docs** — `CHANGELOG.md` under `## [Unreleased]` (one bold-lead bullet + per user-visible change: correct ability names, canonical/custom ability + choice, held items, stronger-first suggestions, configurable suggestion + count, and the ability-accuracy fixes); `docs/implementation-decisions.md` + "Phase 7"; `docs/post-migration-review.md` "Phase 7 audit" (§7.4); + `docs/test-plan.md` a Phase 7 section; `docs/plan/reference-pokedata.md` + §2 and its size figures; `CLAUDE.md`'s "~208 KB" line, its architecture + tree (`domain/item/`), its phase list; `docs/plan/README.md`'s order + table; `docs/STATUS.md`; `ROADMAP.md` (generational type charts). + +## 10. Definition of done + +- `./gradlew testDebugUnitTest lintDebug assembleDebug` green, and CI green + on the PR. Never report a build as passing that you did not run — this + sandbox may have no Android SDK; check `$ANDROID_HOME` and + `command -v sdkmanager` first and fall back to CI. +- Every new user-visible string present in **both** `values/strings.xml` + and `values-en/strings.xml`. +- A fresh install and an **upgrade from a v2 database** both re-sync and + show BSTs — verify the upgrade path by hand and record it in + `docs/test-plan.md`. +- `docs/test-plan.md` has a Phase 7 section covering: the ability picker + (canonical, hidden, custom, ROM-hack free text), item entry and its + effect on the per-Pokémon card, the suggestion count setting, and that + suggestions on a solid team now lead with strong Pokémon rather than + Raticate. +- No new Gradle dependency. Everything here is hand-rolled parsing and + Compose, consistent with every phase before it. From 67ab7a6e84c5c6cad37676115e50aced84eab2dc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 11:22:00 +0000 Subject: [PATCH 2/7] Phase 7: dataset base stats, canonical abilities, ability effect gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements docs/plan/phase-7-accuracy-and-customization.md steps 1-4 of its task order (§9): the dataset layer, Room v3, and the ability picker/engine fixes. BST-aware suggestions, held items, the suggestion-count setting, docs and the exhaustive type-chart/dual-typing test sweep are follow-up commits on this same PR. Dataset (§2): - 4 new pinned CSVs (pokemon_stats, pokemon_stats_past, ability_names, move_names), parsed and joined into the assembled catalogue. - PokemonEntry.baseStatTotal (current BST) and ParsedDataset.pastBst (per-generation historical BST, five-stat Gen-I rule) per §2.2's worked examples. - ParsedDataset.pokemonAbilities: every canonical (pokemonId, slot) ability row, for the picker's canonical list. - Ability/move display names now come from ability_names.csv/move_names.csv instead of prettify(), which cannot produce a correct hyphenated name (Well-Baked Body, Double-Edge, U-turn, ...). defaultAbility now carries the display name, not the raw PokeAPI slug (§0.2's Sap Sipper/sap-sipper bug). Room v3 (§8): - poke_species.baseStatTotal, team_member.item, custom_pokemon.item (nullable, item wiring itself lands in the next commit). - New cache tables poke_pokemon_ability, poke_species_bst_past, wired into PokedexDao.replaceCache()/clearCache() by name, never clearAllTables(). - MIGRATION_2_3 + schemas/3.json + DATASET_SCHEMA_VERSION bump to 2, so every existing install re-syncs and actually gets the new columns. - Migration2To3Test mirrors Migration1To2Test's MigrationTestHelper coverage. Ability picker (§3): - abilityKey() (strip every symbol, not just spaces) replaces normalizeAbilityName(), so a slug and a display name with a mismatched hyphen still resolve to the same ABILITY_EFFECTS entry. - ui/common/AbilityPicker.kt: a species' canonical abilities (normal, then hidden) plus a "Custom ability..." row that falls back to the existing free-text-with-suggestions picker — a ROM hack ability with no PokeAPI entry stays typeable. Canonical options with a coverage effect are marked. Wired into SlotEditorScreen and RosterEditorScreen. Ability effect gaps (§7.1/§7.2), found by auditing every ability's PokeAPI short_effect text: - 10 previously-unmodelled defensive abilities (Heatproof, Water Bubble, Purifying Salt, Filter, Solid Rock, Prism Armor, Primordial Sea, Desolate Land, Delta Stream, Tera Shell) plus Dry Skin's missing Fire 1.25x half. - Wonder Guard promoted from a UI-only badge to a real effect: only a super-effective hit deals damage. - The offensive gap: Scrappy/Mind's Eye (Normal/Fighting bypass Ghost immunity, grid-display only) and the -ate/Normalize abilities (rewrite a Normal move's type, gated on real moves being entered). - CoverageEngineTest/AbilityEffectsTest extended accordingly. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011j5jieoE4nqU2ZBSM7USHc --- .../3.json | 762 ++++++++++++++++++ .../coverdex/data/local/CoverDexDatabase.kt | 6 +- .../data/local/dao/CustomPokemonDao.kt | 8 +- .../coverdex/data/local/dao/PokedexDao.kt | 47 ++ .../data/local/entity/PokedexEntities.kt | 32 + .../data/local/entity/TeamEntities.kt | 6 + .../data/local/migration/Migrations.kt | 23 + .../data/pokeapi/DatasetSyncManager.kt | 6 + .../coverdex/data/pokeapi/PokeDataClient.kt | 11 +- .../coverdex/data/repository/Mappers.kt | 30 + .../data/repository/PokedexRepositoryImpl.kt | 7 + .../com/marcogn/coverdex/di/DatabaseModule.kt | 3 +- .../coverdex/domain/ability/AbilityEffects.kt | 178 +++- .../domain/coverage/CoverageEngine.kt | 58 +- .../marcogn/coverdex/domain/model/PastBst.kt | 17 + .../coverdex/domain/model/PokemonEntry.kt | 5 + .../coverdex/domain/model/SpeciesAbility.kt | 16 + .../domain/pokeapi/DatasetAssembly.kt | 115 ++- .../coverdex/domain/pokeapi/DatasetParsers.kt | 46 ++ .../coverdex/domain/pokeapi/SyncStage.kt | 7 +- .../domain/repository/PokedexRepository.kt | 16 + .../coverdex/ui/common/AbilityPicker.kt | 161 ++++ .../coverdex/ui/roster/RosterEditorScreen.kt | 19 +- .../coverdex/ui/team/SlotEditorScreen.kt | 19 +- .../coverdex/ui/team/SlotEditorViewModel.kt | 5 + .../ui/team/analysis/PerPokemonCard.kt | 11 +- app/src/main/res/values-en/strings.xml | 6 + app/src/main/res/values/strings.xml | 6 + .../coverdex/data/local/Migration2To3Test.kt | 85 ++ .../coverdex/data/local/PokedexDaoTest.kt | 44 + .../data/pokeapi/DatasetSyncManagerTest.kt | 4 + .../coverdex/data/repository/MappersTest.kt | 39 + .../data/repository/TeamRepositoryTest.kt | 2 + .../domain/ability/AbilityEffectsTest.kt | 143 +++- .../domain/coverage/CoverageEngineTest.kt | 126 ++- .../domain/pokeapi/DatasetAssemblyTest.kt | 93 ++- .../domain/pokeapi/DatasetParsersTest.kt | 36 + .../ui/team/analysis/FakePokedexRepository.kt | 4 + app/src/test/resources/csv/abilities.csv | 1 + app/src/test/resources/csv/ability_names.csv | 4 + app/src/test/resources/csv/move_names.csv | 5 + app/src/test/resources/csv/moves.csv | 1 + app/src/test/resources/csv/pokemon.csv | 1 + .../test/resources/csv/pokemon_species.csv | 1 + app/src/test/resources/csv/pokemon_stats.csv | 31 + .../test/resources/csv/pokemon_stats_past.csv | 3 + app/src/test/resources/csv/pokemon_types.csv | 1 + 47 files changed, 2119 insertions(+), 131 deletions(-) create mode 100644 app/schemas/com.marcogn.coverdex.data.local.CoverDexDatabase/3.json create mode 100644 app/src/main/java/com/marcogn/coverdex/domain/model/PastBst.kt create mode 100644 app/src/main/java/com/marcogn/coverdex/domain/model/SpeciesAbility.kt create mode 100644 app/src/main/java/com/marcogn/coverdex/ui/common/AbilityPicker.kt create mode 100644 app/src/test/java/com/marcogn/coverdex/data/local/Migration2To3Test.kt create mode 100644 app/src/test/resources/csv/ability_names.csv create mode 100644 app/src/test/resources/csv/move_names.csv create mode 100644 app/src/test/resources/csv/pokemon_stats.csv create mode 100644 app/src/test/resources/csv/pokemon_stats_past.csv diff --git a/app/schemas/com.marcogn.coverdex.data.local.CoverDexDatabase/3.json b/app/schemas/com.marcogn.coverdex.data.local.CoverDexDatabase/3.json new file mode 100644 index 0000000..1e9c3f9 --- /dev/null +++ b/app/schemas/com.marcogn.coverdex.data.local.CoverDexDatabase/3.json @@ -0,0 +1,762 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "00000000000000000000000000000000", + "entities": [ + { + "tableName": "poke_species", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `displayName` TEXT NOT NULL, `searchName` TEXT NOT NULL, `speciesId` INTEGER NOT NULL, `speciesName` TEXT NOT NULL, `type1` TEXT NOT NULL, `type2` TEXT, `isLegendary` INTEGER NOT NULL, `isMythical` INTEGER NOT NULL, `isFinalEvolution` INTEGER NOT NULL, `generationIntroduced` INTEGER NOT NULL, `defaultAbility` TEXT, `isDefaultForm` INTEGER NOT NULL, `baseStatTotal` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "searchName", + "columnName": "searchName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "speciesId", + "columnName": "speciesId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "speciesName", + "columnName": "speciesName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type1", + "columnName": "type1", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type2", + "columnName": "type2", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isLegendary", + "columnName": "isLegendary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isMythical", + "columnName": "isMythical", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isFinalEvolution", + "columnName": "isFinalEvolution", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "generationIntroduced", + "columnName": "generationIntroduced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultAbility", + "columnName": "defaultAbility", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isDefaultForm", + "columnName": "isDefaultForm", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "baseStatTotal", + "columnName": "baseStatTotal", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_poke_species_searchName", + "unique": false, + "columnNames": [ + "searchName" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_poke_species_searchName` ON `${TABLE_NAME}` (`searchName`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "poke_move", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `displayName` TEXT NOT NULL, `searchName` TEXT NOT NULL, `typeName` TEXT NOT NULL, `power` INTEGER, `damageClass` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "searchName", + "columnName": "searchName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "typeName", + "columnName": "typeName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "power", + "columnName": "power", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "damageClass", + "columnName": "damageClass", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_poke_move_searchName", + "unique": false, + "columnNames": [ + "searchName" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_poke_move_searchName` ON `${TABLE_NAME}` (`searchName`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "poke_ability", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `displayName` TEXT NOT NULL, `searchName` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "searchName", + "columnName": "searchName", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_poke_ability_searchName", + "unique": false, + "columnNames": [ + "searchName" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_poke_ability_searchName` ON `${TABLE_NAME}` (`searchName`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "type_efficacy", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`attacker` TEXT NOT NULL, `defender` TEXT NOT NULL, `factor` REAL NOT NULL, PRIMARY KEY(`attacker`, `defender`))", + "fields": [ + { + "fieldPath": "attacker", + "columnName": "attacker", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defender", + "columnName": "defender", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "factor", + "columnName": "factor", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "attacker", + "defender" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "poke_cache_meta", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `schemaVersion` INTEGER NOT NULL, `datasetRevision` TEXT NOT NULL, `syncedAtEpochMillis` INTEGER NOT NULL, `speciesCount` INTEGER NOT NULL, `moveCount` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "schemaVersion", + "columnName": "schemaVersion", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "datasetRevision", + "columnName": "datasetRevision", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "syncedAtEpochMillis", + "columnName": "syncedAtEpochMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "speciesCount", + "columnName": "speciesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "moveCount", + "columnName": "moveCount", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "team", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `createdAtEpochMillis` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAtEpochMillis", + "columnName": "createdAtEpochMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "team_member", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `teamId` TEXT NOT NULL, `slotIndex` INTEGER NOT NULL, `pokedexId` INTEGER, `speciesName` TEXT NOT NULL, `type1` TEXT NOT NULL, `type2` TEXT, `ability` TEXT, `isCustomSaved` INTEGER NOT NULL, `item` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`teamId`) REFERENCES `team`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "teamId", + "columnName": "teamId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "slotIndex", + "columnName": "slotIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pokedexId", + "columnName": "pokedexId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "speciesName", + "columnName": "speciesName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type1", + "columnName": "type1", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type2", + "columnName": "type2", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ability", + "columnName": "ability", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isCustomSaved", + "columnName": "isCustomSaved", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "item", + "columnName": "item", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_team_member_teamId", + "unique": false, + "columnNames": [ + "teamId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_team_member_teamId` ON `${TABLE_NAME}` (`teamId`)" + } + ], + "foreignKeys": [ + { + "table": "team", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "teamId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "team_member_move", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `memberId` TEXT NOT NULL, `moveIndex` INTEGER NOT NULL, `name` TEXT NOT NULL, `typeName` TEXT NOT NULL, `power` INTEGER, `damageClass` TEXT NOT NULL, `isCustom` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`memberId`) REFERENCES `team_member`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "memberId", + "columnName": "memberId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "moveIndex", + "columnName": "moveIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "typeName", + "columnName": "typeName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "power", + "columnName": "power", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "damageClass", + "columnName": "damageClass", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isCustom", + "columnName": "isCustom", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_team_member_move_memberId", + "unique": false, + "columnNames": [ + "memberId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_team_member_move_memberId` ON `${TABLE_NAME}` (`memberId`)" + } + ], + "foreignKeys": [ + { + "table": "team_member", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "memberId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "custom_pokemon", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `type1` TEXT NOT NULL, `type2` TEXT, `ability` TEXT, `createdAtEpochMillis` INTEGER NOT NULL, `item` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type1", + "columnName": "type1", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type2", + "columnName": "type2", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "ability", + "columnName": "ability", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAtEpochMillis", + "columnName": "createdAtEpochMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "item", + "columnName": "item", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "custom_pokemon_move", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `customId` TEXT NOT NULL, `moveIndex` INTEGER NOT NULL, `name` TEXT NOT NULL, `typeName` TEXT NOT NULL, `power` INTEGER, `damageClass` TEXT NOT NULL, `isCustom` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`customId`) REFERENCES `custom_pokemon`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "customId", + "columnName": "customId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "moveIndex", + "columnName": "moveIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "typeName", + "columnName": "typeName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "power", + "columnName": "power", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "damageClass", + "columnName": "damageClass", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isCustom", + "columnName": "isCustom", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_custom_pokemon_move_customId", + "unique": false, + "columnNames": [ + "customId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_custom_pokemon_move_customId` ON `${TABLE_NAME}` (`customId`)" + } + ], + "foreignKeys": [ + { + "table": "custom_pokemon", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "customId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "poke_pokemon_ability", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`pokemonId` INTEGER NOT NULL, `slot` INTEGER NOT NULL, `abilitySlug` TEXT NOT NULL, `displayName` TEXT NOT NULL, `isHidden` INTEGER NOT NULL, PRIMARY KEY(`pokemonId`, `slot`))", + "fields": [ + { + "fieldPath": "pokemonId", + "columnName": "pokemonId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "slot", + "columnName": "slot", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "abilitySlug", + "columnName": "abilitySlug", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isHidden", + "columnName": "isHidden", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "pokemonId", + "slot" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "poke_species_bst_past", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`pokemonId` INTEGER NOT NULL, `generationId` INTEGER NOT NULL, `bst` INTEGER NOT NULL, PRIMARY KEY(`pokemonId`, `generationId`))", + "fields": [ + { + "fieldPath": "pokemonId", + "columnName": "pokemonId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "generationId", + "columnName": "generationId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bst", + "columnName": "bst", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "pokemonId", + "generationId" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '00000000000000000000000000000000')" + ] + } +} diff --git a/app/src/main/java/com/marcogn/coverdex/data/local/CoverDexDatabase.kt b/app/src/main/java/com/marcogn/coverdex/data/local/CoverDexDatabase.kt index 8aa3af6..9d662ec 100644 --- a/app/src/main/java/com/marcogn/coverdex/data/local/CoverDexDatabase.kt +++ b/app/src/main/java/com/marcogn/coverdex/data/local/CoverDexDatabase.kt @@ -11,6 +11,8 @@ import com.marcogn.coverdex.data.local.entity.CustomPokemonMoveEntity import com.marcogn.coverdex.data.local.entity.PokeAbilityEntity import com.marcogn.coverdex.data.local.entity.PokeCacheMetaEntity import com.marcogn.coverdex.data.local.entity.PokeMoveEntity +import com.marcogn.coverdex.data.local.entity.PokePokemonAbilityEntity +import com.marcogn.coverdex.data.local.entity.PokeSpeciesBstPastEntity import com.marcogn.coverdex.data.local.entity.PokeSpeciesEntity import com.marcogn.coverdex.data.local.entity.TeamEntity import com.marcogn.coverdex.data.local.entity.TeamMemberEntity @@ -24,13 +26,15 @@ import com.marcogn.coverdex.data.local.entity.TypeEfficacyEntity PokeAbilityEntity::class, TypeEfficacyEntity::class, PokeCacheMetaEntity::class, + PokePokemonAbilityEntity::class, + PokeSpeciesBstPastEntity::class, TeamEntity::class, TeamMemberEntity::class, TeamMemberMoveEntity::class, CustomPokemonEntity::class, CustomPokemonMoveEntity::class, ], - version = 2, + version = 3, exportSchema = true, ) abstract class CoverDexDatabase : RoomDatabase() { diff --git a/app/src/main/java/com/marcogn/coverdex/data/local/dao/CustomPokemonDao.kt b/app/src/main/java/com/marcogn/coverdex/data/local/dao/CustomPokemonDao.kt index 0be76de..ce0bfe7 100644 --- a/app/src/main/java/com/marcogn/coverdex/data/local/dao/CustomPokemonDao.kt +++ b/app/src/main/java/com/marcogn/coverdex/data/local/dao/CustomPokemonDao.kt @@ -28,10 +28,10 @@ interface CustomPokemonDao { * is deliberately never part of it, so editing a roster entry can never reset its creation * time (and so its place in `observeRoster()`'s `ORDER BY createdAtEpochMillis`). */ @Query( - "UPDATE custom_pokemon SET name = :name, type1 = :type1, type2 = :type2, ability = :ability " + - "WHERE id = :id", + "UPDATE custom_pokemon SET name = :name, type1 = :type1, type2 = :type2, ability = :ability, " + + "item = :item WHERE id = :id", ) - suspend fun updateFields(id: String, name: String, type1: String, type2: String?, ability: String?) + suspend fun updateFields(id: String, name: String, type1: String, type2: String?, ability: String?, item: String?) @Query("DELETE FROM custom_pokemon_move WHERE customId = :customId") suspend fun deleteMovesForCustom(customId: String) @@ -46,7 +46,7 @@ interface CustomPokemonDao { @Transaction suspend fun upsert(entity: CustomPokemonEntity, moves: List) { if (exists(entity.id)) { - updateFields(entity.id, entity.name, entity.type1, entity.type2, entity.ability) + updateFields(entity.id, entity.name, entity.type1, entity.type2, entity.ability, entity.item) } else { insert(entity) } diff --git a/app/src/main/java/com/marcogn/coverdex/data/local/dao/PokedexDao.kt b/app/src/main/java/com/marcogn/coverdex/data/local/dao/PokedexDao.kt index 4b6dca9..e8dd82c 100644 --- a/app/src/main/java/com/marcogn/coverdex/data/local/dao/PokedexDao.kt +++ b/app/src/main/java/com/marcogn/coverdex/data/local/dao/PokedexDao.kt @@ -8,6 +8,8 @@ import androidx.room.Transaction import com.marcogn.coverdex.data.local.entity.PokeAbilityEntity import com.marcogn.coverdex.data.local.entity.PokeCacheMetaEntity import com.marcogn.coverdex.data.local.entity.PokeMoveEntity +import com.marcogn.coverdex.data.local.entity.PokePokemonAbilityEntity +import com.marcogn.coverdex.data.local.entity.PokeSpeciesBstPastEntity import com.marcogn.coverdex.data.local.entity.PokeSpeciesEntity import com.marcogn.coverdex.data.local.entity.TypeEfficacyEntity import kotlinx.coroutines.flow.Flow @@ -118,6 +120,45 @@ interface PokedexDao { @Query("SELECT * FROM type_efficacy") suspend fun getAllTypeEfficacy(): List + // --- Per-form canonical abilities (Phase 7) --- + + @Query("DELETE FROM poke_pokemon_ability") + suspend fun deleteAllPokemonAbilities() + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertPokemonAbilities(items: List) + + @Transaction + suspend fun replaceAllPokemonAbilities(items: List) { + deleteAllPokemonAbilities() + insertPokemonAbilities(items) + } + + /** Ordered hidden-last, then by slot — matches the ability picker's canonical-list order + * (phase-7-accuracy-and-customization.md §3.2). */ + @Query("SELECT * FROM poke_pokemon_ability WHERE pokemonId = :pokemonId ORDER BY isHidden, slot") + suspend fun getAbilitiesForSpecies(pokemonId: Int): List + + // --- Historical base stat totals (Phase 7) --- + + @Query("DELETE FROM poke_species_bst_past") + suspend fun deleteAllBstPast() + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertBstPast(items: List) + + @Transaction + suspend fun replaceAllBstPast(items: List) { + deleteAllBstPast() + insertBstPast(items) + } + + /** Every historical BST row — a small table (a few hundred rows at most; see + * phase-7-accuracy-and-customization.md §2.2), so callers building a per-generation lookup + * load it whole, the same one-shot-list shape as [getAllSpecies]/[getAllMoves]. */ + @Query("SELECT * FROM poke_species_bst_past") + suspend fun getAllBstPast(): List + // --- Cache metadata --- @Query("SELECT * FROM poke_cache_meta WHERE id = 1") @@ -140,12 +181,16 @@ interface PokedexDao { moves: List, abilities: List, typeEfficacy: List, + pokemonAbilities: List, + bstPast: List, meta: PokeCacheMetaEntity, ) { replaceAllSpecies(species) replaceAllMoves(moves) replaceAllAbilities(abilities) replaceAllTypeEfficacy(typeEfficacy) + replaceAllPokemonAbilities(pokemonAbilities) + replaceAllBstPast(bstPast) upsertMeta(meta) } @@ -157,6 +202,8 @@ interface PokedexDao { deleteAllMoves() deleteAllAbilities() deleteAllTypeEfficacy() + deleteAllPokemonAbilities() + deleteAllBstPast() deleteMeta() } } diff --git a/app/src/main/java/com/marcogn/coverdex/data/local/entity/PokedexEntities.kt b/app/src/main/java/com/marcogn/coverdex/data/local/entity/PokedexEntities.kt index 2ae46e7..65bfdcb 100644 --- a/app/src/main/java/com/marcogn/coverdex/data/local/entity/PokedexEntities.kt +++ b/app/src/main/java/com/marcogn/coverdex/data/local/entity/PokedexEntities.kt @@ -1,5 +1,6 @@ package com.marcogn.coverdex.data.local.entity +import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.Index import androidx.room.PrimaryKey @@ -26,6 +27,37 @@ data class PokeSpeciesEntity( val generationIntroduced: Int, val defaultAbility: String?, val isDefaultForm: Boolean, + /** Added in Phase 7 by `ALTER TABLE ... ADD COLUMN ... DEFAULT 0` (schema v3) — the default + * is required so every pre-existing row gets a value, and must be declared here too or + * `MigrationTestHelper`'s schema validation flags a mismatch. See + * docs/plan/phase-7-accuracy-and-customization.md §8. */ + @ColumnInfo(defaultValue = "0") + val baseStatTotal: Int = 0, +) + +/** A species form's canonical ability, one row per (form, slot) — added in Phase 7 to back the + * ability picker's canonical list, see phase-7-accuracy-and-customization.md §3.2/§8. Wiped and + * rebuilt by `PokedexDao.replaceCache()`/`clearCache()` alongside every other cache table, never + * by `clearAllTables()`. */ +@Entity(tableName = "poke_pokemon_ability", primaryKeys = ["pokemonId", "slot"]) +data class PokePokemonAbilityEntity( + val pokemonId: Int, + val slot: Int, + val abilitySlug: String, + val displayName: String, + val isHidden: Boolean, +) + +/** A form's base stat total as it held through an older generation — only present for the small + * number of forms whose stats changed across generations; see [PokeSpeciesEntity.baseStatTotal] + * for the current value and docs/plan/phase-7-accuracy-and-customization.md §2.2 for the + * generation-1 five-stat rule this backs. Same cache-table lifecycle as + * [PokePokemonAbilityEntity]. */ +@Entity(tableName = "poke_species_bst_past", primaryKeys = ["pokemonId", "generationId"]) +data class PokeSpeciesBstPastEntity( + val pokemonId: Int, + val generationId: Int, + val bst: Int, ) @Entity(tableName = "poke_move", indices = [Index("searchName")]) diff --git a/app/src/main/java/com/marcogn/coverdex/data/local/entity/TeamEntities.kt b/app/src/main/java/com/marcogn/coverdex/data/local/entity/TeamEntities.kt index 3ec319c..c6fd710 100644 --- a/app/src/main/java/com/marcogn/coverdex/data/local/entity/TeamEntities.kt +++ b/app/src/main/java/com/marcogn/coverdex/data/local/entity/TeamEntities.kt @@ -44,6 +44,9 @@ data class TeamMemberEntity( val type2: String?, val ability: String?, val isCustomSaved: Boolean, + /** Nullable, added in Phase 7 (schema v3) — see + * docs/plan/phase-7-accuracy-and-customization.md §4. */ + val item: String? = null, ) @Entity( @@ -77,6 +80,9 @@ data class CustomPokemonEntity( val type2: String?, val ability: String?, val createdAtEpochMillis: Long, + /** Nullable, added in Phase 7 (schema v3) — see + * docs/plan/phase-7-accuracy-and-customization.md §4. */ + val item: String? = null, ) @Entity( diff --git a/app/src/main/java/com/marcogn/coverdex/data/local/migration/Migrations.kt b/app/src/main/java/com/marcogn/coverdex/data/local/migration/Migrations.kt index 755d197..b107946 100644 --- a/app/src/main/java/com/marcogn/coverdex/data/local/migration/Migrations.kt +++ b/app/src/main/java/com/marcogn/coverdex/data/local/migration/Migrations.kt @@ -40,3 +40,26 @@ val MIGRATION_1_2 = object : Migration(1, 2) { db.execSQL("CREATE INDEX IF NOT EXISTS `index_custom_pokemon_move_customId` ON `custom_pokemon_move` (`customId`)") } } + +/** Additive only — adds the held-item columns and the two catalogue tables Phase 7 needs (base + * stats and canonical per-form abilities). See + * docs/plan/phase-7-accuracy-and-customization.md §8. The new `poke_species` column needs a + * `DEFAULT 0`: SQLite requires one on `ALTER TABLE ... ADD COLUMN` when the column is `NOT NULL`, + * so every pre-existing row gets a value instead of the migration failing outright. */ +val MIGRATION_2_3 = object : Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `team_member` ADD COLUMN `item` TEXT") + db.execSQL("ALTER TABLE `custom_pokemon` ADD COLUMN `item` TEXT") + db.execSQL("ALTER TABLE `poke_species` ADD COLUMN `baseStatTotal` INTEGER NOT NULL DEFAULT 0") + db.execSQL( + "CREATE TABLE IF NOT EXISTS `poke_pokemon_ability` (`pokemonId` INTEGER NOT NULL, " + + "`slot` INTEGER NOT NULL, `abilitySlug` TEXT NOT NULL, `displayName` TEXT NOT NULL, " + + "`isHidden` INTEGER NOT NULL, PRIMARY KEY(`pokemonId`, `slot`))", + ) + db.execSQL( + "CREATE TABLE IF NOT EXISTS `poke_species_bst_past` (`pokemonId` INTEGER NOT NULL, " + + "`generationId` INTEGER NOT NULL, `bst` INTEGER NOT NULL, " + + "PRIMARY KEY(`pokemonId`, `generationId`))", + ) + } +} diff --git a/app/src/main/java/com/marcogn/coverdex/data/pokeapi/DatasetSyncManager.kt b/app/src/main/java/com/marcogn/coverdex/data/pokeapi/DatasetSyncManager.kt index e516991..66f6813 100644 --- a/app/src/main/java/com/marcogn/coverdex/data/pokeapi/DatasetSyncManager.kt +++ b/app/src/main/java/com/marcogn/coverdex/data/pokeapi/DatasetSyncManager.kt @@ -78,6 +78,10 @@ class DatasetSyncManager @Inject constructor( movesCsv = files.getValue(DatasetFile.MOVES), typesCsv = files.getValue(DatasetFile.TYPES), typeEfficacyCsv = files.getValue(DatasetFile.TYPE_EFFICACY), + pokemonStatsCsv = files.getValue(DatasetFile.POKEMON_STATS), + pokemonStatsPastCsv = files.getValue(DatasetFile.POKEMON_STATS_PAST), + abilityNamesCsv = files.getValue(DatasetFile.ABILITY_NAMES), + moveNamesCsv = files.getValue(DatasetFile.MOVE_NAMES), ) _state.value = SyncState.Running(SyncStage.WRITING, progress = 0.95f) @@ -87,6 +91,8 @@ class DatasetSyncManager @Inject constructor( moves = dataset.moves.map { it.toEntity() }, abilities = dataset.abilities.map { it.toEntity() }, typeEfficacy = dataset.typeChart.toEntities(), + pokemonAbilities = dataset.pokemonAbilities.map { it.toEntity() }, + bstPast = dataset.pastBst.map { it.toEntity() }, meta = PokeCacheMetaEntity( schemaVersion = DATASET_SCHEMA_VERSION, datasetRevision = DATASET_REVISION, diff --git a/app/src/main/java/com/marcogn/coverdex/data/pokeapi/PokeDataClient.kt b/app/src/main/java/com/marcogn/coverdex/data/pokeapi/PokeDataClient.kt index c479a9d..2f49aa1 100644 --- a/app/src/main/java/com/marcogn/coverdex/data/pokeapi/PokeDataClient.kt +++ b/app/src/main/java/com/marcogn/coverdex/data/pokeapi/PokeDataClient.kt @@ -23,8 +23,11 @@ private const val CONNECT_TIMEOUT_MS = 10_000 private const val READ_TIMEOUT_MS = 15_000 private const val RETRY_DELAY_MS = 500L -/** The 8 pinned CSV files that make up the whole dataset — see - * docs/plan/reference-pokedata.md §2 for exact sizes and headers. */ +/** The 12 pinned CSV files that make up the whole dataset — see + * docs/plan/reference-pokedata.md §2 for exact sizes and headers. The last four were added in + * Phase 7 (docs/plan/phase-7-accuracy-and-customization.md §2) for base stats and correct + * English ability/move names; they are read at the same pinned [DATASET_REVISION] as the + * original eight. */ enum class DatasetFile(val fileName: String) { POKEMON("pokemon.csv"), SPECIES("pokemon_species.csv"), @@ -34,6 +37,10 @@ enum class DatasetFile(val fileName: String) { MOVES("moves.csv"), TYPES("types.csv"), TYPE_EFFICACY("type_efficacy.csv"), + POKEMON_STATS("pokemon_stats.csv"), + POKEMON_STATS_PAST("pokemon_stats_past.csv"), + ABILITY_NAMES("ability_names.csv"), + MOVE_NAMES("move_names.csv"), } /** Source of the 8 pinned CSV files. The only reason this is an interface rather than just diff --git a/app/src/main/java/com/marcogn/coverdex/data/repository/Mappers.kt b/app/src/main/java/com/marcogn/coverdex/data/repository/Mappers.kt index 70f5aef..1188c9c 100644 --- a/app/src/main/java/com/marcogn/coverdex/data/repository/Mappers.kt +++ b/app/src/main/java/com/marcogn/coverdex/data/repository/Mappers.kt @@ -2,13 +2,17 @@ package com.marcogn.coverdex.data.repository import com.marcogn.coverdex.data.local.entity.PokeAbilityEntity import com.marcogn.coverdex.data.local.entity.PokeMoveEntity +import com.marcogn.coverdex.data.local.entity.PokePokemonAbilityEntity +import com.marcogn.coverdex.data.local.entity.PokeSpeciesBstPastEntity import com.marcogn.coverdex.data.local.entity.PokeSpeciesEntity import com.marcogn.coverdex.data.local.entity.TypeEfficacyEntity import com.marcogn.coverdex.domain.model.AbilityEntry import com.marcogn.coverdex.domain.model.DamageClass import com.marcogn.coverdex.domain.model.MoveEntry +import com.marcogn.coverdex.domain.model.PastBst import com.marcogn.coverdex.domain.model.PokemonEntry import com.marcogn.coverdex.domain.model.PokemonType +import com.marcogn.coverdex.domain.model.SpeciesAbility import com.marcogn.coverdex.domain.model.TypeChart fun PokemonEntry.toEntity(): PokeSpeciesEntity = PokeSpeciesEntity( @@ -26,6 +30,7 @@ fun PokemonEntry.toEntity(): PokeSpeciesEntity = PokeSpeciesEntity( generationIntroduced = generationIntroduced, defaultAbility = defaultAbility, isDefaultForm = isDefaultForm, + baseStatTotal = baseStatTotal, ) fun PokeSpeciesEntity.toDomain(): PokemonEntry? { @@ -44,6 +49,7 @@ fun PokeSpeciesEntity.toDomain(): PokemonEntry? { generationIntroduced = generationIntroduced, defaultAbility = defaultAbility, isDefaultForm = isDefaultForm, + baseStatTotal = baseStatTotal, ) } @@ -77,6 +83,30 @@ fun TypeChart.toEntities(): List = TypeEfficacyEntity(attacker = attacker.apiName, defender = defender.apiName, factor = factor) } +fun SpeciesAbility.toEntity(): PokePokemonAbilityEntity = PokePokemonAbilityEntity( + pokemonId = pokemonId, + slot = slot, + abilitySlug = slug, + displayName = displayName, + isHidden = isHidden, +) + +fun PokePokemonAbilityEntity.toDomain(): SpeciesAbility = SpeciesAbility( + pokemonId = pokemonId, + slug = abilitySlug, + displayName = displayName, + isHidden = isHidden, + slot = slot, +) + +fun PastBst.toEntity(): PokeSpeciesBstPastEntity = PokeSpeciesBstPastEntity( + pokemonId = pokemonId, + generationId = generationId, + bst = bst, +) + +fun PokeSpeciesBstPastEntity.toDomain(): PastBst = PastBst(pokemonId = pokemonId, generationId = generationId, bst = bst) + fun List.toTypeChart(): TypeChart { val table = mutableMapOf>() for (row in this) { diff --git a/app/src/main/java/com/marcogn/coverdex/data/repository/PokedexRepositoryImpl.kt b/app/src/main/java/com/marcogn/coverdex/data/repository/PokedexRepositoryImpl.kt index f8a3664..5b322bd 100644 --- a/app/src/main/java/com/marcogn/coverdex/data/repository/PokedexRepositoryImpl.kt +++ b/app/src/main/java/com/marcogn/coverdex/data/repository/PokedexRepositoryImpl.kt @@ -6,7 +6,9 @@ import com.marcogn.coverdex.data.pokeapi.DatasetSyncManager import com.marcogn.coverdex.domain.model.AbilityEntry import com.marcogn.coverdex.domain.model.CacheStatus import com.marcogn.coverdex.domain.model.MoveEntry +import com.marcogn.coverdex.domain.model.PastBst import com.marcogn.coverdex.domain.model.PokemonEntry +import com.marcogn.coverdex.domain.model.SpeciesAbility import com.marcogn.coverdex.domain.model.SyncState import com.marcogn.coverdex.domain.model.TypeChart import com.marcogn.coverdex.domain.pokeapi.DATASET_SCHEMA_VERSION @@ -73,4 +75,9 @@ class PokedexRepositoryImpl @Inject constructor( override suspend fun allMoves(): List = pokedexDao.getAllMoves().mapNotNull { it.toDomain() } override suspend fun typeChart(): TypeChart = pokedexDao.getAllTypeEfficacy().toTypeChart() + + override suspend fun abilitiesForSpecies(pokemonId: Int): List = + pokedexDao.getAbilitiesForSpecies(pokemonId).map { it.toDomain() } + + override suspend fun allPastBst(): List = pokedexDao.getAllBstPast().map { it.toDomain() } } diff --git a/app/src/main/java/com/marcogn/coverdex/di/DatabaseModule.kt b/app/src/main/java/com/marcogn/coverdex/di/DatabaseModule.kt index b6ac6a3..b2af7ee 100644 --- a/app/src/main/java/com/marcogn/coverdex/di/DatabaseModule.kt +++ b/app/src/main/java/com/marcogn/coverdex/di/DatabaseModule.kt @@ -8,6 +8,7 @@ import com.marcogn.coverdex.data.local.dao.CustomPokemonDao import com.marcogn.coverdex.data.local.dao.PokedexDao import com.marcogn.coverdex.data.local.dao.TeamDao import com.marcogn.coverdex.data.local.migration.MIGRATION_1_2 +import com.marcogn.coverdex.data.local.migration.MIGRATION_2_3 import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -25,7 +26,7 @@ object DatabaseModule { @Singleton fun provideDatabase(@ApplicationContext context: Context): CoverDexDatabase = Room.databaseBuilder(context, CoverDexDatabase::class.java, CoverDexDatabase.DATABASE_NAME) - .addMigrations(MIGRATION_1_2) + .addMigrations(MIGRATION_1_2, MIGRATION_2_3) .build() @Provides diff --git a/app/src/main/java/com/marcogn/coverdex/domain/ability/AbilityEffects.kt b/app/src/main/java/com/marcogn/coverdex/domain/ability/AbilityEffects.kt index 3adb450..b2b59a1 100644 --- a/app/src/main/java/com/marcogn/coverdex/domain/ability/AbilityEffects.kt +++ b/app/src/main/java/com/marcogn/coverdex/domain/ability/AbilityEffects.kt @@ -2,39 +2,60 @@ package com.marcogn.coverdex.domain.ability import com.marcogn.coverdex.domain.model.PokemonType -/** Verbatim port of `legacy-web/src/data/abilityEffects.ts`'s `AbilityEffect` union — adding or - * removing an entry anywhere in this file is a spec change, not an implementation detail (see - * `docs/plan/phase-3-analysis.md` §1). */ +/** Ported originally from `legacy-web/src/data/abilityEffects.ts`'s `AbilityEffect` union (that + * directory was deleted in Phase 6 — see CLAUDE.md, "Sibling projects"); extended in Phase 7 + * (docs/plan/phase-7-accuracy-and-customization.md §7.1) with the variants the original TypeScript + * never needed. Adding or removing an entry anywhere in this file is a spec change, not an + * implementation detail. */ enum class AbilityEffectSide { OFFENSIVE, DEFENSIVE } sealed interface AbilityEffect { data class Immunity(val type: PokemonType) : AbilityEffect data class Multiplier(val type: PokemonType, val factor: Double, val side: AbilityEffectSide) : AbilityEffect data class BadgeOnly(val note: String) : AbilityEffect + + /** Filter / Solid Rock / Prism Armor: multiplies an already-super-effective (>1x) incoming + * hit by [factor]. A no-op at 1x or below — see [applyAbilityEffects]. */ + data class SuperEffectiveMultiplier(val factor: Double) : AbilityEffect + + /** Delta Stream: nothing is super-effective against the holder while its ability holds — + * caps an incoming multiplier at 1.0. */ + data object NeverSuperEffective : AbilityEffect + + /** Wonder Guard: only a super-effective (>1x) hit deals any damage at all — every other + * multiplier, resistances and neutral hits alike, becomes 0. Promoted from [BadgeOnly] in + * Phase 7: leaving Shedinja's signature ability as a UI-only badge made `defensiveProfile` + * actively wrong for the one Pokemon it applies to (phase-7-...md §7.1). */ + data object OnlySuperEffective : AbilityEffect } -/** Canonical list of abilities with known coverage effects, used by the ability picker UI. Names - * are in display format (lowercase, space-separated) — matches the TS list verbatim. */ -val KNOWN_ABILITIES_WITH_EFFECTS: List = listOf( - "volt absorb", - "lightning rod", - "motor drive", - "water absorb", - "storm drain", - "dry skin", - "flash fire", - "sap sipper", - "levitate", - "earth eater", - "well-baked body", - "thick fat", - "fluffy", - "wonder guard", -) +/** Ability slugs (PokéAPI identifier format, e.g. `"sap-sipper"`) that have a coverage-relevant + * effect, in ability-picker display format (spaces instead of hyphens) — regenerated from + * [ABILITY_EFFECTS] rather than hand-maintained a second time, so it cannot drift from the actual + * effect table (phase-7-accuracy-and-customization.md §7.1). Not currently read by any UI; kept + * for whatever surface wants a flat "which abilities matter" list without inspecting + * [ABILITY_EFFECTS] itself — the ability picker's own "has an effect" badge + * (`ui/team/SlotEditorScreen.kt`) checks `abilityKey(name) in ABILITY_EFFECTS` directly instead. */ +val KNOWN_ABILITIES_WITH_EFFECTS: List = ABILITY_EFFECTS.keys.map { it.replace('-', ' ') } -/** Hardcoded map of ability slugs (lowercase, hyphenated, matching PokéAPI) to their - * coverage-relevant effects. Only abilities that alter defensive multipliers or warrant a UI - * badge are included here — verbatim port of `ABILITY_EFFECTS`. */ +/** + * Hardcoded map of ability slugs (lowercase, hyphenated, matching PokéAPI) to their + * coverage-relevant effects. Only abilities that alter defensive/offensive type effectiveness or + * warrant a UI badge are included here. + * + * The ten entries from `heatproof` through `tera-shell`, and the `well-baked-body` + * fix-up to `wonder-guard`'s promotion, were added in Phase 7 after auditing every ability's + * `short_effect` text from the pinned dataset's `ability_prose.csv` — see + * docs/plan/phase-7-accuracy-and-customization.md §0.4/§7.1 for the sourcing and the forms-count + * evidence. `tinted-lens` and `neuroforce` are deliberately absent: neither moves a multiplier + * across the >=2x threshold [com.marcogn.coverdex.domain.coverage.offensiveCoverageForMember] + * tests, so neither changes coverage, and this table only models effects that do. + * `primordial-sea`/`desolate-land` are real field effects that apply to both sides in the actual + * games; modelled here as the holder's own immunity only, since this app has no weather/field + * concept. `tera-shell` stays [AbilityEffect.BadgeOnly]: it is unconditional only at full HP, and + * this engine has no HP concept, so modelling it as an always-on multiplier would be wrong more + * often than right. + */ val ABILITY_EFFECTS: Map> = mapOf( // Immunities (defensive — incoming moves of that type deal 0) "volt-absorb" to listOf(AbilityEffect.Immunity(PokemonType.ELECTRIC)), @@ -42,28 +63,123 @@ val ABILITY_EFFECTS: Map> = mapOf( "motor-drive" to listOf(AbilityEffect.Immunity(PokemonType.ELECTRIC)), "water-absorb" to listOf(AbilityEffect.Immunity(PokemonType.WATER)), "storm-drain" to listOf(AbilityEffect.Immunity(PokemonType.WATER)), - "dry-skin" to listOf(AbilityEffect.Immunity(PokemonType.WATER)), "flash-fire" to listOf(AbilityEffect.Immunity(PokemonType.FIRE)), "sap-sipper" to listOf(AbilityEffect.Immunity(PokemonType.GRASS)), "levitate" to listOf(AbilityEffect.Immunity(PokemonType.GROUND)), "earth-eater" to listOf(AbilityEffect.Immunity(PokemonType.GROUND)), "well-baked-body" to listOf(AbilityEffect.Immunity(PokemonType.FIRE)), + "primordial-sea" to listOf(AbilityEffect.Immunity(PokemonType.FIRE)), + "desolate-land" to listOf(AbilityEffect.Immunity(PokemonType.WATER)), + // Dry Skin: absorbs Water (immune) but takes 1.25x from Fire — both halves of its real + // effect, unlike the pre-Phase-7 table which only had the immunity. + "dry-skin" to listOf( + AbilityEffect.Immunity(PokemonType.WATER), + AbilityEffect.Multiplier(PokemonType.FIRE, 1.25, AbilityEffectSide.DEFENSIVE), + ), // Multiplier (defensive — modifies effective damage multiplier received) "thick-fat" to listOf( AbilityEffect.Multiplier(PokemonType.FIRE, 0.5, AbilityEffectSide.DEFENSIVE), AbilityEffect.Multiplier(PokemonType.ICE, 0.5, AbilityEffectSide.DEFENSIVE), ), "fluffy" to listOf(AbilityEffect.Multiplier(PokemonType.FIRE, 2.0, AbilityEffectSide.DEFENSIVE)), + "heatproof" to listOf(AbilityEffect.Multiplier(PokemonType.FIRE, 0.5, AbilityEffectSide.DEFENSIVE)), + "water-bubble" to listOf(AbilityEffect.Multiplier(PokemonType.FIRE, 0.5, AbilityEffectSide.DEFENSIVE)), + "purifying-salt" to listOf(AbilityEffect.Multiplier(PokemonType.GHOST, 0.5, AbilityEffectSide.DEFENSIVE)), + // Super-effective reducers (defensive — only bite once a hit is already >1x) + "filter" to listOf(AbilityEffect.SuperEffectiveMultiplier(0.75)), + "solid-rock" to listOf(AbilityEffect.SuperEffectiveMultiplier(0.75)), + "prism-armor" to listOf(AbilityEffect.SuperEffectiveMultiplier(0.75)), + "delta-stream" to listOf(AbilityEffect.NeverSuperEffective), // Badge-only (no calculation change) - "wonder-guard" to listOf(AbilityEffect.BadgeOnly("Only super-effective moves deal damage")), + "tera-shell" to listOf(AbilityEffect.BadgeOnly("Not very effective at full HP")), + "wonder-guard" to listOf(AbilityEffect.OnlySuperEffective), ) -/** Normalize an ability name to the slug format used as keys in [ABILITY_EFFECTS]. */ -fun normalizeAbilityName(name: String): String = name.lowercase().replace(Regex("\\s+"), "-") +/** Lowercase, letters and digits only — so `"Well-Baked Body"`, `"well-baked-body"` and + * `"wellbakedbody"` all resolve to the same [ABILITY_EFFECTS] entry, mirroring + * [com.marcogn.coverdex.domain.pokeapi.searchKey]. Replaces the pre-Phase-7 `normalizeAbilityName` + * (space-to-hyphen only), which could not match a display name that keeps a hyphen the slug + * doesn't have or vice versa — see docs/plan/phase-7-accuracy-and-customization.md §0.2/§3.1. + * [ABILITY_EFFECTS]'s own keys are hyphenated slugs, so this function is also applied to them at + * lookup time via [normalizedEffectsBySymbolFreeKey]. */ +fun abilityKey(name: String): String = name.lowercase().filter { it.isLetterOrDigit() } + +private val normalizedEffectsBySymbolFreeKey: Map> = + ABILITY_EFFECTS.mapKeys { (slug, _) -> abilityKey(slug) } -/** Look up the effects for a given ability name (case-insensitive, handles spaces). `null` or - * empty (not merely blank — matches the TS `!ability` falsy check exactly) returns `null`. */ +/** Look up the effects for a given ability name — a raw PokéAPI slug, a display name, or + * anything symbol-equivalent to one (see [abilityKey]). `null` or empty (not merely blank — + * matches the pre-Phase-7 `!ability` falsy check exactly) returns `null`, same as an ability + * genuinely absent from [ABILITY_EFFECTS] (a ROM hack's custom ability, or a canonical ability + * with no coverage effect). */ fun getAbilityEffects(ability: String?): List? { if (ability.isNullOrEmpty()) return null - return ABILITY_EFFECTS[normalizeAbilityName(ability)] + return normalizedEffectsBySymbolFreeKey[abilityKey(ability)] } + +/** + * Applies every effect in [effects] to a chart-derived multiplier, in the fixed order + * docs/plan/phase-7-accuracy-and-customization.md §4.2 specifies for items (abilities apply the + * same steps, minus the item-only ones): immunities and [AbilityEffect.OnlySuperEffective] short + * circuit first (an incoming hit is either fully blocked or, for Wonder Guard, everything *but* a + * super-effective hit is), then plain multipliers, then super-effective-only reducers/caps. Pure + * arithmetic — no ordering-sensitive early return except the two variants that are absolute + * (immunity, Wonder Guard), so calling this with an empty or `null`-derived effect list is always + * a safe no-op. + */ +fun applyAbilityEffects(baseMultiplier: Double, attackingType: PokemonType, effects: List?): Double { + if (effects.isNullOrEmpty()) return baseMultiplier + + for (effect in effects) { + if (effect is AbilityEffect.Immunity && effect.type == attackingType) return 0.0 + } + if (effects.any { it is AbilityEffect.OnlySuperEffective }) { + return if (baseMultiplier > 1.0) baseMultiplier else 0.0 + } + + var result = baseMultiplier + for (effect in effects) { + if (effect is AbilityEffect.Multiplier && effect.side == AbilityEffectSide.DEFENSIVE && effect.type == attackingType) { + result *= effect.factor + } + } + if (result > 1.0) { + for (effect in effects) { + when (effect) { + is AbilityEffect.SuperEffectiveMultiplier -> result *= effect.factor + AbilityEffect.NeverSuperEffective -> result = 1.0 + else -> Unit + } + } + } + return result +} + +/** Types this ability's canonical `-ate`/Normalize effect rewrites a move's own type to — a + * Normal-type move only, except Normalize, which rewrites every move. `null` ability or one with + * no such effect returns [moveType] unchanged. Offensive gap closed in Phase 7, see + * docs/plan/phase-7-accuracy-and-customization.md §7.2; `liquid-voice` (sound-based moves become + * Water) is deliberately not modelled here — this app has no move-flag data to know which moves + * are sound-based. */ +fun overriddenMoveType(ability: String?, moveType: PokemonType): PokemonType { + if (ability.isNullOrEmpty()) return moveType + val key = abilityKey(ability) + if (key == "normalize") return PokemonType.NORMAL + if (moveType != PokemonType.NORMAL) return moveType + return when (key) { + "refrigerate" -> PokemonType.ICE + "pixilate" -> PokemonType.FAIRY + "aerilate" -> PokemonType.FLYING + "galvanize" -> PokemonType.ELECTRIC + else -> moveType + } +} + +/** Scrappy/Mind's Eye: the holder's Normal and Fighting moves hit a Ghost-type defender + * neutrally instead of being blocked outright. Never changes [PokemonType.entries]-scanning + * *coverage* (going from 0x to 1x never crosses the >=2x threshold + * [com.marcogn.coverdex.domain.coverage.offensiveCoverageForMember] tests) — only the offensive + * grid, which shows the real multiplier per type. See + * docs/plan/phase-7-accuracy-and-customization.md §7.2. */ +fun bypassesGhostImmunity(ability: String?): Boolean = + !ability.isNullOrEmpty() && abilityKey(ability) in setOf("scrappy", "mindseye") diff --git a/app/src/main/java/com/marcogn/coverdex/domain/coverage/CoverageEngine.kt b/app/src/main/java/com/marcogn/coverdex/domain/coverage/CoverageEngine.kt index 2fc502d..243fe38 100644 --- a/app/src/main/java/com/marcogn/coverdex/domain/coverage/CoverageEngine.kt +++ b/app/src/main/java/com/marcogn/coverdex/domain/coverage/CoverageEngine.kt @@ -1,8 +1,9 @@ package com.marcogn.coverdex.domain.coverage -import com.marcogn.coverdex.domain.ability.AbilityEffect -import com.marcogn.coverdex.domain.ability.AbilityEffectSide +import com.marcogn.coverdex.domain.ability.applyAbilityEffects +import com.marcogn.coverdex.domain.ability.bypassesGhostImmunity import com.marcogn.coverdex.domain.ability.getAbilityEffects +import com.marcogn.coverdex.domain.ability.overriddenMoveType import com.marcogn.coverdex.domain.model.DamageClass import com.marcogn.coverdex.domain.model.PokemonType import com.marcogn.coverdex.domain.model.TeamMember @@ -30,21 +31,9 @@ fun defensiveMultiplier( ): Double { val t1 = chart.multiplier(attackingType, defenderTypes.first) val t2 = defenderTypes.second?.let { chart.multiplier(attackingType, it) } ?: 1.0 - var result = t1 * t2 - - val effects = getAbilityEffects(ability) - if (effects != null) { - for (effect in effects) { - when (effect) { - is AbilityEffect.Immunity -> if (effect.type == attackingType) return 0.0 - is AbilityEffect.Multiplier -> - if (effect.side == AbilityEffectSide.DEFENSIVE && effect.type == attackingType) result *= effect.factor - is AbilityEffect.BadgeOnly -> Unit - } - } - } + val chartProduct = t1 * t2 - return result + return applyAbilityEffects(chartProduct, attackingType, getAbilityEffects(ability)) } private fun damagingMoveTypes(member: TeamMember): List = @@ -52,11 +41,14 @@ private fun damagingMoveTypes(member: TeamMember): List = .filter { it.damageClass != DamageClass.STATUS && (it.power ?: 0) > 0 } .map { it.type } -/** Types this member can hit super-effectively (>=2x). */ +/** Types this member can hit super-effectively (>=2x). [useMoves] gates the -ate/Normalize + * offensive rewrite too, not just which attacking types are used: type-based coverage (no moves + * entered) has no real "Normal-type move" for those abilities to rewrite, only the member's own + * typing — see docs/plan/phase-7-accuracy-and-customization.md §7.2. */ fun offensiveCoverageForMember(chart: TypeChart, member: TeamMember, useMoves: Boolean): Set { val out = mutableSetOf() val attackingTypes = if (useMoves) { - damagingMoveTypes(member) + damagingMoveTypes(member).map { overriddenMoveType(member.ability, it) } } else { listOfNotNull(member.types.first, member.types.second) } @@ -113,7 +105,11 @@ fun analyseTeam(chart: TypeChart, members: List): TeamCoverage { val best = PokemonType.entries.associateWithTo(mutableMapOf()) { 0.0 } for (m in members) { val useMoves = modePerMember[m.id] == CoverageMode.MOVES - val attackingTypes = if (useMoves) damagingMoveTypes(m) else listOfNotNull(m.types.first, m.types.second) + val attackingTypes = if (useMoves) { + damagingMoveTypes(m).map { overriddenMoveType(m.ability, it) } + } else { + listOfNotNull(m.types.first, m.types.second) + } for (atk in attackingTypes) { for (def in PokemonType.entries) { val mult = chart.multiplier(atk, def) @@ -170,8 +166,28 @@ fun sharedWeaknesses(chart: TypeChart, members: List): List { - val attackingTypes = attackingTypesForMember(member) - return PokemonType.entries.associateWith { def -> attackingTypes.maxOfOrNull { atk -> chart.multiplier(atk, def) } ?: 0.0 } + val rawAttackingTypes = attackingTypesForMember(member) + // The -ate/Normalize rewrite only applies to a real move, same gate as + // offensiveCoverageForMember's useMoves check above. + val attackingTypes = if (memberHasMoves(member)) { + rawAttackingTypes.map { overriddenMoveType(member.ability, it) } + } else { + rawAttackingTypes + } + val bypassGhost = bypassesGhostImmunity(member.ability) + return PokemonType.entries.associateWith { def -> + attackingTypes.maxOfOrNull { atk -> + val mult = chart.multiplier(atk, def) + // Scrappy/Mind's Eye: Normal/Fighting moves hit Ghost neutrally instead of being + // blocked. Never crosses the >=2x coverage threshold, so this is grid-display only — + // see overriddenMoveType's/bypassesGhostImmunity's own docs. + if (bypassGhost && mult == 0.0 && def == PokemonType.GHOST && (atk == PokemonType.NORMAL || atk == PokemonType.FIGHTING)) { + 1.0 + } else { + mult + } + } ?: 0.0 + } } /** The defensive grid's "most vulnerable" row: the worst (highest) multiplier any member takes diff --git a/app/src/main/java/com/marcogn/coverdex/domain/model/PastBst.kt b/app/src/main/java/com/marcogn/coverdex/domain/model/PastBst.kt new file mode 100644 index 0000000..43594fd --- /dev/null +++ b/app/src/main/java/com/marcogn/coverdex/domain/model/PastBst.kt @@ -0,0 +1,17 @@ +package com.marcogn.coverdex.domain.model + +/** + * A form's base stat total as it held through generation [generationId] — only emitted when it + * differs from [PokemonEntry.baseStatTotal] (the current, latest-generation value), or for + * generation 1, which is always emitted when a form has any historical Gen-1 data: Gen I has no + * Special Attack/Special Defense split, so its canonical BST is the sum of **five** stats + * (HP/Attack/Defense/Speed/Special), never six — a different scale from every later generation's + * total, which must never be compared against it. See + * docs/plan/phase-7-accuracy-and-customization.md §2.2 for the full derivation and worked + * examples. + */ +data class PastBst( + val pokemonId: Int, + val generationId: Int, + val bst: Int, +) diff --git a/app/src/main/java/com/marcogn/coverdex/domain/model/PokemonEntry.kt b/app/src/main/java/com/marcogn/coverdex/domain/model/PokemonEntry.kt index 287aa84..da3a07c 100644 --- a/app/src/main/java/com/marcogn/coverdex/domain/model/PokemonEntry.kt +++ b/app/src/main/java/com/marcogn/coverdex/domain/model/PokemonEntry.kt @@ -26,4 +26,9 @@ data class PokemonEntry( val generationIntroduced: Int, val defaultAbility: String?, val isDefaultForm: Boolean, + /** Current-generation base stat total (sum of all six stats). `0` when the pinned dataset has + * no `pokemon_stats` rows for this form — never negative, never null. Added in Phase 7 as the + * suggestion ranking's tie-break, never its primary sort — see + * docs/plan/phase-7-accuracy-and-customization.md §5. */ + val baseStatTotal: Int = 0, ) diff --git a/app/src/main/java/com/marcogn/coverdex/domain/model/SpeciesAbility.kt b/app/src/main/java/com/marcogn/coverdex/domain/model/SpeciesAbility.kt new file mode 100644 index 0000000..85e74f1 --- /dev/null +++ b/app/src/main/java/com/marcogn/coverdex/domain/model/SpeciesAbility.kt @@ -0,0 +1,16 @@ +package com.marcogn.coverdex.domain.model + +/** + * One of a species form's canonical abilities — `pokemon_abilities.csv`, joined against + * `abilities.csv`/`ability_names.csv` for display, per + * docs/plan/phase-7-accuracy-and-customization.md §2.3/§3.2. [slot] `1`/`2` are the normal ability + * slots, `3` the hidden ability; [isHidden] mirrors the CSV column directly rather than being + * derived from [slot], since PokéAPI's own hidden flag is the authority. + */ +data class SpeciesAbility( + val pokemonId: Int, + val slug: String, + val displayName: String, + val isHidden: Boolean, + val slot: Int, +) diff --git a/app/src/main/java/com/marcogn/coverdex/domain/pokeapi/DatasetAssembly.kt b/app/src/main/java/com/marcogn/coverdex/domain/pokeapi/DatasetAssembly.kt index f9551fb..e5ca5ea 100644 --- a/app/src/main/java/com/marcogn/coverdex/domain/pokeapi/DatasetAssembly.kt +++ b/app/src/main/java/com/marcogn/coverdex/domain/pokeapi/DatasetAssembly.kt @@ -3,31 +3,48 @@ package com.marcogn.coverdex.domain.pokeapi import com.marcogn.coverdex.domain.model.AbilityEntry import com.marcogn.coverdex.domain.model.DamageClass import com.marcogn.coverdex.domain.model.MoveEntry +import com.marcogn.coverdex.domain.model.PastBst import com.marcogn.coverdex.domain.model.PokemonEntry import com.marcogn.coverdex.domain.model.PokemonType +import com.marcogn.coverdex.domain.model.SpeciesAbility import com.marcogn.coverdex.domain.model.TypeChart -/** `"mr-mime"` -> `"Mr Mime"`. */ +/** `"mr-mime"` -> `"Mr Mime"`. The fallback for a species/form display name (never corrected in + * Phase 7 — see docs/plan/phase-7-accuracy-and-customization.md, "Explicitly out of scope") and + * for any ability/move identifier absent from the pinned `ability_names.csv`/`move_names.csv`. */ fun prettify(kebabName: String): String = kebabName.split("-").joinToString(" ") { part -> part.replaceFirstChar { it.uppercaseChar() } } /** `"mr-mime"` -> `"mrmime"`, so "mrmime"/"mr mime"/"mr-mime" all match the same search row. */ fun searchKey(name: String): String = name.lowercase().filter { it.isLetterOrDigit() } +private const val STAT_HP = 1 +private const val STAT_ATTACK = 2 +private const val STAT_DEFENSE = 3 +private const val STAT_SPECIAL_ATTACK = 4 +private const val STAT_SPECIAL_DEFENSE = 5 +private const val STAT_SPEED = 6 +private const val STAT_SPECIAL_GEN1 = 9 +private val CURRENT_GEN_STAT_IDS = listOf(STAT_HP, STAT_ATTACK, STAT_DEFENSE, STAT_SPECIAL_ATTACK, STAT_SPECIAL_DEFENSE, STAT_SPEED) + /** The assembled catalogue — what [com.marcogn.coverdex.data.pokeapi.DatasetSyncManager] writes - * to the cache tables in one transaction. */ + * to the cache tables in one transaction. [pastBst] and [pokemonAbilities] were added in Phase 7 + * (docs/plan/phase-7-accuracy-and-customization.md §2.3). */ data class ParsedDataset( val species: List, val moves: List, val abilities: List, val typeChart: TypeChart, + val pastBst: List, + val pokemonAbilities: List, ) /** - * Joins the 8 pinned CSVs into [ParsedDataset], implementing docs/plan/reference-pokedata.md §3 - * exactly. Pure: no Android imports, no I/O — the caller ([data.pokeapi.DatasetSyncManager]) - * fetches the raw CSV text and hands it here. + * Joins the 12 pinned CSVs into [ParsedDataset], implementing docs/plan/reference-pokedata.md §3 + * and docs/plan/phase-7-accuracy-and-customization.md §2 exactly. Pure: no Android imports, no + * I/O — the caller ([data.pokeapi.DatasetSyncManager]) fetches the raw CSV text and hands it here. */ +@Suppress("LongParameterList") fun assembleDataset( pokemonCsv: String, speciesCsv: String, @@ -37,6 +54,10 @@ fun assembleDataset( movesCsv: String, typesCsv: String, typeEfficacyCsv: String, + pokemonStatsCsv: String, + pokemonStatsPastCsv: String, + abilityNamesCsv: String, + moveNamesCsv: String, ): ParsedDataset { // types.csv also has ids 19 (stellar), 10001 (unknown) and 10002 (shadow) — filtered out here // so nothing downstream ever sees them (docs/plan/reference-pokedata.md §3). @@ -49,13 +70,23 @@ fun assembleDataset( val speciesById = speciesRows.associateBy { it.id } val evolvesFromIds = speciesRows.mapNotNull { it.evolvesFromSpeciesId }.toSet() + // English display names, falling back to prettify() when a form/move/ability has no + // ability_names.csv/move_names.csv row (phase-7-accuracy-and-customization.md §2.1/§0.3). + val abilityNameById: Map = parseAbilityNames(abilityNamesCsv).associate { it.id to it.name } + val moveNameById: Map = parseMoveNames(moveNamesCsv).associate { it.id to it.name } + val abilityRows = parseAbilities(abilitiesCsv) val abilityIdentifierById = abilityRows.associate { it.id to it.identifier } - val abilities = abilityRows.map { AbilityEntry(id = it.id, name = it.identifier, displayName = prettify(it.identifier)) } + val abilityDisplayNameById: Map = + abilityRows.associate { it.id to (abilityNameById[it.id] ?: prettify(it.identifier)) } + val abilities = abilityRows.map { + AbilityEntry(id = it.id, name = it.identifier, displayName = abilityDisplayNameById.getValue(it.id)) + } val pokemonRows = parsePokemon(pokemonCsv) val typesByPokemonId = parsePokemonTypes(pokemonTypesCsv).groupBy { it.pokemonId } - val abilitiesByPokemonId = parsePokemonAbilities(pokemonAbilitiesCsv).groupBy { it.pokemonId } + val pokemonAbilityRows = parsePokemonAbilities(pokemonAbilitiesCsv) + val abilitiesByPokemonId = pokemonAbilityRows.groupBy { it.pokemonId } val defaultFormIdBySpeciesId = pokemonRows.filter { it.isDefault }.associate { it.speciesId to it.id } fun lowestNonHiddenAbility(pokemonId: Int): String? { @@ -63,7 +94,7 @@ fun assembleDataset( .filter { !it.isHidden } .minByOrNull { it.slot } ?: return null - return abilityIdentifierById[row.abilityId] + return abilityDisplayNameById[row.abilityId] } // A form with no pokemon_abilities row at all (11 forms, all id >= 10301 as of the pinned @@ -76,6 +107,62 @@ fun assembleDataset( return lowestNonHiddenAbility(defaultFormId) } + // --- Base stat totals (phase-7-accuracy-and-customization.md §2.2) --- + + val currentStatsByPokemon: Map> = + parsePokemonStats(pokemonStatsCsv).groupBy { it.pokemonId } + .mapValues { (_, rows) -> rows.associate { it.statId to it.baseStat } } + + // (pokemonId, statId) -> the past rows for that stat, each "held through generationId". + val pastStatRows = parsePokemonStatsPast(pokemonStatsPastCsv) + val pastByPokemonStat: Map, List> = + pastStatRows.groupBy { it.pokemonId to it.statId } + + fun currentStat(pokemonId: Int, statId: Int): Int = currentStatsByPokemon[pokemonId]?.get(statId) ?: 0 + + fun statAt(pokemonId: Int, statId: Int, generation: Int): Int { + val applicable = pastByPokemonStat[pokemonId to statId].orEmpty().filter { it.generationId >= generation } + val chosen = applicable.minByOrNull { it.generationId } + return chosen?.baseStat ?: currentStat(pokemonId, statId) + } + + fun bstAt(pokemonId: Int, generation: Int): Int = + if (generation <= 1) { + // Gen I has no Special Attack/Special Defense split — the canonical total is the sum + // of FIVE stats (HP/Attack/Defense/Speed/Special), never six. A Gen-I total is on a + // different scale from every later generation's and must never be compared to one. + statAt(pokemonId, STAT_HP, generation) + + statAt(pokemonId, STAT_ATTACK, generation) + + statAt(pokemonId, STAT_DEFENSE, generation) + + statAt(pokemonId, STAT_SPEED, generation) + + statAt(pokemonId, STAT_SPECIAL_GEN1, generation) + } else { + CURRENT_GEN_STAT_IDS.sumOf { statId -> statAt(pokemonId, statId, generation) } + } + + fun currentBst(pokemonId: Int): Int = CURRENT_GEN_STAT_IDS.sumOf { statId -> currentStat(pokemonId, statId) } + + val pastBst: List = pastStatRows.map { it.pokemonId }.distinct().flatMap { pokemonId -> + val current = currentBst(pokemonId) + val generations = pastByPokemonStat.keys.filter { it.first == pokemonId } + .flatMap { key -> pastByPokemonStat.getValue(key).map { it.generationId } } + .distinct() + generations.mapNotNull { generation -> + val bst = bstAt(pokemonId, generation) + if (bst != current || generation == 1) PastBst(pokemonId, generation, bst) else null + } + } + + val pokemonAbilities: List = pokemonAbilityRows.map { row -> + SpeciesAbility( + pokemonId = row.pokemonId, + slug = abilityIdentifierById[row.abilityId] ?: "", + displayName = abilityDisplayNameById[row.abilityId] ?: "", + isHidden = row.isHidden, + slot = row.slot, + ) + } + val species = pokemonRows.mapNotNull { pokemon -> val speciesRow = speciesById[pokemon.speciesId] ?: return@mapNotNull null val slots = typesByPokemonId[pokemon.id].orEmpty().sortedBy { it.slot } @@ -95,6 +182,7 @@ fun assembleDataset( generationIntroduced = speciesRow.generationId, defaultAbility = resolveDefaultAbility(pokemon.id, pokemon.speciesId), isDefaultForm = pokemon.isDefault, + baseStatTotal = currentBst(pokemon.id), ) } @@ -109,7 +197,7 @@ fun assembleDataset( MoveEntry( id = move.id, name = move.identifier, - displayName = prettify(move.identifier), + displayName = moveNameById[move.id] ?: prettify(move.identifier), type = type, power = move.power, damageClass = damageClass, @@ -123,5 +211,12 @@ fun assembleDataset( table.getOrPut(attacker) { mutableMapOf() }[defender] = row.damageFactor / 100.0 } - return ParsedDataset(species = species, moves = moves, abilities = abilities, typeChart = TypeChart(table)) + return ParsedDataset( + species = species, + moves = moves, + abilities = abilities, + typeChart = TypeChart(table), + pastBst = pastBst, + pokemonAbilities = pokemonAbilities, + ) } diff --git a/app/src/main/java/com/marcogn/coverdex/domain/pokeapi/DatasetParsers.kt b/app/src/main/java/com/marcogn/coverdex/domain/pokeapi/DatasetParsers.kt index 25be155..15ffb25 100644 --- a/app/src/main/java/com/marcogn/coverdex/domain/pokeapi/DatasetParsers.kt +++ b/app/src/main/java/com/marcogn/coverdex/domain/pokeapi/DatasetParsers.kt @@ -99,3 +99,49 @@ fun parseTypeEfficacy(csv: String): List = damageFactor = row.getValue("damage_factor").toInt(), ) } + +/** `stat_id`: 1 hp, 2 attack, 3 defense, 4 special-attack, 5 special-defense, 6 speed, 9 the + * Gen-1-only combined "special" — hardcoded here exactly as `damage_class_id` and the type ids + * are hardcoded in `assembleDataset`, per docs/plan/phase-7-accuracy-and-customization.md §2.1. + * `stats.csv` itself is never downloaded for this. */ +data class PokemonStatCsvRow(val pokemonId: Int, val statId: Int, val baseStat: Int) + +fun parsePokemonStats(csv: String): List = + CsvParser.parse(csv).map { row -> + PokemonStatCsvRow( + pokemonId = row.getValue("pokemon_id").toInt(), + statId = row.getValue("stat_id").toInt(), + baseStat = row.getValue("base_stat").toInt(), + ) + } + +/** A row means "this stat held this value through generation [generationId] inclusive" — see + * phase-7-accuracy-and-customization.md §2.2 for the derivation this backs. */ +data class PokemonStatPastCsvRow(val pokemonId: Int, val generationId: Int, val statId: Int, val baseStat: Int) + +fun parsePokemonStatsPast(csv: String): List = + CsvParser.parse(csv).map { row -> + PokemonStatPastCsvRow( + pokemonId = row.getValue("pokemon_id").toInt(), + generationId = row.getValue("generation_id").toInt(), + statId = row.getValue("stat_id").toInt(), + baseStat = row.getValue("base_stat").toInt(), + ) + } + +/** One row per (ability/move id, language). Only `local_language_id == 9` (English) rows are + * kept — see phase-7-accuracy-and-customization.md §2.1; a genuinely localized name is a + * follow-up, not this phase's job. */ +data class LocalizedNameCsvRow(val id: Int, val name: String) + +private const val ENGLISH_LANGUAGE_ID = "9" + +fun parseAbilityNames(csv: String): List = + CsvParser.parse(csv) + .filter { it.getValue("local_language_id") == ENGLISH_LANGUAGE_ID } + .map { row -> LocalizedNameCsvRow(id = row.getValue("ability_id").toInt(), name = row.getValue("name")) } + +fun parseMoveNames(csv: String): List = + CsvParser.parse(csv) + .filter { it.getValue("local_language_id") == ENGLISH_LANGUAGE_ID } + .map { row -> LocalizedNameCsvRow(id = row.getValue("move_id").toInt(), name = row.getValue("name")) } diff --git a/app/src/main/java/com/marcogn/coverdex/domain/pokeapi/SyncStage.kt b/app/src/main/java/com/marcogn/coverdex/domain/pokeapi/SyncStage.kt index 4f1ca27..38f122b 100644 --- a/app/src/main/java/com/marcogn/coverdex/domain/pokeapi/SyncStage.kt +++ b/app/src/main/java/com/marcogn/coverdex/domain/pokeapi/SyncStage.kt @@ -4,8 +4,13 @@ package com.marcogn.coverdex.domain.pokeapi * Bumped whenever a cache table's shape changes. Compared against the stored * `poke_cache_meta.schemaVersion` row; a mismatch is treated as "absent" and silently re-synced — * never a crash on a stale row. See docs/plan/reference-pokedata.md §6. + * + * Bumped to 2 in Phase 7: `poke_species` gained `baseStatTotal` and two new cache tables + * (`poke_pokemon_ability`, `poke_species_bst_past`) were added. Without this bump every existing + * install would keep reporting its Room-v2-era cache as fresh and never download base stats or + * per-form abilities — see docs/plan/phase-7-accuracy-and-customization.md §8. */ -const val DATASET_SCHEMA_VERSION = 1 +const val DATASET_SCHEMA_VERSION = 2 /** * The coarse phase of a dataset sync run — unlike Hall of Memories' PokéAPI sync, this one has no diff --git a/app/src/main/java/com/marcogn/coverdex/domain/repository/PokedexRepository.kt b/app/src/main/java/com/marcogn/coverdex/domain/repository/PokedexRepository.kt index eb997e5..5a85288 100644 --- a/app/src/main/java/com/marcogn/coverdex/domain/repository/PokedexRepository.kt +++ b/app/src/main/java/com/marcogn/coverdex/domain/repository/PokedexRepository.kt @@ -3,7 +3,9 @@ package com.marcogn.coverdex.domain.repository import com.marcogn.coverdex.domain.model.AbilityEntry import com.marcogn.coverdex.domain.model.CacheStatus import com.marcogn.coverdex.domain.model.MoveEntry +import com.marcogn.coverdex.domain.model.PastBst import com.marcogn.coverdex.domain.model.PokemonEntry +import com.marcogn.coverdex.domain.model.SpeciesAbility import com.marcogn.coverdex.domain.model.SyncState import com.marcogn.coverdex.domain.model.TypeChart import kotlinx.coroutines.flow.Flow @@ -45,4 +47,18 @@ interface PokedexRepository { suspend fun allMoves(): List suspend fun typeChart(): TypeChart + + /** A species form's canonical abilities (normal slots plus hidden, if any), ordered hidden + * last — backs the ability picker's canonical list. Empty for a form with no + * `pokemon_abilities` rows (see `DatasetAssembly.kt`'s `resolveDefaultAbility` doc) or for a + * `pokemonId` not in the cache. Added in Phase 7, see + * docs/plan/phase-7-accuracy-and-customization.md §3.2. */ + suspend fun abilitiesForSpecies(pokemonId: Int): List + + /** Every historical base-stat-total override, a small table (a few hundred rows at most) — + * callers build a per-generation lookup from this, the same one-shot-list shape as + * [allMoves]. A form absent here never had a historical change; its BST is + * [PokemonEntry.baseStatTotal] at every generation. Added in Phase 7 for the suggestion + * ranking's BST tie-break, see docs/plan/phase-7-accuracy-and-customization.md §2.2/§5.2. */ + suspend fun allPastBst(): List } diff --git a/app/src/main/java/com/marcogn/coverdex/ui/common/AbilityPicker.kt b/app/src/main/java/com/marcogn/coverdex/ui/common/AbilityPicker.kt new file mode 100644 index 0000000..cf859e8 --- /dev/null +++ b/app/src/main/java/com/marcogn/coverdex/ui/common/AbilityPicker.kt @@ -0,0 +1,161 @@ +package com.marcogn.coverdex.ui.common + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.MenuAnchorType +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.marcogn.coverdex.R +import com.marcogn.coverdex.domain.ability.ABILITY_EFFECTS +import com.marcogn.coverdex.domain.ability.abilityKey +import com.marcogn.coverdex.domain.model.AbilityEntry +import com.marcogn.coverdex.domain.model.SpeciesAbility +import kotlinx.coroutines.flow.Flow + +/** + * The ability field's canonical-plus-custom picker — `docs/plan/phase-7-accuracy-and-customization.md` + * §3.2. A species picked from the cache ([pokedexId] non-null) starts in its canonical list + * (normal abilities, then hidden, then a final "Custom ability…" row); a hand-typed or roster + * Pokémon ([pokedexId] null) has no canonical list and goes straight to the free-text picker, + * matching [EditableComboBox]'s existing "suggest but never reject" contract — a ROM hack's + * ability that exists in no PokéAPI table must still be typeable. A canonical option that has a + * coverage effect ([ABILITY_EFFECTS]) is marked with a small dot, per the plan's "don't filter + * the non-affecting ones out, Moxie and Intimidate stay selectable". [resetKey] is the caller's + * draft identity (a fresh id on every new species pick, e.g. `SlotDraft.id`) — every bit of local + * state here is keyed on it, not on [pokedexId] alone, since two different hand-typed/roster + * drafts both have `pokedexId == null` and must never share remembered state. + */ +@Composable +fun AbilityPicker( + resetKey: Any, + pokedexId: Int?, + ability: String?, + onAbilityChange: (String?) -> Unit, + searchAbilities: (String) -> Flow>, + loadCanonicalAbilities: suspend (Int) -> List, + modifier: Modifier = Modifier, +) { + var canonical by remember(resetKey) { mutableStateOf>(emptyList()) } + LaunchedEffect(resetKey) { + canonical = pokedexId?.let { loadCanonicalAbilities(it) }.orEmpty() + } + // Starts on the canonical list whenever one exists for the current species; a hand-typed or + // roster Pokemon (no pokedexId, so canonical is always empty) goes straight to free text. + // Keyed on resetKey (the draft's own identity, fresh on every species pick), not pokedexId + // alone — two different custom/hand-typed drafts both have pokedexId == null and must not + // share remembered state. + var customMode by remember(resetKey) { mutableStateOf(false) } + + if (canonical.isNotEmpty() && !customMode) { + CanonicalAbilityDropdown( + abilities = canonical, + selected = ability, + onSelect = onAbilityChange, + onCustomRequested = { customMode = true }, + modifier = modifier, + ) + } else { + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) { + FreeTextAbilityField(resetKey = resetKey, ability = ability, onAbilityChange = onAbilityChange, searchAbilities = searchAbilities) + if (canonical.isNotEmpty()) { + TextButton(onClick = { customMode = false }) { + Text(stringResource(R.string.ability_picker_back_to_canonical)) + } + } + } + } +} + +private fun hasEffect(nameOrSlug: String): Boolean = abilityKey(nameOrSlug) in ABILITY_EFFECTS + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CanonicalAbilityDropdown( + abilities: List, + selected: String?, + onSelect: (String) -> Unit, + onCustomRequested: () -> Unit, + modifier: Modifier = Modifier, +) { + var expanded by remember { mutableStateOf(false) } + val hiddenSuffix = stringResource(R.string.ability_picker_hidden_suffix) + val customLabel = stringResource(R.string.ability_picker_custom_option) + val effectMarker = stringResource(R.string.ability_picker_effect_marker) + + fun labelFor(a: SpeciesAbility) = a.displayName + if (a.isHidden) hiddenSuffix else "" + + ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }, modifier = modifier) { + OutlinedTextField( + value = selected.orEmpty(), + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(R.string.slot_ability_label)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier + .menuAnchor(MenuAnchorType.PrimaryNotEditable, enabled = true) + .fillMaxWidth(), + ) + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + abilities.forEach { a -> + DropdownMenuItem( + text = { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Text(labelFor(a)) + if (hasEffect(a.slug)) Text(effectMarker) + } + }, + onClick = { + onSelect(a.displayName) + expanded = false + }, + ) + } + DropdownMenuItem( + text = { Text(customLabel) }, + onClick = { + expanded = false + onCustomRequested() + }, + ) + } + } +} + +@Composable +private fun FreeTextAbilityField( + resetKey: Any, + ability: String?, + onAbilityChange: (String?) -> Unit, + searchAbilities: (String) -> Flow>, +) { + var query by remember(resetKey) { mutableStateOf(ability.orEmpty()) } + val results by remember(query) { searchAbilities(query) }.collectAsState(initial = emptyList()) + + EditableComboBox( + value = query, + onValueChange = { value -> + query = value + onAbilityChange(value.ifBlank { null }) + }, + label = stringResource(R.string.slot_ability_label), + suggestions = results.map { it.displayName }, + modifier = Modifier.fillMaxWidth(), + ) +} diff --git a/app/src/main/java/com/marcogn/coverdex/ui/roster/RosterEditorScreen.kt b/app/src/main/java/com/marcogn/coverdex/ui/roster/RosterEditorScreen.kt index 2b83f5a..6b6292b 100644 --- a/app/src/main/java/com/marcogn/coverdex/ui/roster/RosterEditorScreen.kt +++ b/app/src/main/java/com/marcogn/coverdex/ui/roster/RosterEditorScreen.kt @@ -36,7 +36,7 @@ import com.marcogn.coverdex.R import com.marcogn.coverdex.domain.model.PokemonMove import com.marcogn.coverdex.domain.model.PokemonType import com.marcogn.coverdex.domain.model.TeamMember -import com.marcogn.coverdex.ui.common.EditableComboBox +import com.marcogn.coverdex.ui.common.AbilityPicker import com.marcogn.coverdex.ui.common.TypeDropdown import com.marcogn.coverdex.ui.team.MoveSlotEditor import java.util.UUID @@ -99,8 +99,6 @@ fun RosterEditorScreen( val existingMember by viewModel.existingMember.collectAsState() val showMoves by viewModel.showMoves.collectAsState() var draft by remember(existingMember) { mutableStateOf(existingMember?.let { RosterDraft.from(it) } ?: RosterDraft.blank()) } - var abilityQuery by remember(draft.id) { mutableStateOf(draft.ability.orEmpty()) } - val abilityResults by remember(abilityQuery) { viewModel.searchAbilities(abilityQuery) }.collectAsState(initial = emptyList()) BackHandler(onBack = onBackClick) @@ -163,14 +161,13 @@ fun RosterEditorScreen( ) } - EditableComboBox( - value = abilityQuery, - onValueChange = { value -> - abilityQuery = value - draft = draft.copy(ability = value.ifBlank { null }) - }, - label = stringResource(R.string.slot_ability_label), - suggestions = abilityResults.map { it.displayName }, + AbilityPicker( + resetKey = draft.id, + pokedexId = null, + ability = draft.ability, + onAbilityChange = { draft = draft.copy(ability = it) }, + searchAbilities = viewModel::searchAbilities, + loadCanonicalAbilities = { emptyList() }, modifier = Modifier.fillMaxWidth(), ) diff --git a/app/src/main/java/com/marcogn/coverdex/ui/team/SlotEditorScreen.kt b/app/src/main/java/com/marcogn/coverdex/ui/team/SlotEditorScreen.kt index 47b3457..4149e4b 100644 --- a/app/src/main/java/com/marcogn/coverdex/ui/team/SlotEditorScreen.kt +++ b/app/src/main/java/com/marcogn/coverdex/ui/team/SlotEditorScreen.kt @@ -40,8 +40,8 @@ import com.marcogn.coverdex.domain.model.PokemonMove import com.marcogn.coverdex.domain.model.PokemonType import com.marcogn.coverdex.domain.model.TeamMember import com.marcogn.coverdex.domain.sprite.SpriteContext +import com.marcogn.coverdex.ui.common.AbilityPicker import com.marcogn.coverdex.ui.common.DropdownOption -import com.marcogn.coverdex.ui.common.EditableComboBox import com.marcogn.coverdex.ui.common.PokemonSprite import com.marcogn.coverdex.ui.common.SearchableDropdown import com.marcogn.coverdex.ui.common.TypeBadge @@ -197,16 +197,13 @@ fun SlotEditorScreen( ) } - var abilityQuery by remember(currentDraft.id) { mutableStateOf(currentDraft.ability.orEmpty()) } - val abilityResults by remember(abilityQuery) { viewModel.searchAbilities(abilityQuery) }.collectAsState(initial = emptyList()) - EditableComboBox( - value = abilityQuery, - onValueChange = { value -> - abilityQuery = value - draft = currentDraft.copy(ability = value.ifBlank { null }) - }, - label = stringResource(R.string.slot_ability_label), - suggestions = abilityResults.map { it.displayName }, + AbilityPicker( + resetKey = currentDraft.id, + pokedexId = currentDraft.pokedexId, + ability = currentDraft.ability, + onAbilityChange = { draft = currentDraft.copy(ability = it) }, + searchAbilities = viewModel::searchAbilities, + loadCanonicalAbilities = viewModel::canonicalAbilities, modifier = Modifier.fillMaxWidth(), ) diff --git a/app/src/main/java/com/marcogn/coverdex/ui/team/SlotEditorViewModel.kt b/app/src/main/java/com/marcogn/coverdex/ui/team/SlotEditorViewModel.kt index 49147a8..96db4ef 100644 --- a/app/src/main/java/com/marcogn/coverdex/ui/team/SlotEditorViewModel.kt +++ b/app/src/main/java/com/marcogn/coverdex/ui/team/SlotEditorViewModel.kt @@ -8,6 +8,7 @@ import com.marcogn.coverdex.data.settings.SettingsPreferences import com.marcogn.coverdex.domain.model.AbilityEntry import com.marcogn.coverdex.domain.model.MoveEntry import com.marcogn.coverdex.domain.model.PokemonEntry +import com.marcogn.coverdex.domain.model.SpeciesAbility import com.marcogn.coverdex.domain.model.TeamMember import com.marcogn.coverdex.domain.repository.CustomPokemonRepository import com.marcogn.coverdex.domain.repository.PokedexRepository @@ -54,6 +55,10 @@ class SlotEditorViewModel @Inject constructor( fun searchAbilities(query: String): Flow> = pokedexRepository.searchAbilities(query) + /** The picked species' canonical abilities, for [com.marcogn.coverdex.ui.common.AbilityPicker] — + * see docs/plan/phase-7-accuracy-and-customization.md §3.2. */ + suspend fun canonicalAbilities(pokemonId: Int): List = pokedexRepository.abilitiesForSpecies(pokemonId) + fun searchMoves(query: String): Flow> = pokedexRepository.searchMoves(query) fun save(member: TeamMember, onSaved: () -> Unit) { diff --git a/app/src/main/java/com/marcogn/coverdex/ui/team/analysis/PerPokemonCard.kt b/app/src/main/java/com/marcogn/coverdex/ui/team/analysis/PerPokemonCard.kt index d606645..47f4c16 100644 --- a/app/src/main/java/com/marcogn/coverdex/ui/team/analysis/PerPokemonCard.kt +++ b/app/src/main/java/com/marcogn/coverdex/ui/team/analysis/PerPokemonCard.kt @@ -32,7 +32,6 @@ import androidx.compose.ui.unit.dp import com.marcogn.coverdex.R import com.marcogn.coverdex.domain.ability.AbilityEffect import com.marcogn.coverdex.domain.ability.getAbilityEffects -import com.marcogn.coverdex.domain.ability.normalizeAbilityName import com.marcogn.coverdex.domain.coverage.attackingTypesForMember import com.marcogn.coverdex.domain.coverage.defensiveMultiplier import com.marcogn.coverdex.domain.coverage.memberHasMoves @@ -99,16 +98,20 @@ fun PerPokemonCard(member: TeamMember, chart: TypeChart, modifier: Modifier = Mo // (stringResource-backed) — resolved here, in the composable context, rather // than inside the plain joinToString lambda below, which cannot call them. val immuneToLabel = stringResource(R.string.analysis_immune_to) + val superEffectiveHitsLabel = stringResource(R.string.analysis_super_effective_hits) + val neverSuperEffectiveLabel = stringResource(R.string.analysis_never_super_effective) val typeNames = PokemonType.entries.associateWith { it.displayName() } val effects = getAbilityEffects(member.ability) - val isWonderGuard = normalizeAbilityName(member.ability) == "wonder-guard" + val isWonderGuard = effects?.any { it is AbilityEffect.OnlySuperEffective } == true val summary = effects - ?.filter { it !is AbilityEffect.BadgeOnly } + ?.filter { it !is AbilityEffect.BadgeOnly && it !is AbilityEffect.OnlySuperEffective } ?.joinToString(", ") { effect -> when (effect) { is AbilityEffect.Immunity -> "$immuneToLabel ${typeNames.getValue(effect.type)}" is AbilityEffect.Multiplier -> "×${effect.factor} ${typeNames.getValue(effect.type)}" - is AbilityEffect.BadgeOnly -> "" + is AbilityEffect.SuperEffectiveMultiplier -> "×${effect.factor} $superEffectiveHitsLabel" + AbilityEffect.NeverSuperEffective -> neverSuperEffectiveLabel + is AbilityEffect.BadgeOnly, AbilityEffect.OnlySuperEffective -> "" } } DefRow(stringResource(R.string.slot_ability_label)) { diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 315eb2c..4b0e312 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -80,6 +80,8 @@ Team best immune to Only super-effective moves deal damage + super-effective hits + never super-effective against this Pokémon Pokémon @@ -145,6 +147,10 @@ Type 2 None Ability + (hidden) + Custom ability… + Back to canonical abilities + Moves Pick or type a move… Custom move name diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 94b7e01..8f307e4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -80,6 +80,8 @@ Miglior team immune a Solo le mosse super-efficaci infliggono danni + i colpi super-efficaci + mai super-efficace contro questo Pokémon Pokémon @@ -145,6 +147,10 @@ Tipo 2 Nessuno Abilità + (nascosta) + Abilità custom… + Torna alle abilità canoniche + Mosse Scegli o digita una mossa… Nome mossa personalizzata diff --git a/app/src/test/java/com/marcogn/coverdex/data/local/Migration2To3Test.kt b/app/src/test/java/com/marcogn/coverdex/data/local/Migration2To3Test.kt new file mode 100644 index 0000000..7e57ade --- /dev/null +++ b/app/src/test/java/com/marcogn/coverdex/data/local/Migration2To3Test.kt @@ -0,0 +1,85 @@ +package com.marcogn.coverdex.data.local + +import androidx.room.testing.MigrationTestHelper +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import androidx.test.platform.app.InstrumentationRegistry +import com.marcogn.coverdex.data.local.migration.MIGRATION_2_3 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +// sdk = 26: see CLAUDE.md, "Known gotchas". +@Config(sdk = [26]) +@RunWith(RobolectricTestRunner::class) +class Migration2To3Test { + + @get:Rule + val helper: MigrationTestHelper = MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + CoverDexDatabase::class.java, + emptyList(), + FrameworkSQLiteOpenHelperFactory(), + ) + + @Test + fun `a v2 database opens at v3 with existing rows intact, new columns null-or-default, new tables present and empty`() { + val dbName = "migration-2-3-test" + helper.createDatabase(dbName, 2).apply { + execSQL( + "INSERT INTO poke_cache_meta (id, schemaVersion, datasetRevision, syncedAtEpochMillis, speciesCount, moveCount) " + + "VALUES (1, 1, 'abc123', 1000, 5, 5)", + ) + execSQL( + "INSERT INTO poke_species (id, name, displayName, searchName, speciesId, speciesName, type1, type2, " + + "isLegendary, isMythical, isFinalEvolution, generationIntroduced, defaultAbility, isDefaultForm) " + + "VALUES (1, 'bulbasaur', 'Bulbasaur', 'bulbasaur', 1, 'bulbasaur', 'grass', 'poison', 0, 0, 0, 1, 'overgrow', 1)", + ) + execSQL( + "INSERT INTO team (id, name, createdAtEpochMillis, position) VALUES ('t1', 'My Team', 1000, 0)", + ) + execSQL( + "INSERT INTO team_member (id, teamId, slotIndex, pokedexId, speciesName, type1, type2, ability, isCustomSaved) " + + "VALUES ('m1', 't1', 0, 1, 'Bulbasaur', 'grass', 'poison', 'overgrow', 0)", + ) + execSQL( + "INSERT INTO custom_pokemon (id, name, type1, type2, ability, createdAtEpochMillis) " + + "VALUES ('c1', 'Custom Mon', 'fire', NULL, NULL, 2000)", + ) + close() + } + + val migrated = helper.runMigrationsAndValidate(dbName, 3, true, MIGRATION_2_3) + + // Existing rows survive, with the new poke_species column defaulting to 0. + migrated.query("SELECT baseStatTotal FROM poke_species WHERE id = 1").use { cursor -> + cursor.moveToFirst() + assertEquals(0, cursor.getInt(0)) + } + migrated.query("SELECT name FROM poke_species WHERE id = 1").use { cursor -> + cursor.moveToFirst() + assertEquals("bulbasaur", cursor.getString(0)) + } + + // The new team_member/custom_pokemon `item` column is null for pre-existing rows. + migrated.query("SELECT item FROM team_member WHERE id = 'm1'").use { cursor -> + cursor.moveToFirst() + assertNull(cursor.getString(0)) + } + migrated.query("SELECT item FROM custom_pokemon WHERE id = 'c1'").use { cursor -> + cursor.moveToFirst() + assertNull(cursor.getString(0)) + } + + // The two new cache tables exist and are queryable (empty, but present). + for (table in listOf("poke_pokemon_ability", "poke_species_bst_past")) { + migrated.query("SELECT COUNT(*) FROM $table").use { cursor -> + cursor.moveToFirst() + assertEquals("table $table should be empty but present", 0, cursor.getInt(0)) + } + } + } +} diff --git a/app/src/test/java/com/marcogn/coverdex/data/local/PokedexDaoTest.kt b/app/src/test/java/com/marcogn/coverdex/data/local/PokedexDaoTest.kt index 3d8900c..7e79e80 100644 --- a/app/src/test/java/com/marcogn/coverdex/data/local/PokedexDaoTest.kt +++ b/app/src/test/java/com/marcogn/coverdex/data/local/PokedexDaoTest.kt @@ -6,6 +6,8 @@ import com.marcogn.coverdex.data.local.dao.PokedexDao import com.marcogn.coverdex.data.local.entity.PokeAbilityEntity import com.marcogn.coverdex.data.local.entity.PokeCacheMetaEntity import com.marcogn.coverdex.data.local.entity.PokeMoveEntity +import com.marcogn.coverdex.data.local.entity.PokePokemonAbilityEntity +import com.marcogn.coverdex.data.local.entity.PokeSpeciesBstPastEntity import com.marcogn.coverdex.data.local.entity.PokeSpeciesEntity import com.marcogn.coverdex.data.local.entity.TypeEfficacyEntity import kotlinx.coroutines.flow.first @@ -184,6 +186,8 @@ class PokedexDaoTest { moves = listOf(PokeMoveEntity(1, "pound", "Pound", "pound", "normal", 40, "PHYSICAL")), abilities = listOf(PokeAbilityEntity(65, "overgrow", "Overgrow", "overgrow")), typeEfficacy = listOf(TypeEfficacyEntity("fire", "grass", 2.0)), + pokemonAbilities = emptyList(), + bstPast = emptyList(), meta = PokeCacheMetaEntity(schemaVersion = 1, datasetRevision = "abc123", syncedAtEpochMillis = 1000L, speciesCount = 1, moveCount = 1), ) @@ -198,6 +202,8 @@ class PokedexDaoTest { moves = listOf(PokeMoveEntity(1, "pound", "Pound", "pound", "normal", 40, "PHYSICAL")), abilities = listOf(PokeAbilityEntity(65, "overgrow", "Overgrow", "overgrow")), typeEfficacy = listOf(TypeEfficacyEntity("fire", "grass", 2.0)), + pokemonAbilities = emptyList(), + bstPast = emptyList(), meta = PokeCacheMetaEntity(schemaVersion = 1, datasetRevision = "abc123", syncedAtEpochMillis = 1000L, speciesCount = 1, moveCount = 1), ) @@ -209,4 +215,42 @@ class PokedexDaoTest { assertTrue(dao.getAllTypeEfficacy().isEmpty()) assertNull(dao.getMeta()) } + + @Test + fun `getAbilitiesForSpecies orders hidden last, then by slot`() = runTest { + dao.replaceAllPokemonAbilities( + listOf( + PokePokemonAbilityEntity(pokemonId = 1, slot = 3, abilitySlug = "chlorophyll", displayName = "Chlorophyll", isHidden = true), + PokePokemonAbilityEntity(pokemonId = 1, slot = 1, abilitySlug = "overgrow", displayName = "Overgrow", isHidden = false), + ), + ) + + val result = dao.getAbilitiesForSpecies(1) + + assertEquals(listOf("overgrow", "chlorophyll"), result.map { it.abilitySlug }) + } + + @Test + fun `getAbilitiesForSpecies is empty for a species with no rows`() = runTest { + assertTrue(dao.getAbilitiesForSpecies(999).isEmpty()) + } + + @Test + fun `bst past rows round-trip and clearCache wipes them too`() = runTest { + dao.replaceCache( + species = listOf(species(1, "bulbasaur")), + moves = emptyList(), + abilities = emptyList(), + typeEfficacy = emptyList(), + pokemonAbilities = emptyList(), + bstPast = listOf(PokeSpeciesBstPastEntity(pokemonId = 144, generationId = 1, bst = 460)), + meta = PokeCacheMetaEntity(schemaVersion = 1, datasetRevision = "abc123", syncedAtEpochMillis = 1000L, speciesCount = 1, moveCount = 0), + ) + + assertEquals(460, dao.getAllBstPast().first { it.pokemonId == 144 }.bst) + + dao.clearCache() + + assertTrue(dao.getAllBstPast().isEmpty()) + } } diff --git a/app/src/test/java/com/marcogn/coverdex/data/pokeapi/DatasetSyncManagerTest.kt b/app/src/test/java/com/marcogn/coverdex/data/pokeapi/DatasetSyncManagerTest.kt index a0ca332..e176eea 100644 --- a/app/src/test/java/com/marcogn/coverdex/data/pokeapi/DatasetSyncManagerTest.kt +++ b/app/src/test/java/com/marcogn/coverdex/data/pokeapi/DatasetSyncManagerTest.kt @@ -38,6 +38,10 @@ private class FakeDatasetSource(private val shouldFail: Boolean = false) : Datas DatasetFile.MOVES to fixture("moves.csv"), DatasetFile.TYPES to fixture("types.csv"), DatasetFile.TYPE_EFFICACY to fixture("type_efficacy.csv"), + DatasetFile.POKEMON_STATS to fixture("pokemon_stats.csv"), + DatasetFile.POKEMON_STATS_PAST to fixture("pokemon_stats_past.csv"), + DatasetFile.ABILITY_NAMES to fixture("ability_names.csv"), + DatasetFile.MOVE_NAMES to fixture("move_names.csv"), ) } } diff --git a/app/src/test/java/com/marcogn/coverdex/data/repository/MappersTest.kt b/app/src/test/java/com/marcogn/coverdex/data/repository/MappersTest.kt index e20b0bb..8427d9d 100644 --- a/app/src/test/java/com/marcogn/coverdex/data/repository/MappersTest.kt +++ b/app/src/test/java/com/marcogn/coverdex/data/repository/MappersTest.kt @@ -2,13 +2,17 @@ package com.marcogn.coverdex.data.repository import com.marcogn.coverdex.data.local.entity.PokeAbilityEntity import com.marcogn.coverdex.data.local.entity.PokeMoveEntity +import com.marcogn.coverdex.data.local.entity.PokePokemonAbilityEntity +import com.marcogn.coverdex.data.local.entity.PokeSpeciesBstPastEntity import com.marcogn.coverdex.data.local.entity.PokeSpeciesEntity import com.marcogn.coverdex.data.local.entity.TypeEfficacyEntity import com.marcogn.coverdex.domain.model.AbilityEntry import com.marcogn.coverdex.domain.model.DamageClass import com.marcogn.coverdex.domain.model.MoveEntry +import com.marcogn.coverdex.domain.model.PastBst import com.marcogn.coverdex.domain.model.PokemonEntry import com.marcogn.coverdex.domain.model.PokemonType +import com.marcogn.coverdex.domain.model.SpeciesAbility import com.marcogn.coverdex.domain.model.TypeChart import org.junit.Assert.assertEquals import org.junit.Assert.assertNull @@ -38,6 +42,21 @@ class MappersTest { assertEquals(entry, roundTripped) } + @Test + fun `baseStatTotal round-trips through the entity`() { + val entry = PokemonEntry( + id = 1, name = "bulbasaur", displayName = "Bulbasaur", speciesId = 1, speciesName = "bulbasaur", + types = PokemonType.GRASS to PokemonType.POISON, isLegendary = false, isMythical = false, + isFinalEvolution = false, generationIntroduced = 1, defaultAbility = "overgrow", isDefaultForm = true, + baseStatTotal = 318, + ) + + val entity = entry.toEntity() + + assertEquals(318, entity.baseStatTotal) + assertEquals(entry, entity.toDomain()) + } + @Test fun `a single-typed entry's entity has a null type2, not an empty string`() { val entry = PokemonEntry( @@ -118,4 +137,24 @@ class MappersTest { val ability = PokeAbilityEntity(id = 1, name = "x", displayName = "X", searchName = "x") assertEquals(AbilityEntry(1, "x", "X"), ability.toDomain()) } + + @Test + fun `SpeciesAbility round-trips through its entity`() { + val ability = SpeciesAbility(pokemonId = 1, slug = "overgrow", displayName = "Overgrow", isHidden = false, slot = 1) + + val entity = ability.toEntity() + + assertEquals(PokePokemonAbilityEntity(pokemonId = 1, slot = 1, abilitySlug = "overgrow", displayName = "Overgrow", isHidden = false), entity) + assertEquals(ability, entity.toDomain()) + } + + @Test + fun `PastBst round-trips through its entity`() { + val past = PastBst(pokemonId = 144, generationId = 1, bst = 460) + + val entity = past.toEntity() + + assertEquals(PokeSpeciesBstPastEntity(pokemonId = 144, generationId = 1, bst = 460), entity) + assertEquals(past, entity.toDomain()) + } } diff --git a/app/src/test/java/com/marcogn/coverdex/data/repository/TeamRepositoryTest.kt b/app/src/test/java/com/marcogn/coverdex/data/repository/TeamRepositoryTest.kt index 16a1494..7d7f184 100644 --- a/app/src/test/java/com/marcogn/coverdex/data/repository/TeamRepositoryTest.kt +++ b/app/src/test/java/com/marcogn/coverdex/data/repository/TeamRepositoryTest.kt @@ -100,6 +100,8 @@ class TeamRepositoryTest { moves = emptyList(), abilities = emptyList(), typeEfficacy = emptyList(), + pokemonAbilities = emptyList(), + bstPast = emptyList(), meta = PokeCacheMetaEntity(schemaVersion = 1, datasetRevision = "abc123", syncedAtEpochMillis = 1000L, speciesCount = 1, moveCount = 0), ) val teamId = repository.createTeam("My Team") diff --git a/app/src/test/java/com/marcogn/coverdex/domain/ability/AbilityEffectsTest.kt b/app/src/test/java/com/marcogn/coverdex/domain/ability/AbilityEffectsTest.kt index 7d58983..94b387d 100644 --- a/app/src/test/java/com/marcogn/coverdex/domain/ability/AbilityEffectsTest.kt +++ b/app/src/test/java/com/marcogn/coverdex/domain/ability/AbilityEffectsTest.kt @@ -2,6 +2,7 @@ package com.marcogn.coverdex.domain.ability import com.marcogn.coverdex.domain.model.PokemonType import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -9,9 +10,11 @@ import org.junit.Test class AbilityEffectsTest { @Test - fun `normalizeAbilityName lowercases and hyphenates spaces`() { - assertEquals("flash-fire", normalizeAbilityName("Flash Fire")) - assertEquals("wonder-guard", normalizeAbilityName("wonder guard")) + fun `abilityKey lowercases and strips every symbol, not just spaces`() { + assertEquals("flashfire", abilityKey("Flash Fire")) + assertEquals("wonderguard", abilityKey("wonder guard")) + assertEquals("wellbakedbody", abilityKey("Well-Baked Body")) + assertEquals("wellbakedbody", abilityKey("well-baked-body")) } @Test @@ -26,6 +29,15 @@ class AbilityEffectsTest { assertEquals(getAbilityEffects("flash-fire"), getAbilityEffects("Flash Fire")) } + @Test + fun `getAbilityEffects resolves a display name with a hyphen the slug does not have`() { + // Phase 7's own regression: prettify()/ability_names.csv can carry a hyphen an + // identifier doesn't (or vice versa) — abilityKey() strips symbols on both sides so + // either spelling resolves. well-baked-body has one hyphen in the slug and one in the + // display name ("Well-Baked Body"); this asserts the *display* name resolves too. + assertEquals(getAbilityEffects("well-baked-body"), getAbilityEffects("Well-Baked Body")) + } + @Test fun `every ABILITY_EFFECTS entry round-trips through getAbilityEffects`() { for ((slug, effects) in ABILITY_EFFECTS) { @@ -34,26 +46,25 @@ class AbilityEffectsTest { } @Test - fun `ABILITY_EFFECTS key set matches legacy-web's exactly`() { - // Ported from abilityEffects.ts: an entry added or removed there must be mirrored here — - // this assertion fails loudly on a silent drop, per phase-3-analysis.md §1. + fun `ABILITY_EFFECTS covers every defensive ability the Phase 7 audit found missing`() { + // docs/plan/phase-7-accuracy-and-customization.md §0.4: heatproof through tera-shell were + // entirely unmodelled pre-Phase-7; dry-skin only had its Water immunity, missing the Fire + // 1.25x; wonder-guard was BadgeOnly instead of a real effect. val expectedKeys = setOf( "volt-absorb", "lightning-rod", "motor-drive", "water-absorb", "storm-drain", "dry-skin", "flash-fire", "sap-sipper", "levitate", "earth-eater", "well-baked-body", "thick-fat", "fluffy", "wonder-guard", + "heatproof", "water-bubble", "purifying-salt", "filter", "solid-rock", + "prism-armor", "primordial-sea", "desolate-land", "delta-stream", "tera-shell", ) assertEquals(expectedKeys, ABILITY_EFFECTS.keys) - assertEquals(14, ABILITY_EFFECTS.size) + assertEquals(24, ABILITY_EFFECTS.size) } @Test - fun `KNOWN_ABILITIES_WITH_EFFECTS matches legacy-web's display list exactly`() { - val expected = listOf( - "volt absorb", "lightning rod", "motor drive", "water absorb", "storm drain", - "dry skin", "flash fire", "sap sipper", "levitate", "earth eater", - "well-baked body", "thick fat", "fluffy", "wonder guard", - ) - assertEquals(expected, KNOWN_ABILITIES_WITH_EFFECTS) + fun `KNOWN_ABILITIES_WITH_EFFECTS is ABILITY_EFFECTS' keys in display format, never hand-drifted`() { + assertEquals(ABILITY_EFFECTS.keys.map { it.replace('-', ' ') }.toSet(), KNOWN_ABILITIES_WITH_EFFECTS.toSet()) + assertEquals(ABILITY_EFFECTS.size, KNOWN_ABILITIES_WITH_EFFECTS.size) } @Test @@ -63,6 +74,20 @@ class AbilityEffectsTest { assertEquals(listOf(AbilityEffect.Immunity(PokemonType.FIRE)), getAbilityEffects("flash-fire")) assertEquals(listOf(AbilityEffect.Immunity(PokemonType.GRASS)), getAbilityEffects("sap-sipper")) assertEquals(listOf(AbilityEffect.Immunity(PokemonType.GROUND)), getAbilityEffects("levitate")) + assertEquals(listOf(AbilityEffect.Immunity(PokemonType.FIRE)), getAbilityEffects("primordial-sea")) + assertEquals(listOf(AbilityEffect.Immunity(PokemonType.WATER)), getAbilityEffects("desolate-land")) + } + + @Test + fun `dry-skin absorbs Water and takes extra Fire damage, both halves`() { + val effects = getAbilityEffects("dry-skin") + assertEquals( + listOf( + AbilityEffect.Immunity(PokemonType.WATER), + AbilityEffect.Multiplier(PokemonType.FIRE, 1.25, AbilityEffectSide.DEFENSIVE), + ), + effects, + ) } @Test @@ -78,8 +103,94 @@ class AbilityEffectsTest { } @Test - fun `wonder-guard is badge-only`() { - val effects = getAbilityEffects("wonder-guard") + fun `wonder-guard is a real effect, not badge-only`() { + assertEquals(listOf(AbilityEffect.OnlySuperEffective), getAbilityEffects("wonder-guard")) + } + + @Test + fun `tera-shell stays badge-only, this engine has no HP concept to gate it on`() { + val effects = getAbilityEffects("tera-shell") assertTrue(effects?.single() is AbilityEffect.BadgeOnly) } + + // --- applyAbilityEffects --- + + @Test + fun `applyAbilityEffects is a no-op for a null or empty effect list`() { + assertEquals(2.0, applyAbilityEffects(2.0, PokemonType.FIRE, null), 0.0) + assertEquals(2.0, applyAbilityEffects(2.0, PokemonType.FIRE, emptyList()), 0.0) + } + + @Test + fun `applyAbilityEffects immunity zeroes out regardless of the base multiplier`() { + val effects = listOf(AbilityEffect.Immunity(PokemonType.ELECTRIC)) + assertEquals(0.0, applyAbilityEffects(4.0, PokemonType.ELECTRIC, effects), 0.0) + assertEquals(0.0, applyAbilityEffects(0.0, PokemonType.ELECTRIC, effects), 0.0) + } + + @Test + fun `applyAbilityEffects wonder-guard blocks a non-super-effective hit but lets a super-effective one through`() { + val effects = listOf(AbilityEffect.OnlySuperEffective) + assertEquals(0.0, applyAbilityEffects(1.0, PokemonType.FIRE, effects), 0.0) + assertEquals(0.0, applyAbilityEffects(0.5, PokemonType.FIRE, effects), 0.0) + assertEquals(2.0, applyAbilityEffects(2.0, PokemonType.DARK, effects), 0.0) + } + + @Test + fun `applyAbilityEffects filter-solid-rock-prism-armor only reduce an already super-effective hit`() { + val effects = listOf(AbilityEffect.SuperEffectiveMultiplier(0.75)) + assertEquals(1.5, applyAbilityEffects(2.0, PokemonType.FIRE, effects), 0.0) + assertEquals(3.0, applyAbilityEffects(4.0, PokemonType.FIRE, effects), 0.0) + // Never-effective and neutral hits are untouched. + assertEquals(1.0, applyAbilityEffects(1.0, PokemonType.FIRE, effects), 0.0) + assertEquals(0.5, applyAbilityEffects(0.5, PokemonType.FIRE, effects), 0.0) + } + + @Test + fun `applyAbilityEffects delta-stream caps a super-effective hit at neutral`() { + val effects = listOf(AbilityEffect.NeverSuperEffective) + assertEquals(1.0, applyAbilityEffects(2.0, PokemonType.ELECTRIC, effects), 0.0) + assertEquals(1.0, applyAbilityEffects(4.0, PokemonType.ELECTRIC, effects), 0.0) + // A resisted or neutral hit is untouched — the cap only ever lowers, never raises. + assertEquals(0.5, applyAbilityEffects(0.5, PokemonType.ELECTRIC, effects), 0.0) + } + + @Test + fun `applyAbilityEffects badge-only never changes the multiplier`() { + val effects = listOf(AbilityEffect.BadgeOnly("note")) + assertEquals(2.0, applyAbilityEffects(2.0, PokemonType.FIRE, effects), 0.0) + } + + // --- overriddenMoveType / bypassesGhostImmunity (offensive gap, §7.2) --- + + @Test + fun `overriddenMoveType rewrites only a Normal-type move, for the -ate abilities`() { + assertEquals(PokemonType.ICE, overriddenMoveType("refrigerate", PokemonType.NORMAL)) + assertEquals(PokemonType.FAIRY, overriddenMoveType("pixilate", PokemonType.NORMAL)) + assertEquals(PokemonType.FLYING, overriddenMoveType("aerilate", PokemonType.NORMAL)) + assertEquals(PokemonType.ELECTRIC, overriddenMoveType("galvanize", PokemonType.NORMAL)) + // A non-Normal move is untouched by any of them. + assertEquals(PokemonType.WATER, overriddenMoveType("refrigerate", PokemonType.WATER)) + } + + @Test + fun `overriddenMoveType normalize rewrites every move, not just Normal ones`() { + assertEquals(PokemonType.NORMAL, overriddenMoveType("normalize", PokemonType.WATER)) + assertEquals(PokemonType.NORMAL, overriddenMoveType("normalize", PokemonType.NORMAL)) + } + + @Test + fun `overriddenMoveType is a no-op for null, empty or unrelated abilities`() { + assertEquals(PokemonType.WATER, overriddenMoveType(null, PokemonType.WATER)) + assertEquals(PokemonType.WATER, overriddenMoveType("", PokemonType.WATER)) + assertEquals(PokemonType.NORMAL, overriddenMoveType("intimidate", PokemonType.NORMAL)) + } + + @Test + fun `bypassesGhostImmunity is true only for scrappy and mind's eye`() { + assertTrue(bypassesGhostImmunity("scrappy")) + assertTrue(bypassesGhostImmunity("Mind's Eye")) + assertFalse(bypassesGhostImmunity("intimidate")) + assertFalse(bypassesGhostImmunity(null)) + } } diff --git a/app/src/test/java/com/marcogn/coverdex/domain/coverage/CoverageEngineTest.kt b/app/src/test/java/com/marcogn/coverdex/domain/coverage/CoverageEngineTest.kt index dbb5874..43f1d18 100644 --- a/app/src/test/java/com/marcogn/coverdex/domain/coverage/CoverageEngineTest.kt +++ b/app/src/test/java/com/marcogn/coverdex/domain/coverage/CoverageEngineTest.kt @@ -295,17 +295,121 @@ class CoverageEngineTest { } @Test - fun `wonder-guard does not alter any multiplier (badge-only)`() { - assertEquals( - defensiveMultiplier(chart, PokemonType.FIRE, PokemonType.GHOST to null), - defensiveMultiplier(chart, PokemonType.FIRE, PokemonType.GHOST to null, "wonder-guard"), - 0.0, - ) - assertEquals( - defensiveMultiplier(chart, PokemonType.DARK, PokemonType.GHOST to null), - defensiveMultiplier(chart, PokemonType.DARK, PokemonType.GHOST to null, "wonder-guard"), - 0.0, - ) + fun `wonder-guard blocks a non-super-effective hit but lets a super-effective one through unchanged`() { + // Fire vs Ghost is neutral (1x) in the fixture — Wonder Guard blocks it entirely. + assertEquals(0.0, defensiveMultiplier(chart, PokemonType.FIRE, PokemonType.GHOST to null, "wonder-guard"), 0.0) + // Dark vs Ghost is 2x (super-effective) — passes through unchanged, real damage happens. + assertEquals(2.0, defensiveMultiplier(chart, PokemonType.DARK, PokemonType.GHOST to null, "wonder-guard"), 0.0) + } + + @Test + fun `heatproof and water-bubble both halve Fire damage`() { + assertEquals(0.5, defensiveMultiplier(chart, PokemonType.FIRE, PokemonType.NORMAL to null, "heatproof"), 0.0) + assertEquals(0.5, defensiveMultiplier(chart, PokemonType.FIRE, PokemonType.NORMAL to null, "water-bubble"), 0.0) + } + + @Test + fun `purifying-salt halves Ghost damage`() { + // Ghost vs Fire is neutral (1x, not immune) in the fixture. + assertEquals(0.5, defensiveMultiplier(chart, PokemonType.GHOST, PokemonType.FIRE to null, "purifying-salt"), 0.0) + } + + @Test + fun `dry-skin absorbs Water and takes extra Fire damage`() { + assertEquals(0.0, defensiveMultiplier(chart, PokemonType.WATER, PokemonType.NORMAL to null, "dry-skin"), 0.0) + assertEquals(1.25, defensiveMultiplier(chart, PokemonType.FIRE, PokemonType.NORMAL to null, "dry-skin"), 0.0) + } + + @Test + fun `filter, solid-rock and prism-armor each reduce an already super-effective hit by a quarter`() { + // Fire vs Grass is 2x in the fixture. + for (ability in listOf("filter", "solid-rock", "prism-armor")) { + assertEquals(1.5, defensiveMultiplier(chart, PokemonType.FIRE, PokemonType.GRASS to null, ability), 0.0) + } + } + + @Test + fun `filter does not touch a neutral or resisted hit`() { + assertEquals(1.0, defensiveMultiplier(chart, PokemonType.FIRE, PokemonType.NORMAL to null, "filter"), 0.0) + assertEquals(0.5, defensiveMultiplier(chart, PokemonType.FIRE, PokemonType.FIRE to null, "filter"), 0.0) + } + + @Test + fun `delta-stream caps a super-effective hit against the holder at neutral`() { + // Electric vs Flying is 2x in the fixture. + assertEquals(1.0, defensiveMultiplier(chart, PokemonType.ELECTRIC, PokemonType.FLYING to null, "delta-stream"), 0.0) + } + + @Test + fun `primordial-sea and desolate-land are immune to Fire and Water respectively`() { + assertEquals(0.0, defensiveMultiplier(chart, PokemonType.FIRE, PokemonType.GRASS to null, "primordial-sea"), 0.0) + assertEquals(0.0, defensiveMultiplier(chart, PokemonType.WATER, PokemonType.GRASS to null, "desolate-land"), 0.0) + } + + @Test + fun `tera-shell is badge-only, no multiplier change`() { + assertEquals(2.0, defensiveMultiplier(chart, PokemonType.FIRE, PokemonType.GRASS to null, "tera-shell"), 0.0) + } + + // --- offensive ability gap (overriddenMoveType / bypassesGhostImmunity, §7.2) --- + + @Test + fun `refrigerate rewrites a Normal move to Ice for both offensiveCoverageForMember and the grid`() { + val member = buildMember("Vaporeon", PokemonType.WATER to null, listOf(PokemonType.NORMAL), ability = "refrigerate") + + val coverage = offensiveCoverageForMember(chart, member, useMoves = true) + assertTrue(coverage.contains(PokemonType.GRASS)) // Ice hits Grass 2x; a bare Normal move would not + + val multipliers = offensiveMultipliersForMember(chart, member) + assertEquals(2.0, multipliers.getValue(PokemonType.GRASS), 0.0) + } + + @Test + fun `the -ate abilities never rewrite a move that already has a type`() { + val member = buildMember("Vaporeon", PokemonType.WATER to null, listOf(PokemonType.WATER), ability = "aerilate") + + val coverage = offensiveCoverageForMember(chart, member, useMoves = true) + assertFalse(coverage.contains(PokemonType.FIGHTING)) // Flying would hit Fighting 2x; Water does not + } + + @Test + fun `normalize rewrites every move to Normal, not just Normal-type ones`() { + val member = buildMember("Vaporeon", PokemonType.WATER to null, listOf(PokemonType.GRASS), ability = "normalize") + + // Grass hits Ground 2x; Normal does not, so this proves the Grass move was rewritten away. + val coverage = offensiveCoverageForMember(chart, member, useMoves = true) + assertFalse(coverage.contains(PokemonType.GROUND)) + } + + @Test + fun `the -ate abilities do not apply to type-based coverage, only real moves`() { + // No moves entered — Ditto's own type (Normal) is used as the stand-in attack. If + // refrigerate wrongly applied here too, Normal would become Ice and Grass (2x to Ice) + // would show up in coverage; Normal itself never hits Grass for 2x. + val member = buildMember("Ditto", PokemonType.NORMAL to null, ability = "refrigerate") + + val coverage = offensiveCoverageForMember(chart, member, useMoves = false) + assertFalse(coverage.contains(PokemonType.GRASS)) + } + + @Test + fun `scrappy lets Normal and Fighting moves hit Ghost neutrally, in the grid only`() { + val member = buildMember("Kecleon", PokemonType.NORMAL to null, listOf(PokemonType.NORMAL, PokemonType.FIGHTING), ability = "scrappy") + + val multipliers = offensiveMultipliersForMember(chart, member) + assertEquals(1.0, multipliers.getValue(PokemonType.GHOST), 0.0) + + // Coverage (the >=2x scan) is untouched: 0x -> 1x never crosses the threshold. + val coverage = offensiveCoverageForMember(chart, member, useMoves = true) + assertFalse(coverage.contains(PokemonType.GHOST)) + } + + @Test + fun `without scrappy, Normal and Fighting moves stay blocked by Ghost`() { + val member = buildMember("Kecleon", PokemonType.NORMAL to null, listOf(PokemonType.NORMAL)) + + val multipliers = offensiveMultipliersForMember(chart, member) + assertEquals(0.0, multipliers.getValue(PokemonType.GHOST), 0.0) } @Test diff --git a/app/src/test/java/com/marcogn/coverdex/domain/pokeapi/DatasetAssemblyTest.kt b/app/src/test/java/com/marcogn/coverdex/domain/pokeapi/DatasetAssemblyTest.kt index 6dbc502..9e734b2 100644 --- a/app/src/test/java/com/marcogn/coverdex/domain/pokeapi/DatasetAssemblyTest.kt +++ b/app/src/test/java/com/marcogn/coverdex/domain/pokeapi/DatasetAssemblyTest.kt @@ -26,6 +26,10 @@ class DatasetAssemblyTest { movesCsv = fixture("moves.csv"), typesCsv = fixture("types.csv"), typeEfficacyCsv = fixture("type_efficacy.csv"), + pokemonStatsCsv = fixture("pokemon_stats.csv"), + pokemonStatsPastCsv = fixture("pokemon_stats_past.csv"), + abilityNamesCsv = fixture("ability_names.csv"), + moveNamesCsv = fixture("move_names.csv"), ) } @@ -45,11 +49,13 @@ class DatasetAssemblyTest { } @Test - fun `default ability resolves to the lowest-slot non-hidden ability`() { + fun `default ability resolves to the lowest-slot non-hidden ability, as a display name`() { val bulbasaur = dataset.species.first { it.id == 1 } - // slot 1 non-hidden = overgrow (65); slot 3 hidden = chlorophyll (34), not picked. - assertEquals("overgrow", bulbasaur.defaultAbility) + // slot 1 non-hidden = overgrow (65); slot 3 hidden = chlorophyll (34), not picked. The + // value is the display name ("Overgrow"), not the raw slug — Phase 7 fixed + // defaultAbility carrying the PokeAPI identifier verbatim (phase-7-...md §0.2). + assertEquals("Overgrow", bulbasaur.defaultAbility) } @Test @@ -57,8 +63,8 @@ class DatasetAssemblyTest { val zygardeMega = dataset.species.first { it.id == 10301 } // 10301 (zygarde-mega) has no ability rows of its own; its species (718) default form - // (718, zygarde-50) resolves to aura-break (188). - assertEquals("aura-break", zygardeMega.defaultAbility) + // (718, zygarde-50) resolves to aura-break (188), shown as "Aura Break". + assertEquals("Aura Break", zygardeMega.defaultAbility) } @Test @@ -129,11 +135,84 @@ class DatasetAssemblyTest { } @Test - fun `abilities list is built from abilities csv with prettified display names`() { + fun `abilities list is built from ability_names csv, not prettify`() { val overgrow = dataset.abilities.first { it.id == 65 } - assertEquals("overgrow", overgrow.name) assertEquals("Overgrow", overgrow.displayName) + + // well-baked-body is the case prettify() gets wrong ("Well Baked Body") — the real name, + // from ability_names.csv, keeps the hyphen. See phase-7-...md §0.3. + val wellBakedBody = dataset.abilities.first { it.id == 202 } + assertEquals("Well-Baked Body", wellBakedBody.displayName) + } + + @Test + fun `an ability absent from ability_names csv falls back to prettify`() { + // chlorophyll (34) is deliberately absent from the trimmed ability_names.csv fixture. + val chlorophyll = dataset.abilities.first { it.id == 34 } + assertEquals(prettify("chlorophyll"), chlorophyll.displayName) + } + + @Test + fun `moves list is built from move_names csv, not prettify`() { + val doubleEdge = dataset.moves.first { it.id == 250 } + // prettify("double-edge") would give "Double Edge" — the real name keeps the hyphen. + assertEquals("Double-Edge", doubleEdge.displayName) + } + + @Test + fun `baseStatTotal is the sum of the six current stats`() { + val bulbasaur = dataset.species.first { it.id == 1 } + assertEquals(45 + 49 + 49 + 65 + 65 + 45, bulbasaur.baseStatTotal) + + val deoxysAttack = dataset.species.first { it.id == 10001 } + assertEquals(50 + 180 + 20 + 180 + 20 + 150, deoxysAttack.baseStatTotal) + } + + @Test + fun `a form with no pokemon_stats row has baseStatTotal zero, not a crash`() { + // id 999 ("teststat") has a pokemon.csv/pokemon_types.csv row but no pokemon_stats.csv + // row at all. + val noStats = dataset.species.first { it.id == 999 } + assertEquals(0, noStats.baseStatTotal) + } + + @Test + fun `pastBst carries the generation-1 five-stat total, always emitted when a special row exists`() { + val articunoGen1 = dataset.pastBst.first { it.pokemonId == 144 && it.generationId == 1 } + + // hp(90) + attack(85) + defense(100) + speed(85) + special(100, from the past row) = 460, + // NOT the current six-stat 580 — Gen I has no Sp.Atk/Sp.Def split. + assertEquals(460, articunoGen1.bst) + } + + @Test + fun `pastBst carries a later-generation stat change when the total actually differs`() { + val deoxysAttackGen5 = dataset.pastBst.first { it.pokemonId == 10001 && it.generationId == 5 } + + // Special Attack was 150 through gen 5 (currently 180); every other stat unchanged. + assertEquals(50 + 180 + 20 + 150 + 20 + 150, deoxysAttackGen5.bst) + } + + @Test + fun `a form with no historical stat changes has no pastBst rows`() { + assertTrue(dataset.pastBst.none { it.pokemonId == 1 }) + assertTrue(dataset.pastBst.none { it.pokemonId == 718 }) + } + + @Test + fun `pokemonAbilities carries every canonical ability row with its display name`() { + val bulbasaurAbilities = dataset.pokemonAbilities.filter { it.pokemonId == 1 } + + assertEquals(2, bulbasaurAbilities.size) + val nonHidden = bulbasaurAbilities.first { !it.isHidden } + assertEquals("overgrow", nonHidden.slug) + assertEquals("Overgrow", nonHidden.displayName) + assertEquals(1, nonHidden.slot) + + val hidden = bulbasaurAbilities.first { it.isHidden } + assertEquals("chlorophyll", hidden.slug) + assertEquals("Chlorophyll", hidden.displayName) } @Test diff --git a/app/src/test/java/com/marcogn/coverdex/domain/pokeapi/DatasetParsersTest.kt b/app/src/test/java/com/marcogn/coverdex/domain/pokeapi/DatasetParsersTest.kt index d59843b..46b2e53 100644 --- a/app/src/test/java/com/marcogn/coverdex/domain/pokeapi/DatasetParsersTest.kt +++ b/app/src/test/java/com/marcogn/coverdex/domain/pokeapi/DatasetParsersTest.kt @@ -122,4 +122,40 @@ class DatasetParsersTest { assertEquals(setOf(0, 50, 100, 200), factors) } + + @Test + fun `parses pokemon stats csv into six rows per form`() { + val rows = parsePokemonStats(fixture("pokemon_stats.csv")) + val bulbasaurStats = rows.filter { it.pokemonId == 1 }.associate { it.statId to it.baseStat } + + assertEquals(mapOf(1 to 45, 2 to 49, 3 to 49, 4 to 65, 5 to 65, 6 to 45), bulbasaurStats) + } + + @Test + fun `parses pokemon stats past csv, generationId is the last generation the value held`() { + val rows = parsePokemonStatsPast(fixture("pokemon_stats_past.csv")) + + val deoxysAttack = rows.first { it.pokemonId == 10001 } + assertEquals(5, deoxysAttack.generationId) + assertEquals(4, deoxysAttack.statId) + assertEquals(150, deoxysAttack.baseStat) + } + + @Test + fun `parses ability names csv, keeping only the English rows`() { + val rows = parseAbilityNames(fixture("ability_names.csv")) + + assertEquals("Overgrow", rows.first { it.id == 65 }.name) + assertEquals("Well-Baked Body", rows.first { it.id == 202 }.name) + assertEquals(3, rows.size) + } + + @Test + fun `parses move names csv, keeping only the English rows`() { + val rows = parseMoveNames(fixture("move_names.csv")) + + assertEquals("Pound", rows.first { it.id == 1 }.name) + assertEquals("Double-Edge", rows.first { it.id == 250 }.name) + assertEquals(4, rows.size) + } } diff --git a/app/src/test/java/com/marcogn/coverdex/ui/team/analysis/FakePokedexRepository.kt b/app/src/test/java/com/marcogn/coverdex/ui/team/analysis/FakePokedexRepository.kt index 21fdb1a..69f578f 100644 --- a/app/src/test/java/com/marcogn/coverdex/ui/team/analysis/FakePokedexRepository.kt +++ b/app/src/test/java/com/marcogn/coverdex/ui/team/analysis/FakePokedexRepository.kt @@ -3,7 +3,9 @@ package com.marcogn.coverdex.ui.team.analysis import com.marcogn.coverdex.domain.model.AbilityEntry import com.marcogn.coverdex.domain.model.CacheStatus import com.marcogn.coverdex.domain.model.MoveEntry +import com.marcogn.coverdex.domain.model.PastBst import com.marcogn.coverdex.domain.model.PokemonEntry +import com.marcogn.coverdex.domain.model.SpeciesAbility import com.marcogn.coverdex.domain.model.SyncState import com.marcogn.coverdex.domain.model.TypeChart import com.marcogn.coverdex.domain.repository.PokedexRepository @@ -37,4 +39,6 @@ class FakePokedexRepository( override suspend fun allSpecies(): List = pool override suspend fun allMoves(): List = emptyList() override suspend fun typeChart(): TypeChart = chart + override suspend fun abilitiesForSpecies(pokemonId: Int): List = emptyList() + override suspend fun allPastBst(): List = emptyList() } diff --git a/app/src/test/resources/csv/abilities.csv b/app/src/test/resources/csv/abilities.csv index b04bbb6..6131075 100644 --- a/app/src/test/resources/csv/abilities.csv +++ b/app/src/test/resources/csv/abilities.csv @@ -2,3 +2,4 @@ id,identifier,generation_id,is_main_series 34,chlorophyll,3,1 65,overgrow,3,1 188,aura-break,6,1 +202,well-baked-body,9,1 diff --git a/app/src/test/resources/csv/ability_names.csv b/app/src/test/resources/csv/ability_names.csv new file mode 100644 index 0000000..1a404d6 --- /dev/null +++ b/app/src/test/resources/csv/ability_names.csv @@ -0,0 +1,4 @@ +ability_id,local_language_id,name +65,9,Overgrow +188,9,Aura Break +202,9,Well-Baked Body diff --git a/app/src/test/resources/csv/move_names.csv b/app/src/test/resources/csv/move_names.csv new file mode 100644 index 0000000..51be3b5 --- /dev/null +++ b/app/src/test/resources/csv/move_names.csv @@ -0,0 +1,5 @@ +move_id,local_language_id,name +1,9,Pound +32,9,Horn Drill +150,9,Splash +250,9,Double-Edge diff --git a/app/src/test/resources/csv/moves.csv b/app/src/test/resources/csv/moves.csv index f34107c..18a54f9 100644 --- a/app/src/test/resources/csv/moves.csv +++ b/app/src/test/resources/csv/moves.csv @@ -2,3 +2,4 @@ id,identifier,generation_id,type_id,power,pp,accuracy,priority,target_id,damage_ 1,pound,1,1,40,35,100,0,10,2,1,,5,1,5 32,horn-drill,1,1,,5,30,0,10,2,39,,1,14,9 150,splash,1,1,,40,,0,7,1,86,,3,28,16 +250,double-edge,1,1,120,15,100,0,10,2,,,,, diff --git a/app/src/test/resources/csv/pokemon.csv b/app/src/test/resources/csv/pokemon.csv index 4c108ef..595f6fc 100644 --- a/app/src/test/resources/csv/pokemon.csv +++ b/app/src/test/resources/csv/pokemon.csv @@ -4,3 +4,4 @@ id,identifier,species_id,height,weight,base_experience,order,is_default 718,zygarde-50,718,50,3050,270,858,1 10301,zygarde-mega,718,77,6100,,,0 144,articuno,144,17,554,261,236,1 +999,teststat,999,3,10,50,1,1 diff --git a/app/src/test/resources/csv/pokemon_species.csv b/app/src/test/resources/csv/pokemon_species.csv index 9f92c01..d938cf9 100644 --- a/app/src/test/resources/csv/pokemon_species.csv +++ b/app/src/test/resources/csv/pokemon_species.csv @@ -4,3 +4,4 @@ id,identifier,generation_id,evolves_from_species_id,evolution_chain_id,color_id, 144,articuno,1,,73,2,9,5,-1,3,35,0,80,0,1,0,1,0,176,192 718,zygarde,6,,370,5,2,,-1,3,0,0,120,0,1,0,1,0,718, 386,deoxys,3,,202,8,12,5,-1,3,0,0,120,0,1,1,0,1,416, +999,teststat,1,,999,1,1,,1,45,70,0,20,0,4,0,0,0,999, diff --git a/app/src/test/resources/csv/pokemon_stats.csv b/app/src/test/resources/csv/pokemon_stats.csv new file mode 100644 index 0000000..60fe636 --- /dev/null +++ b/app/src/test/resources/csv/pokemon_stats.csv @@ -0,0 +1,31 @@ +pokemon_id,stat_id,base_stat,effort +1,1,45,0 +1,2,49,0 +1,3,49,0 +1,4,65,1 +1,5,65,0 +1,6,45,0 +10001,1,50,0 +10001,2,180,0 +10001,3,20,0 +10001,4,180,0 +10001,5,20,0 +10001,6,150,3 +718,1,216,3 +718,2,100,0 +718,3,121,0 +718,4,91,0 +718,5,95,0 +718,6,85,0 +10301,1,216,0 +10301,2,100,0 +10301,3,121,0 +10301,4,91,0 +10301,5,95,0 +10301,6,95,0 +144,1,90,0 +144,2,85,0 +144,3,100,0 +144,4,95,0 +144,5,125,1 +144,6,85,0 diff --git a/app/src/test/resources/csv/pokemon_stats_past.csv b/app/src/test/resources/csv/pokemon_stats_past.csv new file mode 100644 index 0000000..f630423 --- /dev/null +++ b/app/src/test/resources/csv/pokemon_stats_past.csv @@ -0,0 +1,3 @@ +pokemon_id,generation_id,stat_id,base_stat,effort +144,1,9,100,0 +10001,5,4,150,0 diff --git a/app/src/test/resources/csv/pokemon_types.csv b/app/src/test/resources/csv/pokemon_types.csv index 96289bf..405606a 100644 --- a/app/src/test/resources/csv/pokemon_types.csv +++ b/app/src/test/resources/csv/pokemon_types.csv @@ -8,3 +8,4 @@ pokemon_id,type_id,slot 144,3,2 10301,16,1 10301,5,2 +999,1,1 From 61387b929e80129685a1e112e80bc5318c23b6f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 11:35:47 +0000 Subject: [PATCH 3/7] Phase 7: held items (defensive subset) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements docs/plan/phase-7-accuracy-and-customization.md §4 — step 5 of the task order (§9). BST-aware suggestions, the suggestion-count setting, docs and the exhaustive type-chart sweep are still follow-up commits. - domain/item/ItemEffects.kt: Air Balloon, Iron Ball, Ring Target, and one resist berry per type (18 items total: 17 types + Chilan for Normal, which applies unconditionally since nothing is super-effective against Normal). items.csv is deliberately not downloaded — this is a hardcoded table, same shape as ABILITY_EFFECTS, with the same symbol-insensitive itemKey() lookup. - TeamMember.item (nullable, default null — a suggestion candidate never sets one). Room columns landed in the previous commit; this one wires them through TeamMappers, CustomPokemonDao's upsert (already fixed earlier) and BackupRepositoryImpl's restore path, which had its own direct CustomPokemonEntity(...) construction that bypassed the mapper and would have silently dropped the item on every restore — caught by a new round-trip test before it shipped. - CoverageEngine.defensiveMultiplier gains an item parameter and applies ability+item effects in the order §4.2 specifies: item RemovesTypeImmunities/GroundsHolder cancel a type-chart 0, then ability immunity (skipped for a Ground attack under Iron Ball, which also neutralizes Levitate/Earth Eater), then item Immunity, then ability multiplier and super-effective reducer, then the resist berry. Threaded through defensiveProfile/sharedWeaknessCounts/mostVulnerableByType and Scoring.kt's weaknesses()/teamScoringContext()/computeCompositeScore(). - Showdown export/import: "Species @ Item" round-trips instead of being discarded on import. Backup format bumped to v2 (a v1 file has no `item` key at all, so it still decodes with item defaulting to null). - UI: ui/common/ItemPicker.kt (free text + suggestions from the modelled set, same contract as the ability field), wired into SlotEditorScreen and RosterEditorScreen; shown on SlotSummaryCard ("@ Item") and PerPokemonCard (with its effect summary, mirroring the ability row). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011j5jieoE4nqU2ZBSM7USHc --- .../data/repository/BackupRepositoryImpl.kt | 1 + .../coverdex/data/repository/TeamMappers.kt | 4 + .../coverdex/domain/backup/BackupPayload.kt | 11 ++- .../domain/coverage/CoverageEngine.kt | 45 +++++++++-- .../coverdex/domain/item/ItemEffects.kt | 79 +++++++++++++++++++ .../coverdex/domain/model/TeamMember.kt | 5 ++ .../domain/showdown/ShowdownFormat.kt | 12 ++- .../coverdex/domain/suggestion/Scoring.kt | 13 ++- .../marcogn/coverdex/ui/common/ItemPicker.kt | 47 +++++++++++ .../coverdex/ui/roster/RosterEditorScreen.kt | 12 +++ .../coverdex/ui/team/SlotEditorScreen.kt | 12 +++ .../coverdex/ui/team/SlotSummaryCard.kt | 9 +++ .../ui/team/analysis/PerPokemonCard.kt | 25 +++++- app/src/main/res/values-en/strings.xml | 3 + app/src/main/res/values/strings.xml | 3 + .../repository/BackupRepositoryImplTest.kt | 11 +++ .../data/repository/TeamMappersTest.kt | 22 ++++++ .../domain/backup/BackupPayloadTest.kt | 41 ++++++++++ .../domain/coverage/CoverageEngineTest.kt | 52 ++++++++++++ .../coverdex/domain/item/ItemEffectsTest.kt | 58 ++++++++++++++ .../domain/showdown/ShowdownFormatTest.kt | 46 +++++++++++ .../coverdex/domain/suggestion/ScoringTest.kt | 8 ++ 22 files changed, 504 insertions(+), 15 deletions(-) create mode 100644 app/src/main/java/com/marcogn/coverdex/domain/item/ItemEffects.kt create mode 100644 app/src/main/java/com/marcogn/coverdex/ui/common/ItemPicker.kt create mode 100644 app/src/test/java/com/marcogn/coverdex/domain/item/ItemEffectsTest.kt diff --git a/app/src/main/java/com/marcogn/coverdex/data/repository/BackupRepositoryImpl.kt b/app/src/main/java/com/marcogn/coverdex/data/repository/BackupRepositoryImpl.kt index 8a3c77b..e5ea865 100644 --- a/app/src/main/java/com/marcogn/coverdex/data/repository/BackupRepositoryImpl.kt +++ b/app/src/main/java/com/marcogn/coverdex/data/repository/BackupRepositoryImpl.kt @@ -72,6 +72,7 @@ class BackupRepositoryImpl @Inject constructor( type2 = member.types.second?.apiName, ability = member.ability, createdAtEpochMillis = restoreTimeBase + index, + item = member.item, ) customMoveEntities += member.movesToCustomEntities() } diff --git a/app/src/main/java/com/marcogn/coverdex/data/repository/TeamMappers.kt b/app/src/main/java/com/marcogn/coverdex/data/repository/TeamMappers.kt index 627e495..0f9b036 100644 --- a/app/src/main/java/com/marcogn/coverdex/data/repository/TeamMappers.kt +++ b/app/src/main/java/com/marcogn/coverdex/data/repository/TeamMappers.kt @@ -82,6 +82,7 @@ fun TeamMember.toEntity(teamId: String, slotIndex: Int): TeamMemberEntity = Team type2 = types.second?.apiName, ability = ability, isCustomSaved = isCustomSaved, + item = item, ) fun TeamMember.movesToEntities(): List = @@ -94,6 +95,7 @@ fun TeamMember.toCustomEntity(): CustomPokemonEntity = CustomPokemonEntity( type2 = types.second?.apiName, ability = ability, createdAtEpochMillis = System.currentTimeMillis(), + item = item, ) fun TeamMember.movesToCustomEntities(): List = @@ -107,6 +109,7 @@ fun TeamMemberWithMoves.toDomain(): TeamMember { speciesName = member.speciesName, types = parseType(member.type1) to member.type2?.let { parseType(it) }, ability = member.ability, + item = member.item, moves = (0 until MOVE_COUNT).map { index -> movesByIndex[index]?.toDomain() }, isCustomSaved = member.isCustomSaved, ) @@ -120,6 +123,7 @@ fun CustomPokemonWithMoves.toDomain(): TeamMember { speciesName = custom.name, types = parseType(custom.type1) to custom.type2?.let { parseType(it) }, ability = custom.ability, + item = custom.item, moves = (0 until MOVE_COUNT).map { index -> movesByIndex[index]?.toDomain() }, isCustomSaved = true, ) diff --git a/app/src/main/java/com/marcogn/coverdex/domain/backup/BackupPayload.kt b/app/src/main/java/com/marcogn/coverdex/domain/backup/BackupPayload.kt index ef20d74..bcacfea 100644 --- a/app/src/main/java/com/marcogn/coverdex/domain/backup/BackupPayload.kt +++ b/app/src/main/java/com/marcogn/coverdex/domain/backup/BackupPayload.kt @@ -11,8 +11,11 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json /** The current format this build writes and the newest one it can read — see - * [BackupFormatTooNewException]. */ -const val CURRENT_BACKUP_FORMAT_VERSION = 1 + * [BackupFormatTooNewException]. Bumped to 2 in Phase 7 for [BackupTeamMemberDto.item]; a v1 + * file has no `item` key at all (missing, not an explicit `null`), so it still decodes cleanly + * with [BackupTeamMemberDto.item] falling back to its default — see + * docs/plan/phase-7-accuracy-and-customization.md §4.3. */ +const val CURRENT_BACKUP_FORMAT_VERSION = 2 /** Thrown when a backup's `formatVersion` is newer than this build understands — a clear, typed * rejection rather than a parse crash or silent data loss. */ @@ -62,6 +65,8 @@ data class BackupTeamMemberDto( /** Always length 4, `null` for an empty move slot. */ val moves: List, val isCustomSaved: Boolean, + /** Added in format version 2 — see [CURRENT_BACKUP_FORMAT_VERSION]. */ + val item: String? = null, ) @Serializable @@ -94,6 +99,7 @@ fun TeamMember.toBackupDto(): BackupTeamMemberDto = BackupTeamMemberDto( ability = ability, moves = moves.map { it?.toBackupDto() }, isCustomSaved = isCustomSaved, + item = item, ) fun BackupTeamMemberDto.toDomain(): TeamMember = TeamMember( @@ -104,6 +110,7 @@ fun BackupTeamMemberDto.toDomain(): TeamMember = TeamMember( ability = ability, moves = moves.map { it?.toDomain() }, isCustomSaved = isCustomSaved, + item = item, ) fun Team.toBackupDto(): BackupTeamDto = BackupTeamDto( diff --git a/app/src/main/java/com/marcogn/coverdex/domain/coverage/CoverageEngine.kt b/app/src/main/java/com/marcogn/coverdex/domain/coverage/CoverageEngine.kt index 243fe38..feec90d 100644 --- a/app/src/main/java/com/marcogn/coverdex/domain/coverage/CoverageEngine.kt +++ b/app/src/main/java/com/marcogn/coverdex/domain/coverage/CoverageEngine.kt @@ -4,6 +4,8 @@ import com.marcogn.coverdex.domain.ability.applyAbilityEffects import com.marcogn.coverdex.domain.ability.bypassesGhostImmunity import com.marcogn.coverdex.domain.ability.getAbilityEffects import com.marcogn.coverdex.domain.ability.overriddenMoveType +import com.marcogn.coverdex.domain.item.ItemEffect +import com.marcogn.coverdex.domain.item.getItemEffects import com.marcogn.coverdex.domain.model.DamageClass import com.marcogn.coverdex.domain.model.PokemonType import com.marcogn.coverdex.domain.model.TeamMember @@ -20,20 +22,50 @@ import com.marcogn.coverdex.domain.model.TypeChart /** * Compute defensive effectiveness on a defender with one or two types. Multiplies effectiveness - * across both defender types (stacking — never additive). When an ability is provided, immunity - * and multiplier effects are applied. + * across both defender types (stacking — never additive), then applies [ability] and [item] + * effects in the fixed order docs/plan/phase-7-accuracy-and-customization.md §4.2 specifies: + * + * 1. The type chart product, above. + * 2. Item [ItemEffect.RemovesTypeImmunities]/[ItemEffect.GroundsHolder] cancel a `0.0` that came + * from the type chart itself — Ring Target for any type, Iron Ball only for a Ground attack. + * 3. Ability immunity (skipped entirely for a Ground attack when Iron Ball is held — it + * neutralizes an ability-granted Ground immunity too, e.g. Levitate/Earth Eater, not just the + * type chart's). + * 4. Item [ItemEffect.Immunity] (Air Balloon) — same Iron Ball exception as step 3. + * 5. Ability multiplier (Thick Fat, Heatproof, Dry Skin's Fire 1.25x, ...). + * 6. Ability super-effective reducer/cap (Filter/Solid Rock/Prism Armor, Delta Stream) — only + * once the running value is already `> 1.0`. + * 7. Item [ItemEffect.ResistBerry] — `x0.5` once the running value is `> 1.0`, or unconditionally + * for Chilan Berry ([ItemEffect.ResistBerry.alwaysApplies]). + * + * Steps 3+5+6 are one call to [applyAbilityEffects]; steps 2/4/7 are items, interleaved around it + * rather than folded into the same helper, since Iron Ball's step-3/4 skip couldn't otherwise be + * expressed without a callback. */ fun defensiveMultiplier( chart: TypeChart, attackingType: PokemonType, defenderTypes: Pair, ability: String? = null, + item: String? = null, ): Double { val t1 = chart.multiplier(attackingType, defenderTypes.first) val t2 = defenderTypes.second?.let { chart.multiplier(attackingType, it) } ?: 1.0 val chartProduct = t1 * t2 - return applyAbilityEffects(chartProduct, attackingType, getAbilityEffects(ability)) + val itemEffects = getItemEffects(item).orEmpty() + val groundsHolder = attackingType == PokemonType.GROUND && itemEffects.any { it is ItemEffect.GroundsHolder } + val removesTypeImmunities = itemEffects.any { it is ItemEffect.RemovesTypeImmunities } + + val afterTypeChartCancel = if (chartProduct == 0.0 && (removesTypeImmunities || groundsHolder)) 1.0 else chartProduct + + if (!groundsHolder && itemEffects.any { it is ItemEffect.Immunity && it.type == attackingType }) return 0.0 + + val abilityEffects = if (groundsHolder) null else getAbilityEffects(ability) + val afterAbility = applyAbilityEffects(afterTypeChartCancel, attackingType, abilityEffects) + + val resistBerry = itemEffects.filterIsInstance().find { it.type == attackingType } + return if (resistBerry != null && (resistBerry.alwaysApplies || afterAbility > 1.0)) afterAbility * 0.5 else afterAbility } private fun damagingMoveTypes(member: TeamMember): List = @@ -133,12 +165,13 @@ fun defensiveProfile( chart: TypeChart, types: Pair, ability: String? = null, + item: String? = null, ): DefensiveProfile { val weaknesses = mutableListOf() val resistances = mutableListOf() val immunities = mutableListOf() for (atk in PokemonType.entries) { - when (val m = defensiveMultiplier(chart, atk, types, ability)) { + when (val m = defensiveMultiplier(chart, atk, types, ability, item)) { 0.0 -> immunities.add(atk) else -> if (m > 1.0) weaknesses.add(atk) else if (m < 1.0) resistances.add(atk) } @@ -153,7 +186,7 @@ fun defensiveProfile( * UI calls one shared function for both, per the same "call the shared function, don't repeat * the filter" principle `phase-3-analysis.md` states for `collectAttackingTypes`. */ fun sharedWeaknessCounts(chart: TypeChart, members: List): Map = - PokemonType.entries.associateWith { atk -> members.count { defensiveMultiplier(chart, atk, it.types, it.ability) > 1.0 } } + PokemonType.entries.associateWith { atk -> members.count { defensiveMultiplier(chart, atk, it.types, it.ability, it.item) > 1.0 } } /** Types that hit 2+ members for super-effective damage. */ fun sharedWeaknesses(chart: TypeChart, members: List): List = @@ -193,4 +226,4 @@ fun offensiveMultipliersForMember(chart: TypeChart, member: TeamMember): Map): Map = - PokemonType.entries.associateWith { atk -> members.maxOfOrNull { m -> defensiveMultiplier(chart, atk, m.types, m.ability) } ?: 0.0 } + PokemonType.entries.associateWith { atk -> members.maxOfOrNull { m -> defensiveMultiplier(chart, atk, m.types, m.ability, m.item) } ?: 0.0 } diff --git a/app/src/main/java/com/marcogn/coverdex/domain/item/ItemEffects.kt b/app/src/main/java/com/marcogn/coverdex/domain/item/ItemEffects.kt new file mode 100644 index 0000000..78957f2 --- /dev/null +++ b/app/src/main/java/com/marcogn/coverdex/domain/item/ItemEffects.kt @@ -0,0 +1,79 @@ +package com.marcogn.coverdex.domain.item + +import com.marcogn.coverdex.domain.model.PokemonType + +/** + * Held items are otherwise unmodelled in this app (no field existed before Phase 7 at all) — see + * docs/plan/phase-7-accuracy-and-customization.md §4. Modelled here only for the items that + * change a type multiplier; everything else is free text with no calculation effect, same + * "type it, nothing rejects it" contract as `domain/ability/AbilityEffects.kt`. Deliberately not + * modelled, with the reason: Heavy-Duty Boots and Utility Umbrella (entry hazards / weather — + * neither touches a type multiplier), Expert Belt and the type-boosting plates/gems (offensive + * damage, not the >=2x coverage threshold `offensiveCoverageForMember` tests). + */ +sealed interface ItemEffect { + /** Air Balloon: Ground moves miss entirely, until the balloon pops — modelled here as an + * unconditional immunity, since this engine has no "already been hit this turn" concept. */ + data class Immunity(val type: PokemonType) : ItemEffect + + /** Iron Ball: grounds the holder, cancelling a Ground-move immunity from any source + * (Levitate, Air Balloon, the Flying type itself, Ground's own type-chart immunities elsewhere) + * — but only for Ground moves; every other immunity the holder has is untouched. */ + data object GroundsHolder : ItemEffect + + /** Ring Target: removes every type immunity the holder has, of any type, from any source — + * broader than Iron Ball, which only cancels Ground. */ + data object RemovesTypeImmunities : ItemEffect + + /** A type-resist berry: halves an incoming hit of [type] once it is already super-effective + * (>1x) — a real resist berry is also consumed on that hit, which this engine has no concept + * of, so the halving is modelled as permanent. [alwaysApplies] is Chilan Berry's own + * exception: it halves Normal damage unconditionally, super-effective or not, since Normal + * has no super-effective matchups to gate on. */ + data class ResistBerry(val type: PokemonType, val alwaysApplies: Boolean = false) : ItemEffect +} + +/** Ported from PokéAPI's own item data: one resist berry per type (Chilan Berry covers Normal, + * unconditionally, since nothing is super-effective against Normal), plus Air Balloon, Iron Ball + * and Ring Target. Keyed the same way as `ABILITY_EFFECTS` — lowercase, hyphenated, matching + * PokéAPI's item `identifier` — and looked up the same symbol-insensitive way via [itemKey]. */ +val ITEM_EFFECTS: Map> = mapOf( + "air-balloon" to listOf(ItemEffect.Immunity(PokemonType.GROUND)), + "iron-ball" to listOf(ItemEffect.GroundsHolder), + "ring-target" to listOf(ItemEffect.RemovesTypeImmunities), + "occa-berry" to listOf(ItemEffect.ResistBerry(PokemonType.FIRE)), + "passho-berry" to listOf(ItemEffect.ResistBerry(PokemonType.WATER)), + "wacan-berry" to listOf(ItemEffect.ResistBerry(PokemonType.ELECTRIC)), + "rindo-berry" to listOf(ItemEffect.ResistBerry(PokemonType.GRASS)), + "yache-berry" to listOf(ItemEffect.ResistBerry(PokemonType.ICE)), + "chople-berry" to listOf(ItemEffect.ResistBerry(PokemonType.FIGHTING)), + "kebia-berry" to listOf(ItemEffect.ResistBerry(PokemonType.POISON)), + "shuca-berry" to listOf(ItemEffect.ResistBerry(PokemonType.GROUND)), + "coba-berry" to listOf(ItemEffect.ResistBerry(PokemonType.FLYING)), + "payapa-berry" to listOf(ItemEffect.ResistBerry(PokemonType.PSYCHIC)), + "tanga-berry" to listOf(ItemEffect.ResistBerry(PokemonType.BUG)), + "charti-berry" to listOf(ItemEffect.ResistBerry(PokemonType.ROCK)), + "kasib-berry" to listOf(ItemEffect.ResistBerry(PokemonType.GHOST)), + "haban-berry" to listOf(ItemEffect.ResistBerry(PokemonType.DRAGON)), + "colbur-berry" to listOf(ItemEffect.ResistBerry(PokemonType.DARK)), + "babiri-berry" to listOf(ItemEffect.ResistBerry(PokemonType.STEEL)), + "roseli-berry" to listOf(ItemEffect.ResistBerry(PokemonType.FAIRY)), + "chilan-berry" to listOf(ItemEffect.ResistBerry(PokemonType.NORMAL, alwaysApplies = true)), +) + +/** Lowercase, letters and digits only — mirrors + * [com.marcogn.coverdex.domain.ability.abilityKey]/[com.marcogn.coverdex.domain.pokeapi.searchKey], + * so `"Air Balloon"`, `"air-balloon"` and `"airballoon"` all resolve to the same [ITEM_EFFECTS] + * entry. */ +fun itemKey(name: String): String = name.lowercase().filter { it.isLetterOrDigit() } + +private val effectsBySymbolFreeKey: Map> = ITEM_EFFECTS.mapKeys { (slug, _) -> itemKey(slug) } + +/** `null` or empty returns `null`, same falsy contract as + * [com.marcogn.coverdex.domain.ability.getAbilityEffects] — an item genuinely absent from + * [ITEM_EFFECTS] (anything not in the defensive subset, or free text) is indistinguishable from + * "no item" here, which is correct: neither has a calculation effect. */ +fun getItemEffects(item: String?): List? { + if (item.isNullOrEmpty()) return null + return effectsBySymbolFreeKey[itemKey(item)] +} diff --git a/app/src/main/java/com/marcogn/coverdex/domain/model/TeamMember.kt b/app/src/main/java/com/marcogn/coverdex/domain/model/TeamMember.kt index ab1f260..d8d19b1 100644 --- a/app/src/main/java/com/marcogn/coverdex/domain/model/TeamMember.kt +++ b/app/src/main/java/com/marcogn/coverdex/domain/model/TeamMember.kt @@ -18,4 +18,9 @@ data class TeamMember( val ability: String?, val moves: List, val isCustomSaved: Boolean, + /** Free text, same "type it or pick it" contract as [ability] — modelled effects exist only + * for the defensive subset in `domain/item/ItemEffects.kt`. Added in Phase 7; `null` for + * every member that predates it and for a suggestion candidate ([memberFromEntry] never sets + * one). See docs/plan/phase-7-accuracy-and-customization.md §4. */ + val item: String? = null, ) diff --git a/app/src/main/java/com/marcogn/coverdex/domain/showdown/ShowdownFormat.kt b/app/src/main/java/com/marcogn/coverdex/domain/showdown/ShowdownFormat.kt index bae62ad..4e3a10c 100644 --- a/app/src/main/java/com/marcogn/coverdex/domain/showdown/ShowdownFormat.kt +++ b/app/src/main/java/com/marcogn/coverdex/domain/showdown/ShowdownFormat.kt @@ -41,11 +41,13 @@ private fun MoveEntry.toPokemonMove(): PokemonMove = PokemonMove( isCustom = false, ) -/** Convert a [TeamMember] to a Showdown-style block. Fields not tracked (item, EVs, nature) are +/** Convert a [TeamMember] to a Showdown-style block. [TeamMember.item] round-trips as the + * standard `Species @ Item` line (Phase 7 — see + * docs/plan/phase-7-accuracy-and-customization.md §4.3); EVs and nature are still untracked and * emitted as placeholders that are valid to re-import. */ fun exportMemberToShowdown(m: TeamMember): String { val lines = mutableListOf() - lines += "${m.speciesName} @ " + lines += "${m.speciesName} @ ${m.item ?: ""}" lines += "Ability: ${m.ability ?: ""}" lines += "EVs: " lines += " Nature" @@ -83,6 +85,7 @@ fun parseShowdownBlock( var speciesName = "Unknown" var overrideTypes: Pair? = null var ability: String? = null + var item: String? = null val moves = arrayOfNulls(4) var moveIdx = 0 val unknown = mutableListOf() @@ -117,6 +120,10 @@ fun parseShowdownBlock( // Species line, possibly with "@ item". val speciesLine = line.substringBefore("@").trim() if (speciesLine.isNotEmpty()) speciesName = speciesLine + if (line.contains("@")) { + val itemValue = line.substringAfter("@").trim() + if (itemValue.isNotEmpty()) item = itemValue + } } } } @@ -130,6 +137,7 @@ fun parseShowdownBlock( speciesName = speciesName, types = types, ability = ability, + item = item, moves = moves.toList(), isCustomSaved = false, ) diff --git a/app/src/main/java/com/marcogn/coverdex/domain/suggestion/Scoring.kt b/app/src/main/java/com/marcogn/coverdex/domain/suggestion/Scoring.kt index 650a2c0..1f83889 100644 --- a/app/src/main/java/com/marcogn/coverdex/domain/suggestion/Scoring.kt +++ b/app/src/main/java/com/marcogn/coverdex/domain/suggestion/Scoring.kt @@ -26,8 +26,13 @@ const val AGGRAVATED_WEAKNESS_PENALTY = 1.0 * `docs/implementation-decisions.md`, "Post-migration review", for why this is a deliberate spec * change (composite scores now differ from the ported baseline for any member/candidate with a * scoring-relevant ability) rather than a pure refactor. */ -fun weaknesses(chart: TypeChart, types: Pair, ability: String? = null): List = - PokemonType.entries.filter { atk -> defensiveMultiplier(chart, atk, types, ability) > 1.0 } +fun weaknesses( + chart: TypeChart, + types: Pair, + ability: String? = null, + item: String? = null, +): List = + PokemonType.entries.filter { atk -> defensiveMultiplier(chart, atk, types, ability, item) > 1.0 } data class CompositeScoreResult( val compositeScore: Double, @@ -57,7 +62,7 @@ fun teamScoringContext(chart: TypeChart, otherMembers: List): TeamSc val otherWeaknessMap = mutableMapOf>() for (m in otherMembers) { - for (w in weaknesses(chart, m.types, m.ability)) { + for (w in weaknesses(chart, m.types, m.ability, m.item)) { otherWeaknessMap.getOrPut(w) { mutableListOf() }.add(m.speciesName) } } @@ -89,7 +94,7 @@ fun computeCompositeScore( val offensiveGain = newUnion.size - currentTeamCoverage.size val newlyCovered = newUnion.filter { it !in currentTeamCoverage } - val candWeaknesses = weaknesses(chart, candidate.types, candidate.ability) + val candWeaknesses = weaknesses(chart, candidate.types, candidate.ability, candidate.item) val newWeaknesses = mutableListOf() val aggravatedWeaknesses = mutableListOf() diff --git a/app/src/main/java/com/marcogn/coverdex/ui/common/ItemPicker.kt b/app/src/main/java/com/marcogn/coverdex/ui/common/ItemPicker.kt new file mode 100644 index 0000000..68b0e77 --- /dev/null +++ b/app/src/main/java/com/marcogn/coverdex/ui/common/ItemPicker.kt @@ -0,0 +1,47 @@ +package com.marcogn.coverdex.ui.common + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.marcogn.coverdex.R +import com.marcogn.coverdex.domain.item.ITEM_EFFECTS +import com.marcogn.coverdex.domain.pokeapi.prettify + +/** + * The item field — free text with suggestions from the modelled defensive subset + * ([ITEM_EFFECTS]), the same "suggest but never reject" contract as + * [AbilityPicker]'s free-text mode: there is no cached item catalogue (`items.csv` is + * deliberately not downloaded, see docs/plan/phase-7-accuracy-and-customization.md §4), so any + * item name is accepted, modelled or not. [resetKey] is the caller's draft identity, same reason + * as [AbilityPicker]'s. + */ +@Composable +fun ItemPicker( + resetKey: Any, + item: String?, + onItemChange: (String?) -> Unit, + modifier: Modifier = Modifier, +) { + var query by remember(resetKey) { mutableStateOf(item.orEmpty()) } + val suggestions = remember(query) { + val key = query.trim().lowercase() + MODELLED_ITEM_DISPLAY_NAMES.filter { key.isEmpty() || it.lowercase().contains(key) } + } + + EditableComboBox( + value = query, + onValueChange = { value -> + query = value + onItemChange(value.ifBlank { null }) + }, + label = stringResource(R.string.slot_item_label), + suggestions = suggestions, + modifier = modifier, + ) +} + +private val MODELLED_ITEM_DISPLAY_NAMES: List = ITEM_EFFECTS.keys.map { prettify(it) }.sorted() diff --git a/app/src/main/java/com/marcogn/coverdex/ui/roster/RosterEditorScreen.kt b/app/src/main/java/com/marcogn/coverdex/ui/roster/RosterEditorScreen.kt index 6b6292b..95a753e 100644 --- a/app/src/main/java/com/marcogn/coverdex/ui/roster/RosterEditorScreen.kt +++ b/app/src/main/java/com/marcogn/coverdex/ui/roster/RosterEditorScreen.kt @@ -37,6 +37,7 @@ import com.marcogn.coverdex.domain.model.PokemonMove import com.marcogn.coverdex.domain.model.PokemonType import com.marcogn.coverdex.domain.model.TeamMember import com.marcogn.coverdex.ui.common.AbilityPicker +import com.marcogn.coverdex.ui.common.ItemPicker import com.marcogn.coverdex.ui.common.TypeDropdown import com.marcogn.coverdex.ui.team.MoveSlotEditor import java.util.UUID @@ -49,6 +50,7 @@ private data class RosterDraft( val type1: PokemonType, val type2: PokemonType?, val ability: String?, + val item: String?, val moves: List, ) { fun toTeamMember(): TeamMember = TeamMember( @@ -57,6 +59,7 @@ private data class RosterDraft( speciesName = speciesName, types = type1 to type2, ability = ability, + item = item, moves = moves, isCustomSaved = true, ) @@ -68,6 +71,7 @@ private data class RosterDraft( type1 = PokemonType.NORMAL, type2 = null, ability = null, + item = null, moves = List(MOVE_COUNT) { null }, ) @@ -77,6 +81,7 @@ private data class RosterDraft( type1 = member.types.first, type2 = member.types.second, ability = member.ability, + item = member.item, moves = member.moves, ) } @@ -171,6 +176,13 @@ fun RosterEditorScreen( modifier = Modifier.fillMaxWidth(), ) + ItemPicker( + resetKey = draft.id, + item = draft.item, + onItemChange = { draft = draft.copy(item = it) }, + modifier = Modifier.fillMaxWidth(), + ) + if (showMoves) { HorizontalDivider() Text(stringResource(R.string.slot_moves_title), style = MaterialTheme.typography.titleSmall) diff --git a/app/src/main/java/com/marcogn/coverdex/ui/team/SlotEditorScreen.kt b/app/src/main/java/com/marcogn/coverdex/ui/team/SlotEditorScreen.kt index 4149e4b..19268a9 100644 --- a/app/src/main/java/com/marcogn/coverdex/ui/team/SlotEditorScreen.kt +++ b/app/src/main/java/com/marcogn/coverdex/ui/team/SlotEditorScreen.kt @@ -42,6 +42,7 @@ import com.marcogn.coverdex.domain.model.TeamMember import com.marcogn.coverdex.domain.sprite.SpriteContext import com.marcogn.coverdex.ui.common.AbilityPicker import com.marcogn.coverdex.ui.common.DropdownOption +import com.marcogn.coverdex.ui.common.ItemPicker import com.marcogn.coverdex.ui.common.PokemonSprite import com.marcogn.coverdex.ui.common.SearchableDropdown import com.marcogn.coverdex.ui.common.TypeBadge @@ -57,6 +58,7 @@ private data class SlotDraft( val type1: PokemonType, val type2: PokemonType?, val ability: String?, + val item: String?, val moves: List, val isCustomSaved: Boolean, ) { @@ -66,6 +68,7 @@ private data class SlotDraft( speciesName = speciesName, types = type1 to type2, ability = ability, + item = item, moves = moves, isCustomSaved = isCustomSaved, ) @@ -78,6 +81,7 @@ private data class SlotDraft( type1 = member.types.first, type2 = member.types.second, ability = member.ability, + item = member.item, moves = member.moves, isCustomSaved = member.isCustomSaved, ) @@ -152,6 +156,7 @@ fun SlotEditorScreen( type1 = entry.types.first, type2 = entry.types.second, ability = entry.defaultAbility, + item = null, moves = List(MOVE_COUNT) { null }, isCustomSaved = false, ) @@ -207,6 +212,13 @@ fun SlotEditorScreen( modifier = Modifier.fillMaxWidth(), ) + ItemPicker( + resetKey = currentDraft.id, + item = currentDraft.item, + onItemChange = { draft = currentDraft.copy(item = it) }, + modifier = Modifier.fillMaxWidth(), + ) + if (showMoves) { HorizontalDivider() Text(stringResource(R.string.slot_moves_title), style = MaterialTheme.typography.titleSmall) diff --git a/app/src/main/java/com/marcogn/coverdex/ui/team/SlotSummaryCard.kt b/app/src/main/java/com/marcogn/coverdex/ui/team/SlotSummaryCard.kt index 51b9e0b..4490e13 100644 --- a/app/src/main/java/com/marcogn/coverdex/ui/team/SlotSummaryCard.kt +++ b/app/src/main/java/com/marcogn/coverdex/ui/team/SlotSummaryCard.kt @@ -50,6 +50,15 @@ fun SlotSummaryCard( TypeBadge(member.types.first) member.types.second?.let { TypeBadge(it) } } + if (!member.item.isNullOrEmpty()) { + Text( + "@ ${member.item}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } } } diff --git a/app/src/main/java/com/marcogn/coverdex/ui/team/analysis/PerPokemonCard.kt b/app/src/main/java/com/marcogn/coverdex/ui/team/analysis/PerPokemonCard.kt index 47f4c16..5d034f1 100644 --- a/app/src/main/java/com/marcogn/coverdex/ui/team/analysis/PerPokemonCard.kt +++ b/app/src/main/java/com/marcogn/coverdex/ui/team/analysis/PerPokemonCard.kt @@ -35,6 +35,8 @@ import com.marcogn.coverdex.domain.ability.getAbilityEffects import com.marcogn.coverdex.domain.coverage.attackingTypesForMember import com.marcogn.coverdex.domain.coverage.defensiveMultiplier import com.marcogn.coverdex.domain.coverage.memberHasMoves +import com.marcogn.coverdex.domain.item.ItemEffect +import com.marcogn.coverdex.domain.item.getItemEffects import com.marcogn.coverdex.domain.model.PokemonType import com.marcogn.coverdex.domain.model.TeamMember import com.marcogn.coverdex.domain.model.TypeChart @@ -82,7 +84,7 @@ fun PerPokemonCard(member: TeamMember, chart: TypeChart, modifier: Modifier = Mo val resist025x = mutableListOf() val immune = mutableListOf() for (atk in PokemonType.entries) { - when (val mult = defensiveMultiplier(chart, atk, member.types, member.ability)) { + when (val mult = defensiveMultiplier(chart, atk, member.types, member.ability, member.item)) { 0.0 -> immune.add(atk) else -> when { mult >= 4.0 -> weak4x.add(atk) @@ -124,6 +126,27 @@ fun PerPokemonCard(member: TeamMember, chart: TypeChart, modifier: Modifier = Mo } } } + + if (!member.item.isNullOrEmpty()) { + val immuneToLabel = stringResource(R.string.analysis_immune_to) + val groundsHolderLabel = stringResource(R.string.analysis_item_grounds_holder) + val removesImmunitiesLabel = stringResource(R.string.analysis_item_removes_immunities) + val typeNames = PokemonType.entries.associateWith { it.displayName() } + val itemSummary = getItemEffects(member.item)?.joinToString(", ") { effect -> + when (effect) { + is ItemEffect.Immunity -> "$immuneToLabel ${typeNames.getValue(effect.type)}" + ItemEffect.GroundsHolder -> groundsHolderLabel + ItemEffect.RemovesTypeImmunities -> removesImmunitiesLabel + is ItemEffect.ResistBerry -> "×0.5 ${typeNames.getValue(effect.type)}" + } + } + DefRow(stringResource(R.string.slot_item_label)) { + Text(member.item, style = MaterialTheme.typography.bodySmall) + if (!itemSummary.isNullOrEmpty()) { + Text(" — $itemSummary", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.tertiary) + } + } + } if (weak4x.isNotEmpty()) TypeRow(stringResource(R.string.defensive_weaknesses_4x), weak4x) if (weak2x.isNotEmpty()) TypeRow(stringResource(R.string.defensive_weaknesses_2x), weak2x) if (resist05x.isNotEmpty()) TypeRow(stringResource(R.string.defensive_resistances_05x), resist05x) diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 4b0e312..a2bad54 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -82,6 +82,8 @@ Only super-effective moves deal damage super-effective hits never super-effective against this Pokémon + grounds the holder + removes type immunities Pokémon @@ -147,6 +149,7 @@ Type 2 None Ability + Item (hidden) Custom ability… Back to canonical abilities diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8f307e4..1025b8c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -82,6 +82,8 @@ Solo le mosse super-efficaci infliggono danni i colpi super-efficaci mai super-efficace contro questo Pokémon + mette a terra il possessore + rimuove le immunità di tipo Pokémon @@ -147,6 +149,7 @@ Tipo 2 Nessuno Abilità + Strumento (nascosta) Abilità custom… Torna alle abilità canoniche diff --git a/app/src/test/java/com/marcogn/coverdex/data/repository/BackupRepositoryImplTest.kt b/app/src/test/java/com/marcogn/coverdex/data/repository/BackupRepositoryImplTest.kt index 126b749..b370dc7 100644 --- a/app/src/test/java/com/marcogn/coverdex/data/repository/BackupRepositoryImplTest.kt +++ b/app/src/test/java/com/marcogn/coverdex/data/repository/BackupRepositoryImplTest.kt @@ -68,6 +68,17 @@ class BackupRepositoryImplTest { assertEquals(1, customPokemonRepository.roster.first().size) } + @Test + fun `a roster entry's item survives an export-then-import round-trip`() = runTest { + customPokemonRepository.save(pikachu().copy(id = "custom-pikachu", isCustomSaved = true, item = "Light Ball")) + + val payload = backupRepository.exportPayload() + backupRepository.importPayload(payload) + + val restored = customPokemonRepository.roster.first().single() + assertEquals("Light Ball", restored.item) + } + @Test fun `importing a payload fully replaces existing teams, preserving ids`() = runTest { val originalTeamId = teamRepository.createTeam("Original") diff --git a/app/src/test/java/com/marcogn/coverdex/data/repository/TeamMappersTest.kt b/app/src/test/java/com/marcogn/coverdex/data/repository/TeamMappersTest.kt index b82748c..3cdaaa2 100644 --- a/app/src/test/java/com/marcogn/coverdex/data/repository/TeamMappersTest.kt +++ b/app/src/test/java/com/marcogn/coverdex/data/repository/TeamMappersTest.kt @@ -43,6 +43,17 @@ class TeamMappersTest { assertEquals(original, rebuilt) } + @Test + fun `item round-trips through the team member entity`() { + val original = member().copy(item = "Air Balloon") + + val entity = original.toEntity(teamId = "t1", slotIndex = 0) + assertEquals("Air Balloon", entity.item) + + val rebuilt = TeamMemberWithMoves(entity, original.movesToEntities()).toDomain() + assertEquals(original, rebuilt) + } + @Test fun `a single-typed member's entity has a null type2`() { val original = member().copy(types = PokemonType.FIRE to null) @@ -124,4 +135,15 @@ class TeamMappersTest { assertEquals(1, moveEntities.size) assertEquals(original.moves[0]?.name, moveEntities.first().name) } + + @Test + fun `item round-trips through the custom roster entity`() { + val original = member().copy(item = "Chilan Berry") + + val entity = original.toCustomEntity() + assertEquals("Chilan Berry", entity.item) + + val rebuilt = CustomPokemonWithMoves(entity, original.movesToCustomEntities()).toDomain() + assertEquals("Chilan Berry", rebuilt.item) + } } diff --git a/app/src/test/java/com/marcogn/coverdex/domain/backup/BackupPayloadTest.kt b/app/src/test/java/com/marcogn/coverdex/domain/backup/BackupPayloadTest.kt index 4f4811c..bf4976f 100644 --- a/app/src/test/java/com/marcogn/coverdex/domain/backup/BackupPayloadTest.kt +++ b/app/src/test/java/com/marcogn/coverdex/domain/backup/BackupPayloadTest.kt @@ -95,6 +95,47 @@ class BackupPayloadTest { assertThrows(BackupFormatTooNewException::class.java) { json.toBackupPayload() } } + @Test + fun `item round-trips through the backup DTO`() { + val original = buildMember("Landorus", PokemonType.GROUND to PokemonType.FLYING).copy(item = "Air Balloon") + + val roundTripped = original.toBackupDto().toDomain() + + assertEquals("Air Balloon", roundTripped.item) + assertEquals(original, roundTripped) + } + + @Test + fun `a v1 backup file (no item key at all) still decodes, item defaulting to null`() { + // Hand-written, not produced via toJson() — a v1 file genuinely never had this key, + // which is a different case from an explicit "item": null (CLAUDE.md's kotlinx.serialization + // gotcha: a default only fills a MISSING key, not an explicit null). + val v1Json = """ + { + "formatVersion": 1, + "exportedAtEpochMillis": 1700000100000, + "teams": [], + "customPokemon": [ + { + "id": "c1", + "pokedexId": null, + "speciesName": "Custom Mon", + "type1": "dragon", + "type2": "steel", + "ability": null, + "moves": [null, null, null, null], + "isCustomSaved": true + } + ] + } + """.trimIndent() + + val decoded = v1Json.toBackupPayload() + + assertEquals(1, decoded.formatVersion) + assertEquals(null, decoded.customPokemon.single().item) + } + @Test fun `an unresolvable move type falls back to Normal instead of crashing`() { val move = PokemonMove(id = "m1", name = "Weird Move", type = PokemonType.FIRE, power = 40, damageClass = DamageClass.PHYSICAL, isCustom = false) diff --git a/app/src/test/java/com/marcogn/coverdex/domain/coverage/CoverageEngineTest.kt b/app/src/test/java/com/marcogn/coverdex/domain/coverage/CoverageEngineTest.kt index 43f1d18..9d63ed5 100644 --- a/app/src/test/java/com/marcogn/coverdex/domain/coverage/CoverageEngineTest.kt +++ b/app/src/test/java/com/marcogn/coverdex/domain/coverage/CoverageEngineTest.kt @@ -412,6 +412,58 @@ class CoverageEngineTest { assertEquals(0.0, multipliers.getValue(PokemonType.GHOST), 0.0) } + // --- held items (§4, phase-7-accuracy-and-customization.md) --- + + @Test + fun `air-balloon makes the holder immune to Ground regardless of a normal weakness`() { + // Fire is normally 2x weak to Ground in the fixture. + assertEquals(0.0, defensiveMultiplier(chart, PokemonType.GROUND, PokemonType.FIRE to null, item = "air-balloon"), 0.0) + } + + @Test + fun `iron-ball grounds a Flying-type, cancelling the type chart's own Ground immunity`() { + assertEquals(0.0, defensiveMultiplier(chart, PokemonType.GROUND, PokemonType.FLYING to null), 0.0) + assertEquals(1.0, defensiveMultiplier(chart, PokemonType.GROUND, PokemonType.FLYING to null, item = "iron-ball"), 0.0) + } + + @Test + fun `iron-ball also cancels an ability-granted Ground immunity (Levitate)`() { + assertEquals(0.0, defensiveMultiplier(chart, PokemonType.GROUND, PokemonType.GHOST to PokemonType.POISON, ability = "levitate"), 0.0) + assertEquals( + 2.0, + defensiveMultiplier(chart, PokemonType.GROUND, PokemonType.GHOST to PokemonType.POISON, ability = "levitate", item = "iron-ball"), + 0.0, + ) + } + + @Test + fun `ring-target removes a type-chart immunity`() { + assertEquals(0.0, defensiveMultiplier(chart, PokemonType.GROUND, PokemonType.FLYING to null), 0.0) + assertEquals(1.0, defensiveMultiplier(chart, PokemonType.GROUND, PokemonType.FLYING to null, item = "ring-target"), 0.0) + } + + @Test + fun `a resist berry halves an already super-effective hit of its type`() { + // Fire vs Grass is 2x; Occa Berry resists Fire. + assertEquals(1.0, defensiveMultiplier(chart, PokemonType.FIRE, PokemonType.GRASS to null, item = "occa-berry"), 0.0) + } + + @Test + fun `a resist berry does not touch a neutral or resisted hit`() { + assertEquals(1.0, defensiveMultiplier(chart, PokemonType.FIRE, PokemonType.NORMAL to null, item = "occa-berry"), 0.0) + assertEquals(0.5, defensiveMultiplier(chart, PokemonType.FIRE, PokemonType.FIRE to null, item = "occa-berry"), 0.0) + } + + @Test + fun `chilan berry halves Normal damage even though Normal is never super-effective`() { + assertEquals(0.5, defensiveMultiplier(chart, PokemonType.NORMAL, PokemonType.WATER to null, item = "chilan-berry"), 0.0) + } + + @Test + fun `an unmodelled item has no effect`() { + assertEquals(2.0, defensiveMultiplier(chart, PokemonType.FIRE, PokemonType.GRASS to null, item = "leftovers"), 0.0) + } + @Test fun `immunity overrides even when the type chart already shows 0 (motor-drive vs ground)`() { assertEquals(0.0, defensiveMultiplier(chart, PokemonType.ELECTRIC, PokemonType.GROUND to null, "motor-drive"), 0.0) diff --git a/app/src/test/java/com/marcogn/coverdex/domain/item/ItemEffectsTest.kt b/app/src/test/java/com/marcogn/coverdex/domain/item/ItemEffectsTest.kt new file mode 100644 index 0000000..c782f59 --- /dev/null +++ b/app/src/test/java/com/marcogn/coverdex/domain/item/ItemEffectsTest.kt @@ -0,0 +1,58 @@ +package com.marcogn.coverdex.domain.item + +import com.marcogn.coverdex.domain.model.PokemonType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class ItemEffectsTest { + + @Test + fun `itemKey lowercases and strips every symbol`() { + assertEquals("airballoon", itemKey("Air Balloon")) + assertEquals("airballoon", itemKey("air-balloon")) + assertEquals("chilanberry", itemKey("Chilan Berry")) + } + + @Test + fun `getItemEffects returns null for null, empty, or an unmodelled item`() { + assertNull(getItemEffects(null)) + assertNull(getItemEffects("")) + assertNull(getItemEffects("Leftovers")) + assertNull(getItemEffects("Heavy-Duty Boots")) + } + + @Test + fun `getItemEffects is case- and symbol-insensitive`() { + assertEquals(getItemEffects("air-balloon"), getItemEffects("Air Balloon")) + } + + @Test + fun `every ITEM_EFFECTS entry round-trips through getItemEffects`() { + for ((slug, effects) in ITEM_EFFECTS) { + assertEquals(effects, getItemEffects(slug)) + } + } + + @Test + fun `ITEM_EFFECTS has one resist berry per type except Normal, plus Chilan for Normal`() { + val resistBerryTypes = ITEM_EFFECTS.values + .flatten() + .filterIsInstance() + .map { it.type } + .toSet() + assertEquals(PokemonType.entries.toSet(), resistBerryTypes) + + val chilan = getItemEffects("chilan-berry")!!.single() as ItemEffect.ResistBerry + assertEquals(PokemonType.NORMAL, chilan.type) + assertTrue(chilan.alwaysApplies) + } + + @Test + fun `air-balloon, iron-ball and ring-target are each modelled once`() { + assertEquals(listOf(ItemEffect.Immunity(PokemonType.GROUND)), getItemEffects("air-balloon")) + assertEquals(listOf(ItemEffect.GroundsHolder), getItemEffects("iron-ball")) + assertEquals(listOf(ItemEffect.RemovesTypeImmunities), getItemEffects("ring-target")) + } +} diff --git a/app/src/test/java/com/marcogn/coverdex/domain/showdown/ShowdownFormatTest.kt b/app/src/test/java/com/marcogn/coverdex/domain/showdown/ShowdownFormatTest.kt index d16dab4..4d97657 100644 --- a/app/src/test/java/com/marcogn/coverdex/domain/showdown/ShowdownFormatTest.kt +++ b/app/src/test/java/com/marcogn/coverdex/domain/showdown/ShowdownFormatTest.kt @@ -96,6 +96,7 @@ class ShowdownFormatTest { assertEquals(PokemonType.FIRE to PokemonType.FLYING, imp.member.types) assertEquals(4, imp.member.moves.filterNotNull().size) assertEquals(emptyList(), imp.unknownMoveNames) + assertEquals("Charcoal", imp.member.item) } @Test @@ -236,4 +237,49 @@ class ShowdownFormatTest { val imp = parseShowdownBlock(paste, ::resolveMove, ::resolveSpecies) assertNull(imp.member.ability) } + + // ---- item handling (Phase 7 — phase-7-accuracy-and-customization.md §4.3) ---- + + @Test + fun `parses the item from the species line's @ suffix`() { + val paste = listOf("Charizard @ Air Balloon", "Ability: Blaze", "- Flamethrower").joinToString("\n") + val imp = parseShowdownBlock(paste, ::resolveMove, ::resolveSpecies) + assertEquals("Air Balloon", imp.member.item) + } + + @Test + fun `exports Species @ Item when an item is set`() { + val m = buildMember("Charizard", PokemonType.FIRE to PokemonType.FLYING).copy(item = "Charcoal") + val out = exportMemberToShowdown(m) + assertTrue(out.lines().first() == "Charizard @ Charcoal") + } + + @Test + fun `exports the bare @ line when no item is set, same as before Phase 7`() { + val m = buildMember("Charizard", PokemonType.FIRE to PokemonType.FLYING) + val out = exportMemberToShowdown(m) + assertTrue(out.lines().first() == "Charizard @ ") + } + + @Test + fun `round-trip export with an item then re-import preserves it`() { + val original = buildMember("Charizard", PokemonType.FIRE to PokemonType.FLYING).copy(item = "Life Orb") + val text = exportMemberToShowdown(original) + val imp = parseShowdownBlock(text, ::resolveMove, ::resolveSpecies) + assertEquals("Life Orb", imp.member.item) + } + + @Test + fun `no @ on the species line at all, member item is null`() { + val paste = listOf("Pikachu", "- Thunderbolt").joinToString("\n") + val imp = parseShowdownBlock(paste, ::resolveMove, ::resolveSpecies) + assertNull(imp.member.item) + } + + @Test + fun `empty item value after @, member item is null`() { + val paste = listOf("Pikachu @ ", "- Thunderbolt").joinToString("\n") + val imp = parseShowdownBlock(paste, ::resolveMove, ::resolveSpecies) + assertNull(imp.member.item) + } } diff --git a/app/src/test/java/com/marcogn/coverdex/domain/suggestion/ScoringTest.kt b/app/src/test/java/com/marcogn/coverdex/domain/suggestion/ScoringTest.kt index 2ed94fd..e1f11e6 100644 --- a/app/src/test/java/com/marcogn/coverdex/domain/suggestion/ScoringTest.kt +++ b/app/src/test/java/com/marcogn/coverdex/domain/suggestion/ScoringTest.kt @@ -33,6 +33,14 @@ class ScoringTest { assertFalse(withLevitate.contains(PokemonType.GROUND)) } + @Test + fun `weaknesses honours a held item too, not just the ability (Phase 7 item threading)`() { + val withoutItem = weaknesses(chart, PokemonType.ELECTRIC to null) + val withAirBalloon = weaknesses(chart, PokemonType.ELECTRIC to null, item = "air-balloon") + assertTrue(withoutItem.contains(PokemonType.GROUND)) + assertFalse(withAirBalloon.contains(PokemonType.GROUND)) + } + @Test fun `weaknesses with an unknown ability behaves exactly like no ability`() { val withoutAbility = weaknesses(chart, PokemonType.ELECTRIC to null) From 5a5733c27ea74f6ddcadca599eacf43f96cc85e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 11:49:06 +0000 Subject: [PATCH 4/7] Phase 7: BST-aware suggestion ranking + configurable suggestion count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements docs/plan/phase-7-accuracy-and-customization.md §5 and §6 — steps 6-7 of the task order (§9). Docs, the exhaustive type-chart/dual- typing test sweep, and a broader ability/item test pass are still follow-up commits on this same PR. Suggestion ranking (§5): - Suggestion.baseStatTotal (null for a custom candidate); the ranking comparator gains a single new tie-break step — bestScore, then isFinal, then baseStatTotal descending, then catalogue id ascending. The composite-score formula and its 0.5/1.0 weights are untouched, so ScoringTest/TeamGeneratorTest keep passing unchanged. - domain/model/PastBst.kt: bstResolverFor(pastBst, generation) — null means the current, latest-generation value; a real generation resolves to the smallest stored historical breakpoint at or after it (proven correct in the plan's own §2.2, and exercised here against every case: no history, before/at/after the only breakpoint, strictly between two independent breakpoints, and no cross-contamination between forms). AnalysisViewModel now fetches allPastBst() alongside the chart/pool and passes a generation-aware resolver into computeSuggestions. - SuggestionCard shows the base stat total and a plain-language score hint ("coverage gained minus weaknesses introduced"). - SuggestionEngine.findEntry's per-candidate linear scan over the ~1351- entry pool (up to ~1.8M string comparisons per recomputation) replaced with two maps built once per computeSuggestions call, same displayName- first precedence. - The old "secondary sort ... ascending catalogue id" test replaced with two regression tests against a hand-built pool: a higher baseStatTotal now outranks a lower one on a composite-score tie (reproducing the reported Raticate/Persian/Kangaskhan case), and id still decides when baseStatTotal also ties. Suggestion count (§6): - SettingsPreferences.suggestionCount, clamped to 5-10 on both read and write, default 5 (matching the value Phase 4 hardcoded). - ui/common/StepperCounter.kt: the -/+ row promoted out of ui/surprise/SurpriseMeScreen.kt's private ConstraintCounter (0-floor, no shared ceiling) into a shared composable with explicit canDecrement/canIncrement bounds; Surprise Me's five constraint rows and the new Settings row both use it now. - AnalysisUiState.suggestionCount flows from the setting through to AnalysisScreen's take(state.suggestionCount), replacing the hardcoded take(5); AnalysisUiState.suggestions itself stays unsliced. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011j5jieoE4nqU2ZBSM7USHc --- .../data/settings/SettingsPreferences.kt | 21 ++++++ .../marcogn/coverdex/domain/model/PastBst.kt | 23 ++++++ .../domain/suggestion/SuggestionEngine.kt | 39 ++++++++-- .../coverdex/ui/common/StepperCounter.kt | 42 +++++++++++ .../coverdex/ui/settings/SettingsScreen.kt | 12 ++++ .../coverdex/ui/settings/SettingsViewModel.kt | 16 ++++- .../coverdex/ui/surprise/SurpriseMeScreen.kt | 45 +++++------- .../ui/team/analysis/AnalysisScreen.kt | 2 +- .../ui/team/analysis/AnalysisUiState.kt | 9 ++- .../ui/team/analysis/AnalysisViewModel.kt | 12 +++- .../ui/team/analysis/SuggestionCard.kt | 13 ++++ app/src/main/res/values-en/strings.xml | 3 + app/src/main/res/values/strings.xml | 3 + .../data/settings/SettingsPreferencesTest.kt | 32 +++++++++ .../coverdex/domain/model/PastBstTest.kt | 72 +++++++++++++++++++ .../domain/suggestion/SuggestionEngineTest.kt | 60 +++++++++++----- .../ui/team/analysis/AnalysisViewModelTest.kt | 14 ++++ 17 files changed, 359 insertions(+), 59 deletions(-) create mode 100644 app/src/main/java/com/marcogn/coverdex/ui/common/StepperCounter.kt create mode 100644 app/src/test/java/com/marcogn/coverdex/domain/model/PastBstTest.kt diff --git a/app/src/main/java/com/marcogn/coverdex/data/settings/SettingsPreferences.kt b/app/src/main/java/com/marcogn/coverdex/data/settings/SettingsPreferences.kt index a10a092..70e469e 100644 --- a/app/src/main/java/com/marcogn/coverdex/data/settings/SettingsPreferences.kt +++ b/app/src/main/java/com/marcogn/coverdex/data/settings/SettingsPreferences.kt @@ -5,6 +5,7 @@ import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import com.marcogn.coverdex.domain.model.ThemeMode @@ -23,6 +24,11 @@ internal val SHOW_MOVES_KEY = booleanPreferencesKey("show_moves") internal val INCLUDE_MEGA_DYNAMAX_KEY = booleanPreferencesKey("include_mega_dynamax") internal val EXCLUDE_LEGENDARIES_KEY = booleanPreferencesKey("exclude_legendaries") internal val INCLUDE_CUSTOMS_ANALYSIS_KEY = booleanPreferencesKey("include_customs_analysis") +internal val SUGGESTION_COUNT_KEY = intPreferencesKey("suggestion_count") + +internal const val MIN_SUGGESTION_COUNT = 5 +internal const val MAX_SUGGESTION_COUNT = 10 +internal const val DEFAULT_SUGGESTION_COUNT = MIN_SUGGESTION_COUNT /** * Every app-wide setting, persisted with Preferences DataStore, each observable as a [Flow] — @@ -86,4 +92,19 @@ class SettingsPreferences @Inject constructor(@ApplicationContext private val co suspend fun setIncludeCustomsAnalysis(enabled: Boolean) { context.settingsDataStore.edit { preferences -> preferences[INCLUDE_CUSTOMS_ANALYSIS_KEY] = enabled } } + + /** How many ranked suggestions the Analysis tab's seventh section shows — Phase 7, see + * docs/plan/phase-7-accuracy-and-customization.md §6. Clamped to + * [MIN_SUGGESTION_COUNT]..[MAX_SUGGESTION_COUNT] on read too, not just on write: a + * hand-edited or pre-Phase-7-incompatible store must degrade to a valid value, never crash or + * render an unbounded list. */ + val suggestionCount: Flow = context.settingsDataStore.data.map { preferences -> + (preferences[SUGGESTION_COUNT_KEY] ?: DEFAULT_SUGGESTION_COUNT).coerceIn(MIN_SUGGESTION_COUNT, MAX_SUGGESTION_COUNT) + } + + suspend fun setSuggestionCount(count: Int) { + context.settingsDataStore.edit { preferences -> + preferences[SUGGESTION_COUNT_KEY] = count.coerceIn(MIN_SUGGESTION_COUNT, MAX_SUGGESTION_COUNT) + } + } } diff --git a/app/src/main/java/com/marcogn/coverdex/domain/model/PastBst.kt b/app/src/main/java/com/marcogn/coverdex/domain/model/PastBst.kt index 43594fd..369dbfa 100644 --- a/app/src/main/java/com/marcogn/coverdex/domain/model/PastBst.kt +++ b/app/src/main/java/com/marcogn/coverdex/domain/model/PastBst.kt @@ -15,3 +15,26 @@ data class PastBst( val generationId: Int, val bst: Int, ) + +/** + * Resolves a form's base stat total as of [generation] from the small set of historical + * breakpoints in [pastBst] — `null` means "all generations", i.e. the current, latest-generation + * value ([PokemonEntry.baseStatTotal]). For a real generation number, the answer is the BST at + * the *smallest stored breakpoint >= [generation]*, or the current value if none exists: each + * per-stat historical override is itself a step function that only changes at its own + * breakpoints, so the combined total is provably constant between consecutive stored breakpoints + * — the smallest one at or after [generation] always carries the correct value for every + * generation in that interval. See docs/plan/phase-7-accuracy-and-customization.md §2.2/§5.2 for + * the full derivation. + * + * Returns a resolver function rather than a single value so a suggestion pool of ~1351 entries is + * resolved without re-filtering [pastBst] once per entry. + */ +fun bstResolverFor(pastBst: List, generation: Int?): (PokemonEntry) -> Int? { + if (generation == null) return { it.baseStatTotal } + val applicableByPokemonId: Map = pastBst + .filter { it.generationId >= generation } + .groupBy { it.pokemonId } + .mapValues { (_, rows) -> rows.minBy { it.generationId } } + return { entry -> applicableByPokemonId[entry.id]?.bst ?: entry.baseStatTotal } +} diff --git a/app/src/main/java/com/marcogn/coverdex/domain/suggestion/SuggestionEngine.kt b/app/src/main/java/com/marcogn/coverdex/domain/suggestion/SuggestionEngine.kt index 39ac85d..1f64392 100644 --- a/app/src/main/java/com/marcogn/coverdex/domain/suggestion/SuggestionEngine.kt +++ b/app/src/main/java/com/marcogn/coverdex/domain/suggestion/SuggestionEngine.kt @@ -34,6 +34,10 @@ data class Suggestion( val newWeaknesses: List, val aggravatedWeaknesses: List, val aggravatedMembers: Map>, + /** `null` for a custom candidate (no catalogue entry to look one up on) — see + * docs/plan/phase-7-accuracy-and-customization.md §5. Ranking's tie-break only; never the + * primary sort. */ + val baseStatTotal: Int? = null, ) { enum class Kind { ADD, REPLACE } } @@ -63,8 +67,18 @@ fun memberFromEntry(e: PokemonEntry): TeamMember = TeamMember( isCustomSaved = false, ) -private fun findEntry(pool: List, speciesName: String): PokemonEntry? = - pool.find { it.displayName == speciesName || it.name == speciesName.lowercase() } +/** Looks a candidate/member up in [pool] by species name — displayName match first, then a + * lowercased raw identifier match, same precedence `pool.find { it.displayName == speciesName || + * it.name == speciesName.lowercase() }` had. Built once per [computeSuggestions] call instead of + * scanned linearly per candidate: with the full ~1351-entry pool this was on the order of 1.8M + * string comparisons per recomputation (once per candidate, plus once per team member for the + * legendary filter) — see docs/plan/phase-7-accuracy-and-customization.md §0.7/§5.4. */ +private class EntryLookup(pool: List) { + private val byDisplayName: Map = buildMap { pool.forEach { putIfAbsent(it.displayName, it) } } + private val byName: Map = buildMap { pool.forEach { putIfAbsent(it.name, it) } } + + fun find(speciesName: String): PokemonEntry? = byDisplayName[speciesName] ?: byName[speciesName.lowercase()] +} private data class RankedCandidate( val candidate: TeamMember, @@ -73,10 +87,12 @@ private data class RankedCandidate( val replaceMember: TeamMember?, val isFinal: Boolean, val entryId: Int, + val baseStatTotal: Int?, ) private val rankingComparator = compareByDescending { it.bestScore } .thenByDescending { it.isFinal } + .thenByDescending { it.baseStatTotal ?: -1 } .thenBy { it.entryId } /** @@ -92,9 +108,18 @@ fun computeSuggestions( pool: List, customs: List, options: SuggestionOptions, + /** Resolves a catalogue entry's base stat total for the ranking's tie-break — defaults to its + * current, latest-generation value ([PokemonEntry.baseStatTotal]); a caller building the + * pool for a specific `options.generation` passes a generation-aware resolver instead. See + * docs/plan/phase-7-accuracy-and-customization.md §5.2. Domain code stays Room-free: the + * caller resolves historical BST ahead of time and hands in this closure, same pattern as + * `domain/showdown/ShowdownFormat.kt`'s `resolveMove`/`resolveSpecies`. */ + bstFor: (PokemonEntry) -> Int? = { it.baseStatTotal }, ): List { if (pool.isEmpty() && (!options.includeCustoms || customs.isEmpty())) return emptyList() + val entryLookup = EntryLookup(pool) + var filtered = pool.filter { it.isFinalEvolution } if (options.generation != null) { @@ -103,7 +128,7 @@ fun computeSuggestions( if (options.excludeLegendaries) { val teamHasLegendary = members.any { m -> - val entry = findEntry(pool, m.speciesName) + val entry = entryLookup.find(m.speciesName) entry != null && (entry.isLegendary || entry.isMythical) } if (!teamHasLegendary) { @@ -129,7 +154,7 @@ fun computeSuggestions( val context = teamScoringContext(chart, members) deduped.map { cand -> val result = computeCompositeScore(chart, cand, context, teamAnalysis.unionCovered) - val entry = findEntry(pool, cand.speciesName) + val entry = entryLookup.find(cand.speciesName) RankedCandidate( candidate = cand, result = result, @@ -137,6 +162,7 @@ fun computeSuggestions( replaceMember = null, isFinal = entry?.isFinalEvolution ?: false, entryId = entry?.id ?: Int.MAX_VALUE, + baseStatTotal = entry?.let(bstFor), ) } } else { @@ -157,7 +183,7 @@ fun computeSuggestions( bestResult = result } } - val entry = findEntry(pool, cand.speciesName) + val entry = entryLookup.find(cand.speciesName) RankedCandidate( candidate = cand, result = bestResult, @@ -165,6 +191,7 @@ fun computeSuggestions( replaceMember = bestMember, isFinal = entry?.isFinalEvolution ?: false, entryId = entry?.id ?: Int.MAX_VALUE, + baseStatTotal = entry?.let(bstFor), ) } } @@ -184,6 +211,7 @@ fun computeSuggestions( newWeaknesses = r.result.newWeaknesses, aggravatedWeaknesses = r.result.aggravatedWeaknesses, aggravatedMembers = r.result.aggravatedMembers, + baseStatTotal = r.baseStatTotal, ) } } else { @@ -201,6 +229,7 @@ fun computeSuggestions( newWeaknesses = r.result.newWeaknesses, aggravatedWeaknesses = r.result.aggravatedWeaknesses, aggravatedMembers = r.result.aggravatedMembers, + baseStatTotal = r.baseStatTotal, ) } } diff --git a/app/src/main/java/com/marcogn/coverdex/ui/common/StepperCounter.kt b/app/src/main/java/com/marcogn/coverdex/ui/common/StepperCounter.kt new file mode 100644 index 0000000..b683a93 --- /dev/null +++ b/app/src/main/java/com/marcogn/coverdex/ui/common/StepperCounter.kt @@ -0,0 +1,42 @@ +package com.marcogn.coverdex.ui.common + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +/** + * A labelled `−`/count/`+` row — Surprise Me's own constraint counters (0-floor, no ceiling + * beyond the team's remaining budget) and Settings' suggestion-count stepper (5-10, Phase 7 — + * see docs/plan/phase-7-accuracy-and-customization.md §6) share this one composable rather than + * each keeping its own copy. [canDecrement]/[canIncrement] are the caller's own bounds check, not + * assumed here — the two callers have different floors/ceilings. + */ +@Composable +fun StepperCounter( + label: String, + value: Int, + canDecrement: Boolean, + canIncrement: Boolean, + onDecrement: () -> Unit, + onIncrement: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth().padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f)) + IconButton(onClick = onDecrement, enabled = canDecrement) { Text("−") } + Text(value.toString(), modifier = Modifier.width(24.dp), textAlign = TextAlign.Center) + IconButton(onClick = onIncrement, enabled = canIncrement) { Text("+") } + } +} diff --git a/app/src/main/java/com/marcogn/coverdex/ui/settings/SettingsScreen.kt b/app/src/main/java/com/marcogn/coverdex/ui/settings/SettingsScreen.kt index 64874f2..b7610e1 100644 --- a/app/src/main/java/com/marcogn/coverdex/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/marcogn/coverdex/ui/settings/SettingsScreen.kt @@ -28,8 +28,11 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.marcogn.coverdex.BuildConfig import com.marcogn.coverdex.R +import com.marcogn.coverdex.data.settings.MAX_SUGGESTION_COUNT +import com.marcogn.coverdex.data.settings.MIN_SUGGESTION_COUNT import com.marcogn.coverdex.domain.model.ThemeMode import com.marcogn.coverdex.ui.common.CoverDexTopBar +import com.marcogn.coverdex.ui.common.StepperCounter import com.marcogn.coverdex.ui.theme.ThemeViewModel @Composable @@ -60,6 +63,15 @@ fun SettingsScreen( checked = uiState.includeLegendaries, onCheckedChange = settingsViewModel::setIncludeLegendaries, ) + StepperCounter( + label = stringResource(R.string.settings_suggestion_count), + value = uiState.suggestionCount, + canDecrement = uiState.suggestionCount > MIN_SUGGESTION_COUNT, + canIncrement = uiState.suggestionCount < MAX_SUGGESTION_COUNT, + onDecrement = { settingsViewModel.setSuggestionCount(uiState.suggestionCount - 1) }, + onIncrement = { settingsViewModel.setSuggestionCount(uiState.suggestionCount + 1) }, + modifier = Modifier.padding(horizontal = 16.dp), + ) } SettingsSection(title = stringResource(R.string.settings_section_appearance)) { ThemeOption(ThemeMode.SYSTEM, stringResource(R.string.settings_theme_system), themeMode, themeViewModel::setThemeMode) diff --git a/app/src/main/java/com/marcogn/coverdex/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/marcogn/coverdex/ui/settings/SettingsViewModel.kt index d6566f7..be50e75 100644 --- a/app/src/main/java/com/marcogn/coverdex/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/marcogn/coverdex/ui/settings/SettingsViewModel.kt @@ -30,6 +30,9 @@ data class SettingsUiState( /** Set only on a failed export/import — cleared on the next attempt. Never a success message: * a successful restore is visible immediately in the Teams/Roster lists it just replaced. */ val backupMessage: String? = null, + /** How many ranked suggestions the Analysis tab shows, 5-10 — Phase 7, see + * docs/plan/phase-7-accuracy-and-customization.md §6. */ + val suggestionCount: Int = 5, ) @HiltViewModel @@ -44,10 +47,14 @@ class SettingsViewModel @Inject constructor( val uiState: StateFlow = combine( combine(pokedexRepository.cacheStatus, pokedexRepository.syncState) { cacheStatus, syncState -> cacheStatus to syncState }, - combine(settingsPreferences.includeMegaDynamax, settingsPreferences.excludeLegendaries) { includeMega, excludeLegendaries -> includeMega to excludeLegendaries }, + combine( + settingsPreferences.includeMegaDynamax, + settingsPreferences.excludeLegendaries, + settingsPreferences.suggestionCount, + ) { includeMega, excludeLegendaries, suggestionCount -> Triple(includeMega, excludeLegendaries, suggestionCount) }, backupBusy, backupMessage, - ) { (cacheStatus, syncState), (includeMegaDynamax, excludeLegendaries), busy, message -> + ) { (cacheStatus, syncState), (includeMegaDynamax, excludeLegendaries, suggestionCount), busy, message -> SettingsUiState( cacheStatus = cacheStatus, syncState = syncState, @@ -55,6 +62,7 @@ class SettingsViewModel @Inject constructor( includeLegendaries = !excludeLegendaries, backupBusy = busy, backupMessage = message, + suggestionCount = suggestionCount, ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsUiState()) @@ -72,6 +80,10 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { settingsPreferences.setExcludeLegendaries(!enabled) } } + fun setSuggestionCount(count: Int) { + viewModelScope.launch { settingsPreferences.setSuggestionCount(count) } + } + fun exportBackup(destination: Uri) { viewModelScope.launch { backupBusy.value = true diff --git a/app/src/main/java/com/marcogn/coverdex/ui/surprise/SurpriseMeScreen.kt b/app/src/main/java/com/marcogn/coverdex/ui/surprise/SurpriseMeScreen.kt index 9b50575..c2cb23c 100644 --- a/app/src/main/java/com/marcogn/coverdex/ui/surprise/SurpriseMeScreen.kt +++ b/app/src/main/java/com/marcogn/coverdex/ui/surprise/SurpriseMeScreen.kt @@ -44,6 +44,7 @@ import com.marcogn.coverdex.ui.common.CoverDexTopBar import com.marcogn.coverdex.ui.common.DropdownOption import com.marcogn.coverdex.ui.common.PokemonSprite import com.marcogn.coverdex.ui.common.SearchableDropdown +import com.marcogn.coverdex.ui.common.StepperCounter import com.marcogn.coverdex.ui.common.TypeBadge import com.marcogn.coverdex.ui.teams.TeamNameDialog @@ -123,41 +124,46 @@ fun SurpriseMeScreen( stringResource(R.string.surprise_me_remaining_slots, state.remainingSlots), style = MaterialTheme.typography.titleSmall, ) - ConstraintCounter( + StepperCounter( label = stringResource(R.string.surprise_me_starters), value = state.constraints.starterSlots, + canDecrement = state.constraints.starterSlots > 0, canIncrement = !state.budgetFull, - onIncrement = { viewModel.updateConstraints { it.copy(starterSlots = it.starterSlots + 1) } }, onDecrement = { viewModel.updateConstraints { it.copy(starterSlots = (it.starterSlots - 1).coerceAtLeast(0)) } }, + onIncrement = { viewModel.updateConstraints { it.copy(starterSlots = it.starterSlots + 1) } }, ) - ConstraintCounter( + StepperCounter( label = stringResource(R.string.surprise_me_legendaries_mythicals), value = state.constraints.legendaryMythicalSlots, + canDecrement = state.constraints.legendaryMythicalSlots > 0, canIncrement = !state.budgetFull, - onIncrement = { viewModel.updateConstraints { it.copy(legendaryMythicalSlots = it.legendaryMythicalSlots + 1) } }, onDecrement = { viewModel.updateConstraints { it.copy(legendaryMythicalSlots = (it.legendaryMythicalSlots - 1).coerceAtLeast(0)) } }, + onIncrement = { viewModel.updateConstraints { it.copy(legendaryMythicalSlots = it.legendaryMythicalSlots + 1) } }, ) - ConstraintCounter( + StepperCounter( label = stringResource(R.string.surprise_me_mega_evolutions), value = state.constraints.megaSlots, + canDecrement = state.constraints.megaSlots > 0, canIncrement = !state.budgetFull, - onIncrement = { viewModel.updateConstraints { it.copy(megaSlots = it.megaSlots + 1) } }, onDecrement = { viewModel.updateConstraints { it.copy(megaSlots = (it.megaSlots - 1).coerceAtLeast(0)) } }, + onIncrement = { viewModel.updateConstraints { it.copy(megaSlots = it.megaSlots + 1) } }, ) - ConstraintCounter( + StepperCounter( label = stringResource(R.string.surprise_me_dynamax_gmax), value = state.constraints.dynamaxSlots, + canDecrement = state.constraints.dynamaxSlots > 0, canIncrement = !state.budgetFull, - onIncrement = { viewModel.updateConstraints { it.copy(dynamaxSlots = it.dynamaxSlots + 1) } }, onDecrement = { viewModel.updateConstraints { it.copy(dynamaxSlots = (it.dynamaxSlots - 1).coerceAtLeast(0)) } }, + onIncrement = { viewModel.updateConstraints { it.copy(dynamaxSlots = it.dynamaxSlots + 1) } }, ) if (state.customs.isNotEmpty()) { - ConstraintCounter( + StepperCounter( label = stringResource(R.string.surprise_me_custom_pokemon), value = state.constraints.customSlots, + canDecrement = state.constraints.customSlots > 0, canIncrement = !state.budgetFull, - onIncrement = { viewModel.updateConstraints { it.copy(customSlots = it.customSlots + 1) } }, onDecrement = { viewModel.updateConstraints { it.copy(customSlots = (it.customSlots - 1).coerceAtLeast(0)) } }, + onIncrement = { viewModel.updateConstraints { it.copy(customSlots = it.customSlots + 1) } }, ) } } @@ -243,25 +249,6 @@ private fun RemoveAnchorIcon() { androidx.compose.material3.Icon(Icons.Default.Close, contentDescription = null, modifier = Modifier.size(16.dp)) } -@Composable -private fun ConstraintCounter( - label: String, - value: Int, - canIncrement: Boolean, - onIncrement: () -> Unit, - onDecrement: () -> Unit, -) { - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f)) - IconButton(onClick = onDecrement, enabled = value > 0) { Text("−") } - Text(value.toString(), modifier = Modifier.width(24.dp), textAlign = TextAlign.Center) - IconButton(onClick = onIncrement, enabled = canIncrement) { Text("+") } - } -} - @Composable private fun ResultSlotCard( speciesName: String, diff --git a/app/src/main/java/com/marcogn/coverdex/ui/team/analysis/AnalysisScreen.kt b/app/src/main/java/com/marcogn/coverdex/ui/team/analysis/AnalysisScreen.kt index c70d7c5..27e05a1 100644 --- a/app/src/main/java/com/marcogn/coverdex/ui/team/analysis/AnalysisScreen.kt +++ b/app/src/main/java/com/marcogn/coverdex/ui/team/analysis/AnalysisScreen.kt @@ -151,7 +151,7 @@ fun AnalysisScreen(modifier: Modifier = Modifier, viewModel: AnalysisViewModel = color = MaterialTheme.colorScheme.onSurfaceVariant, ) } else { - val displayed = state.suggestions.take(5) + val displayed = state.suggestions.take(state.suggestionCount) Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { if (displayed.all { it.gain == 0 }) { Text( diff --git a/app/src/main/java/com/marcogn/coverdex/ui/team/analysis/AnalysisUiState.kt b/app/src/main/java/com/marcogn/coverdex/ui/team/analysis/AnalysisUiState.kt index 63289fe..8fe06be 100644 --- a/app/src/main/java/com/marcogn/coverdex/ui/team/analysis/AnalysisUiState.kt +++ b/app/src/main/java/com/marcogn/coverdex/ui/team/analysis/AnalysisUiState.kt @@ -18,8 +18,10 @@ data class AnalysisUiState( val showMoves: Boolean = false, val roster: List = emptyList(), /** Every ranked suggestion from `computeSuggestions` — the screen displays only the first - * five (`phase-4-suggestions-and-generator.md` §4), kept unsliced here so a future caller - * (e.g. a "show more" affordance) is not blocked on a state-shape change. */ + * [suggestionCount] (5-10, configurable as of Phase 7; see + * `docs/plan/phase-7-accuracy-and-customization.md` §6 — Phase 4 hardcoded 5), kept unsliced + * here so a future caller (e.g. a "show more" affordance) is not blocked on a state-shape + * change. */ val suggestions: List = emptyList(), /** Backed by `SettingsPreferences.includeCustomsAnalysis` (Phase 5) — persisted, but still * toggled right from this screen's `SuggestionFilters`, same as `showMoves`. */ @@ -28,6 +30,9 @@ data class AnalysisUiState( * `domain/suggestion/SuggestionEngine.kt`'s doc comment for why this is a number, not the * TypeScript's id-range key. */ val generationFilter: Int? = null, + /** How many of [suggestions] the screen actually shows — backed by + * `SettingsPreferences.suggestionCount` (Phase 7). */ + val suggestionCount: Int = 5, ) { val canAnalyse: Boolean get() = members.isNotEmpty() } diff --git a/app/src/main/java/com/marcogn/coverdex/ui/team/analysis/AnalysisViewModel.kt b/app/src/main/java/com/marcogn/coverdex/ui/team/analysis/AnalysisViewModel.kt index afd0b97..15fbf7b 100644 --- a/app/src/main/java/com/marcogn/coverdex/ui/team/analysis/AnalysisViewModel.kt +++ b/app/src/main/java/com/marcogn/coverdex/ui/team/analysis/AnalysisViewModel.kt @@ -7,8 +7,10 @@ import androidx.navigation.toRoute import com.marcogn.coverdex.data.settings.SettingsPreferences import com.marcogn.coverdex.domain.coverage.analyseTeam import com.marcogn.coverdex.domain.coverage.sharedWeaknessCounts +import com.marcogn.coverdex.domain.model.PastBst import com.marcogn.coverdex.domain.model.PokemonEntry import com.marcogn.coverdex.domain.model.Team +import com.marcogn.coverdex.domain.model.bstResolverFor import com.marcogn.coverdex.domain.model.TeamMember import com.marcogn.coverdex.domain.model.TypeChart import com.marcogn.coverdex.domain.repository.CustomPokemonRepository @@ -47,7 +49,7 @@ private val MEGA_DYNAMAX_FORM_REGEX = Regex("-mega|-gmax|-dynamax|-mega-x|-mega- * what `TeamDetailPage.tsx`'s `analysisMembers` memo does, so the engine uniformly falls back to * type-based coverage. */ -private data class DatasetCore(val chart: TypeChart?, val pool: List) +private data class DatasetCore(val chart: TypeChart?, val pool: List, val pastBst: List) private data class CoreData( val team: Team?, @@ -73,7 +75,8 @@ class AnalysisViewModel @Inject constructor( private val datasetCore = combine( pokedexRepository.cacheStatus.map { status -> if (status.isUsable) pokedexRepository.typeChart() else null }, pokedexRepository.cacheStatus.map { status -> if (status.isUsable) pokedexRepository.allSpecies() else emptyList() }, - ) { chart, pool -> DatasetCore(chart, pool) } + pokedexRepository.cacheStatus.map { status -> if (status.isUsable) pokedexRepository.allPastBst() else emptyList() }, + ) { chart, pool, pastBst -> DatasetCore(chart, pool, pastBst) } private val core = combine( teamRepository.team(teamId), @@ -88,7 +91,8 @@ class AnalysisViewModel @Inject constructor( settingsPreferences.includeCustomsAnalysis, settingsPreferences.excludeLegendaries, generationFilter, - ) { core, includeCustoms, excludeLegendaries, genFilter -> + settingsPreferences.suggestionCount, + ) { core, includeCustoms, excludeLegendaries, genFilter, suggestionCount -> val filled = core.team?.members?.filterNotNull() ?: emptyList() val members = if (core.showMoves) filled else filled.map { it.copy(moves = List(4) { null }) } val coverage = core.dataset.chart?.let { analyseTeam(it, members) } @@ -115,6 +119,7 @@ class AnalysisViewModel @Inject constructor( excludeLegendaries = excludeLegendaries, generation = genFilter, ), + bstFor = bstResolverFor(core.dataset.pastBst, genFilter), ) } ?: emptyList() @@ -128,6 +133,7 @@ class AnalysisViewModel @Inject constructor( suggestions = suggestions, includeCustomsAnalysis = includeCustoms, generationFilter = genFilter, + suggestionCount = suggestionCount, ) } // analyseTeam/computeSuggestions/sharedWeaknessCounts are real work against a catalogue diff --git a/app/src/main/java/com/marcogn/coverdex/ui/team/analysis/SuggestionCard.kt b/app/src/main/java/com/marcogn/coverdex/ui/team/analysis/SuggestionCard.kt index 6662bf3..45dfbe8 100644 --- a/app/src/main/java/com/marcogn/coverdex/ui/team/analysis/SuggestionCard.kt +++ b/app/src/main/java/com/marcogn/coverdex/ui/team/analysis/SuggestionCard.kt @@ -68,6 +68,19 @@ fun SuggestionCard(suggestion: Suggestion, onApply: (Suggestion) -> Unit, modifi style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) + Text( + stringResource(R.string.suggestions_score_hint), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + suggestion.baseStatTotal?.let { bst -> + Text( + stringResource(R.string.suggestions_base_stat_total, bst), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } if (suggestion.newlyCovered.isNotEmpty()) { TypeRow(label = stringResource(R.string.suggestions_covers), types = suggestion.newlyCovered) diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index a2bad54..de1d550 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -100,6 +100,8 @@ already weak: %1$s No new shared weaknesses introduced Score: %1$s + Coverage gained minus weaknesses introduced + Base stat total: %1$d Surprise Me 🎲 @@ -176,6 +178,7 @@ Team Suggestions Include Mega/Dynamax/Gigantamax forms Include Legendary & Mythical Pokémon + Number of suggestions shown Appearance System default Light diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1025b8c..5a0d847 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -100,6 +100,8 @@ già debole: %1$s Nessuna nuova debolezza condivisa introdotta Punteggio: %1$s + Copertura guadagnata meno debolezze introdotte + Statistiche totali: %1$d Sorprendimi 🎲 @@ -176,6 +178,7 @@ Suggerimenti Team Includi forme Mega/Dynamax/Gigantamax Includi Leggendari e Mitici + Numero di suggerimenti mostrati Aspetto Predefinito sistema Chiaro diff --git a/app/src/test/java/com/marcogn/coverdex/data/settings/SettingsPreferencesTest.kt b/app/src/test/java/com/marcogn/coverdex/data/settings/SettingsPreferencesTest.kt index 1e93624..bf07a35 100644 --- a/app/src/test/java/com/marcogn/coverdex/data/settings/SettingsPreferencesTest.kt +++ b/app/src/test/java/com/marcogn/coverdex/data/settings/SettingsPreferencesTest.kt @@ -86,4 +86,36 @@ class SettingsPreferencesTest { assertEquals(true, preferences.includeCustomsAnalysis.first()) } + + @Test + fun `suggestionCount defaults to 5 and round-trips within bounds`() = runTest { + assertEquals(5, preferences.suggestionCount.first()) + + preferences.setSuggestionCount(8) + + assertEquals(8, preferences.suggestionCount.first()) + } + + @Test + fun `setSuggestionCount clamps a value below the minimum on write`() = runTest { + preferences.setSuggestionCount(1) + + assertEquals(5, preferences.suggestionCount.first()) + } + + @Test + fun `setSuggestionCount clamps a value above the maximum on write`() = runTest { + preferences.setSuggestionCount(999) + + assertEquals(10, preferences.suggestionCount.first()) + } + + @Test + fun `a hand-edited out-of-range stored value is clamped on read too`() = runTest { + context.settingsDataStore.edit { it[SUGGESTION_COUNT_KEY] = 0 } + assertEquals(5, preferences.suggestionCount.first()) + + context.settingsDataStore.edit { it[SUGGESTION_COUNT_KEY] = 42 } + assertEquals(10, preferences.suggestionCount.first()) + } } diff --git a/app/src/test/java/com/marcogn/coverdex/domain/model/PastBstTest.kt b/app/src/test/java/com/marcogn/coverdex/domain/model/PastBstTest.kt new file mode 100644 index 0000000..155c166 --- /dev/null +++ b/app/src/test/java/com/marcogn/coverdex/domain/model/PastBstTest.kt @@ -0,0 +1,72 @@ +package com.marcogn.coverdex.domain.model + +import org.junit.Assert.assertEquals +import org.junit.Test + +class PastBstTest { + + private fun entry(id: Int, baseStatTotal: Int) = PokemonEntry( + id = id, name = "p$id", displayName = "P$id", speciesId = id, speciesName = "p$id", + types = PokemonType.NORMAL to null, isLegendary = false, isMythical = false, isFinalEvolution = true, + generationIntroduced = 1, defaultAbility = null, isDefaultForm = true, baseStatTotal = baseStatTotal, + ) + + @Test + fun `null generation always resolves to the current baseStatTotal`() { + val resolver = bstResolverFor(pastBst = emptyList(), generation = null) + + assertEquals(500, resolver(entry(id = 1, baseStatTotal = 500))) + } + + @Test + fun `a form with no historical rows resolves to its current baseStatTotal at any generation`() { + val resolver = bstResolverFor(pastBst = emptyList(), generation = 3) + + assertEquals(500, resolver(entry(id = 1, baseStatTotal = 500))) + } + + @Test + fun `a generation at or after the only breakpoint uses the current value`() { + // Alakazam-shaped: current 500, held at 490 through gen 5. + val past = listOf(PastBst(pokemonId = 65, generationId = 5, bst = 490)) + val resolver = bstResolverFor(past, generation = 6) + + assertEquals(500, resolver(entry(id = 65, baseStatTotal = 500))) + } + + @Test + fun `a generation at or before the only breakpoint uses the historical value`() { + val past = listOf(PastBst(pokemonId = 65, generationId = 5, bst = 490)) + + assertEquals(490, bstResolverFor(past, generation = 5)(entry(id = 65, baseStatTotal = 500))) + assertEquals(490, bstResolverFor(past, generation = 1)(entry(id = 65, baseStatTotal = 500))) + } + + @Test + fun `a generation strictly between two breakpoints uses the smallest breakpoint at or after it`() { + // Two independent stat changes at gen 5 and gen 7 (see phase-7-...md §2.2's proof that + // the combined total is constant between consecutive breakpoints). + val past = listOf( + PastBst(pokemonId = 1, generationId = 5, bst = 400), + PastBst(pokemonId = 1, generationId = 7, bst = 420), + ) + val resolver = bstResolverFor(past, generation = 6) + + // Smallest breakpoint >= 6 is 7 -> 420, matching gen 6 and gen 7 alike. + assertEquals(420, resolver(entry(id = 1, baseStatTotal = 450))) + assertEquals(420, bstResolverFor(past, generation = 7)(entry(id = 1, baseStatTotal = 450))) + } + + @Test + fun `different forms' breakpoints do not cross-contaminate`() { + val past = listOf( + PastBst(pokemonId = 1, generationId = 5, bst = 400), + PastBst(pokemonId = 2, generationId = 5, bst = 999), + ) + val resolver = bstResolverFor(past, generation = 1) + + assertEquals(400, resolver(entry(id = 1, baseStatTotal = 500))) + // Form 3 has no historical rows at all, unaffected by form 1's or form 2's breakpoints. + assertEquals(500, resolver(entry(id = 3, baseStatTotal = 500))) + } +} diff --git a/app/src/test/java/com/marcogn/coverdex/domain/suggestion/SuggestionEngineTest.kt b/app/src/test/java/com/marcogn/coverdex/domain/suggestion/SuggestionEngineTest.kt index 77ce935..48f03f6 100644 --- a/app/src/test/java/com/marcogn/coverdex/domain/suggestion/SuggestionEngineTest.kt +++ b/app/src/test/java/com/marcogn/coverdex/domain/suggestion/SuggestionEngineTest.kt @@ -225,24 +225,50 @@ class SuggestionEngineTest { } @Test - fun `secondary sort on a compositeScore tie is by ascending catalogue id`() { - val team = listOf( - buildMember("Pikachu", PokemonType.ELECTRIC to null), - buildMember("Charizard", PokemonType.FIRE to PokemonType.FLYING), - buildMember("Gyarados", PokemonType.WATER to PokemonType.FLYING), - buildMember("Garchomp", PokemonType.DRAGON to PokemonType.GROUND), - buildMember("Mawile", PokemonType.STEEL to PokemonType.FAIRY), + fun `on a compositeScore tie, the higher baseStatTotal ranks first, ahead of catalogue id`() { + // A hand-built pool, not mockPokemonList() — engineered so both candidates score + // identically (docs/plan/phase-7-accuracy-and-customization.md §5.1's own regression: + // a solid team's remaining candidates tie on compositeScore and used to fall back + // straight to ascending id, surfacing Raticate/Persian/Kangaskhan regardless of how + // strong the alternative actually was). + fun normalCandidate(id: Int, bst: Int) = PokemonEntry( + id = id, name = "normal$id", displayName = "Normal$id", speciesId = id, speciesName = "normal$id", + types = PokemonType.NORMAL to null, isLegendary = false, isMythical = false, isFinalEvolution = true, + generationIntroduced = 1, defaultAbility = null, isDefaultForm = true, baseStatTotal = bst, ) - val suggestions = computeSuggestions(chart, team, pool, emptyList(), SuggestionOptions(includeCustoms = false)) - for (i in 1 until suggestions.size) { - if (suggestions[i - 1].compositeScore == suggestions[i].compositeScore) { - val prevEntry = pool.find { it.displayName == suggestions[i - 1].candidateLabel } - val currEntry = pool.find { it.displayName == suggestions[i].candidateLabel } - if (prevEntry != null && currEntry != null && prevEntry.isFinalEvolution == currEntry.isFinalEvolution) { - assertTrue(prevEntry.id <= currEntry.id) - } - } - } + // Lower id, lower BST. + val weak = normalCandidate(id = 1, bst = 300) + // Higher id, higher BST — must outrank `weak` despite the higher id. + val strong = normalCandidate(id = 2, bst = 500) + val handBuiltPool = listOf(weak, strong) + val team = listOf(buildMember("Pikachu", PokemonType.ELECTRIC to null)) + + val suggestions = computeSuggestions(chart, team, handBuiltPool, emptyList(), SuggestionOptions(includeCustoms = false)) + + assertEquals(2, suggestions.size) + assertEquals(suggestions[0].compositeScore, suggestions[1].compositeScore, 0.0) + assertEquals("Normal2", suggestions[0].candidateLabel) + assertEquals(500, suggestions[0].baseStatTotal) + assertEquals("Normal1", suggestions[1].candidateLabel) + } + + @Test + fun `on a compositeScore AND baseStatTotal tie, ascending catalogue id still decides`() { + fun normalCandidate(id: Int) = PokemonEntry( + id = id, name = "normal$id", displayName = "Normal$id", speciesId = id, speciesName = "normal$id", + types = PokemonType.NORMAL to null, isLegendary = false, isMythical = false, isFinalEvolution = true, + generationIntroduced = 1, defaultAbility = null, isDefaultForm = true, baseStatTotal = 300, + ) + val handBuiltPool = listOf(normalCandidate(id = 5), normalCandidate(id = 3)) + val team = listOf(buildMember("Pikachu", PokemonType.ELECTRIC to null)) + + val suggestions = computeSuggestions(chart, team, handBuiltPool, emptyList(), SuggestionOptions(includeCustoms = false)) + + assertEquals(2, suggestions.size) + assertEquals(suggestions[0].compositeScore, suggestions[1].compositeScore, 0.0) + assertEquals(suggestions[0].baseStatTotal, suggestions[1].baseStatTotal) + assertEquals("Normal3", suggestions[0].candidateLabel) + assertEquals("Normal5", suggestions[1].candidateLabel) } // ---- composite scoring (compositeScoring.test.ts) ---- diff --git a/app/src/test/java/com/marcogn/coverdex/ui/team/analysis/AnalysisViewModelTest.kt b/app/src/test/java/com/marcogn/coverdex/ui/team/analysis/AnalysisViewModelTest.kt index 5616039..761f468 100644 --- a/app/src/test/java/com/marcogn/coverdex/ui/team/analysis/AnalysisViewModelTest.kt +++ b/app/src/test/java/com/marcogn/coverdex/ui/team/analysis/AnalysisViewModelTest.kt @@ -145,6 +145,20 @@ class AnalysisViewModelTest { assertEquals(listOf("Garchomp"), gen4.suggestions.map { it.candidateLabel }) } + @Test + fun `suggestionCount reflects SettingsPreferences and defaults to 5`() = runTest(mainDispatcherRule.dispatcher) { + settingsPreferences.setShowMoves(false) + val teamId = teamRepository.createTeam("T-suggestion-count") + + val vm = viewModel(teamId, pool = mockPokemonList()) + val default = vm.uiState.first { it.chart != null } + assertEquals(5, default.suggestionCount) + + settingsPreferences.setSuggestionCount(8) + val updated = vm.uiState.first { it.suggestionCount == 8 } + assertEquals(8, updated.suggestionCount) + } + @Test fun `coverage is computed without ever advancing the Main test dispatcher`() = runTest(mainDispatcherRule.dispatcher) { // mainDispatcherRule.dispatcher is a StandardTestDispatcher, which only runs work queued From 0d08991fbdb28b083a9d99c72441cd37576ea2b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 11:52:25 +0000 Subject: [PATCH 5/7] Phase 7: exhaustive type-chart and dual-typing engine verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements docs/plan/phase-7-accuracy-and-customization.md §7.3.1-3 — the point-3 ask ("verify the weakness/resistance engine is correct across all 1000+ Pokemon"), answered at the level where it's actually decidable: the complete 171-typing space (18 single types + all 153 unordered dual-type pairs), not a re-test of the same space 1351 times over with worse failure messages. - Every one of the 324 type-chart cells is one of {0, 0.5, 1, 2}. - The eight classic asymmetric matchups (Ghost/Normal, Fighting/Ghost, Ground/Flying immunities; Fairy/Dragon, Steel/Fairy, Fire/Steel; Poison/Steel, Electric/Ground) assert their exact value. - defensiveMultiplier for all 171 typings against all 18 attacking types equals the product of the two single-type lookups. - defensiveProfile buckets every non-neutral type into exactly one of weaknesses/resistances/immunities and omits every neutral one, checked against the real multiplier for all 171 typings x 18 attacking types — this is the test that would have caught a mis-bucketing or double-counting bug anywhere in the whole type-effectiveness surface. - A cheap structural guard in DatasetAssemblyTest that every assembled form's typing is non-null and has no duplicate second type. - One more ability case (Filter turning a genuine x4 weakness into x3, the plan's own worked example) alongside the ones already covered when the ability-effect gaps were closed. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011j5jieoE4nqU2ZBSM7USHc --- .../domain/coverage/CoverageEngineTest.kt | 88 +++++++++++++++++++ .../domain/pokeapi/DatasetAssemblyTest.kt | 11 +++ 2 files changed, 99 insertions(+) diff --git a/app/src/test/java/com/marcogn/coverdex/domain/coverage/CoverageEngineTest.kt b/app/src/test/java/com/marcogn/coverdex/domain/coverage/CoverageEngineTest.kt index 9d63ed5..1b4a084 100644 --- a/app/src/test/java/com/marcogn/coverdex/domain/coverage/CoverageEngineTest.kt +++ b/app/src/test/java/com/marcogn/coverdex/domain/coverage/CoverageEngineTest.kt @@ -328,6 +328,12 @@ class CoverageEngineTest { } } + @Test + fun `filter turns a x4 weakness into x3`() { + // Ice vs Grass/Ground is 2x * 2x = 4x in the fixture. + assertEquals(3.0, defensiveMultiplier(chart, PokemonType.ICE, PokemonType.GRASS to PokemonType.GROUND, "filter"), 0.0) + } + @Test fun `filter does not touch a neutral or resisted hit`() { assertEquals(1.0, defensiveMultiplier(chart, PokemonType.FIRE, PokemonType.NORMAL to null, "filter"), 0.0) @@ -529,4 +535,86 @@ class CoverageEngineTest { val worst = mostVulnerableByType(chart, team) assertEquals(4.0, worst.getValue(PokemonType.ROCK), 0.0) } + + // --- exhaustive type-chart / dual-typing sweep (§7.3.1-2, phase-7-accuracy-and-customization.md) --- + + private val validMultipliers = setOf(0.0, 0.5, 1.0, 2.0) + + @Test + fun `every one of the 324 type chart cells is a real multiplier value`() { + for (atk in PokemonType.entries) { + for (def in PokemonType.entries) { + val mult = chart.multiplier(atk, def) + assertTrue("$atk -> $def was $mult, not one of $validMultipliers", mult in validMultipliers) + } + } + } + + @Test + fun `classic asymmetric matchups are exactly right`() { + assertEquals(0.0, chart.multiplier(PokemonType.NORMAL, PokemonType.GHOST), 0.0) + assertEquals(0.0, chart.multiplier(PokemonType.FIGHTING, PokemonType.GHOST), 0.0) + assertEquals(0.0, chart.multiplier(PokemonType.GROUND, PokemonType.FLYING), 0.0) + assertEquals(2.0, chart.multiplier(PokemonType.FAIRY, PokemonType.DRAGON), 0.0) + assertEquals(2.0, chart.multiplier(PokemonType.STEEL, PokemonType.FAIRY), 0.0) + assertEquals(2.0, chart.multiplier(PokemonType.FIRE, PokemonType.STEEL), 0.0) + assertEquals(0.0, chart.multiplier(PokemonType.POISON, PokemonType.STEEL), 0.0) + assertEquals(0.0, chart.multiplier(PokemonType.ELECTRIC, PokemonType.GROUND), 0.0) + } + + /** The complete space of typings a real Pokemon can have: 18 single types plus every + * unordered pair of two distinct types (18 + C(18,2) = 18 + 153 = 171). Enumerating all 1351+ + * catalogue forms would only re-test this same 171-element space with worse failure + * messages — see docs/plan/phase-7-accuracy-and-customization.md §7.3.2. */ + private fun allTypings(): List> { + val singles = PokemonType.entries.map { it to null } + val pairs = PokemonType.entries.flatMapIndexed { i, t1 -> + PokemonType.entries.drop(i + 1).map { t2 -> t1 to t2 } + } + return singles + pairs + } + + @Test + fun `allTypings enumerates exactly 171 typings, 18 single and 153 dual`() { + val typings = allTypings() + assertEquals(171, typings.size) + assertEquals(18, typings.count { it.second == null }) + assertEquals(153, typings.count { it.second != null }) + } + + @Test + fun `defensiveMultiplier for every one of the 171 typings equals the product of its two single-type lookups`() { + for (types in allTypings()) { + for (atk in PokemonType.entries) { + val expected = chart.multiplier(atk, types.first) * (types.second?.let { chart.multiplier(atk, it) } ?: 1.0) + val actual = defensiveMultiplier(chart, atk, types) + assertEquals("$atk vs $types", expected, actual, 0.0) + } + } + } + + @Test + fun `defensiveProfile buckets every non-neutral type exactly once and omits every neutral one, for all 171 typings`() { + // defensiveProfile's own contract (its class doc): weaknesses ">1x", resistances "<1x and + // >0", immunities "0x" — a neutral (1x) matchup belongs to none of the three and is + // implicit by omission, not a fourth bucket. This test's "no gap" is therefore about the + // NON-neutral types only: every type whose real multiplier isn't 1.0 must appear in + // exactly one bucket, and every bucketed type's real multiplier must match its bucket. + for (types in allTypings()) { + val profile = defensiveProfile(chart, types) + val bucketed = profile.weaknesses + profile.resistances + profile.immunities + + assertEquals("$types: a type landed in more than one bucket", bucketed.size, bucketed.toSet().size) + + for (atk in PokemonType.entries) { + val mult = defensiveMultiplier(chart, atk, types) + when { + mult == 0.0 -> assertTrue("$types: $atk ($mult) should be an immunity", atk in profile.immunities) + mult > 1.0 -> assertTrue("$types: $atk ($mult) should be a weakness", atk in profile.weaknesses) + mult < 1.0 -> assertTrue("$types: $atk ($mult) should be a resistance", atk in profile.resistances) + else -> assertTrue("$types: $atk ($mult) is neutral, must not be bucketed", atk !in bucketed) + } + } + } + } } diff --git a/app/src/test/java/com/marcogn/coverdex/domain/pokeapi/DatasetAssemblyTest.kt b/app/src/test/java/com/marcogn/coverdex/domain/pokeapi/DatasetAssemblyTest.kt index 9e734b2..0adadbd 100644 --- a/app/src/test/java/com/marcogn/coverdex/domain/pokeapi/DatasetAssemblyTest.kt +++ b/app/src/test/java/com/marcogn/coverdex/domain/pokeapi/DatasetAssemblyTest.kt @@ -221,6 +221,17 @@ class DatasetAssemblyTest { assertEquals("Fire Punch", prettify("fire-punch")) } + @Test + fun `every assembled form has a valid typing - non-null type1, and type2 never equal to it`() { + // Cheap structural guard for the invariant §7.3.3 asks for (the real 171-typing coverage + // lives in CoverageEngineTest against the full type space, not against 1351+ forms — + // see phase-7-accuracy-and-customization.md §7.3.2's own reasoning for why). + for (species in dataset.species) { + assertTrue(PokemonType.entries.contains(species.types.first)) + assertTrue(species.types.second == null || species.types.second != species.types.first) + } + } + @Test fun `searchKey normalizes hyphens, spaces and case away`() { assertEquals("mrmime", searchKey("mr-mime")) From 37a376eaeb406b8d53873f4bd7be1e71aa25b765 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 12:07:17 +0000 Subject: [PATCH 6/7] docs: document Phase 7 accuracy and customization work Update CHANGELOG, native spec, reference dataset contract, architecture docs, status snapshot, roadmap, implementation decisions, post-migration review, and README to reflect Phase 7's shipped scope: base stat totals, canonical ability names/picker, held item coverage effects, BST tie-break in suggestions, configurable suggestion count, and the exhaustive coverage-engine verification. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011j5jieoE4nqU2ZBSM7USHc --- CHANGELOG.md | 45 +++++++++ CLAUDE.md | 68 +++++++++----- README.md | 14 ++- ROADMAP.md | 17 +++- docs/STATUS.md | 91 ++++++++++++++++--- docs/implementation-decisions.md | 88 ++++++++++++++++++ docs/plan/README.md | 2 +- docs/plan/native-spec.md | 2 +- .../phase-7-accuracy-and-customization.md | 2 +- docs/plan/reference-pokedata.md | 30 ++++-- docs/post-migration-review.md | 51 +++++++++++ docs/test-plan.md | 57 ++++++++++++ 12 files changed, 412 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bcfe2b5..17cb6b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,51 @@ versions follow [Semantic Versioning](https://semver.org/). Kotlin port against the TypeScript original recovered from git history. Records six findings and an ordered remediation plan in [`docs/post-migration-review.md`](docs/post-migration-review.md). +- **Abilities show their real name, not the raw PokéAPI slug.** Picking a + species used to fill the ability field with `sap-sipper` instead of + "Sap Sipper" — the ability picker one row below showed the correct + name, so the same ability read two different ways on the same screen. + Every ability and move name now comes from PokéAPI's own English name + data instead of a naive hyphen-to-space conversion, which also fixes + names that conversion gets wrong outright (Well-Baked Body, Double-Edge, + U-turn, Will-O-Wisp, ...). +- **The ability field is now a canonical picker with a custom fallback.** + Picking a species offers its real abilities (normal slots, then hidden) + by name; a "Custom ability…" option opens the full catalogue with free + text still accepted, so a ROM hack's non-canonical ability assignment + stays typeable. An option that actually changes the weakness/resistance + map is marked. +- **Ten previously unmodelled abilities now affect the coverage + calculation**: Heatproof, Water Bubble, Purifying Salt, Filter, Solid + Rock, Prism Armor, Primordial Sea, Desolate Land, Delta Stream, Tera + Shell. Dry Skin's missed Fire weakness (1.25×) is now applied alongside + its existing Water immunity. Wonder Guard is now a real effect instead + of a display-only badge — only a super-effective hit deals any damage, + matching Shedinja's actual mechanic. Scrappy and Mind's Eye now let + Normal/Fighting moves hit Ghost-types in the offensive coverage grid, + and Aerilate/Pixilate/Refrigerate/Galvanize/Normalize now rewrite a + Normal-type move's coverage the way they do in the real games. +- **Held items affecting type coverage can now be assigned.** A new item + field (free text, same "type it or pick it" contract as ability) models + Air Balloon, Iron Ball, Ring Target and one resist berry per type. Items + round-trip through Showdown export/import and local backups. +- **Suggestions on an already-strong team now lead with the strongest + alternative, not the lowest Pokédex id.** Once a team's type coverage is + complete every remaining candidate ties on the composite score, and the + ranking used to fall through straight to ascending catalogue id — + surfacing Raticate ahead of far stronger options for no reason connected + to team building. A tied ranking now breaks by base stat total first + (current-generation value, or the historical one for a chosen + generation filter), with catalogue id as the final tie-break only. + Suggestion cards show the candidate's base stat total and a plain- + language explanation of the score. +- **The number of suggestions shown is now configurable**, 5 to 10 + (Settings → Team Suggestions), default 5 — previously hardcoded. +- **The dataset sync downloads four more small CSVs** (base stats, + historical base stats, English ability names, English move names), + ~213 KB → ~578 KB total — still a handful of requests, still well under + a second on any real connection. See + [`docs/plan/reference-pokedata.md`](docs/plan/reference-pokedata.md) §2. ## [2.0.0] - 2026-09-04 diff --git a/CLAUDE.md b/CLAUDE.md index 7f67751..3f4392f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,9 +72,8 @@ the app is native-only from here on. - **Phase 4 — Suggestions and generator**: ✅ done - **Phase 5 — Import/export and settings**: ✅ done - **Phase 6 — Release**: ✅ done -- **Phase 7 — Engine accuracy, abilities/items, BST ranking**: 📋 planned, - not started — see - [`docs/plan/phase-7-accuracy-and-customization.md`](docs/plan/phase-7-accuracy-and-customization.md) +- **Phase 7 — Engine accuracy, abilities/items, BST ranking**: ✅ done — + see [`docs/plan/phase-7-accuracy-and-customization.md`](docs/plan/phase-7-accuracy-and-customization.md) Tick these off as phases land — here and in [`docs/plan/README.md`](docs/plan/README.md). Do not implement anything not @@ -94,8 +93,10 @@ explicitly asks for it. tell users to export their teams to Showdown format first. See `docs/implementation-decisions.md`. - **The dataset sync reads PokéAPI's own CSV source data, not its JSON - mirror.** ~8 requests and ~208 KB instead of ~3875 requests and ~426 MB - — measured, not estimated. See `docs/plan/reference-pokedata.md`. + mirror.** ~12 requests and ~565 KB (8 requests/~208 KB through Phase 6; + Phase 7 added base stats and correct English ability/move names) + instead of ~3875 requests and ~426 MB — measured, not estimated. See + `docs/plan/reference-pokedata.md`. - **Sprite URLs are derived, never stored**, from a Pokémon's id alone. - **Species/type-override/ability/move values on a team slot are denormalized snapshots**, not references into the cached catalogue. @@ -113,7 +114,8 @@ explicitly asks for it. ## Architecture -The full six-phase shape, as it stands at the end of Phase 6: `ui/theme`, +The shape as it stands at the end of Phase 7 (the six-phase native rewrite +plus the engine-accuracy/customization phase that followed it): `ui/theme`, `ui/navigation`, `ui/teams` (real CRUD, plus the dice icon reaching Surprise Me), `ui/team` (team detail, the slot editor, `MoveSlotEditor`, `SlotSummaryCard`), `ui/team/analysis` (`AnalysisScreen`'s seven sections, @@ -122,23 +124,31 @@ Me), `ui/team` (team detail, the slot editor, `MoveSlotEditor`, `SurpriseMeViewModel`, the team generator's own screen), `ui/roster` (real CRUD, its own editor), `ui/importexport` (`ImportShowdownScreen`, `ImportShowdownViewModel`, `ExportShowdownDialog`), `ui/settings` (theme, -language, dataset status, the Showdown import entry point, and local backup), -`ui/common` (`PokemonSprite`, `TypeBadge`, `SearchableDropdown`, -`EditableComboBox`, `TypeDropdown`, `DamageClassDropdown`, the +language, dataset status, the Showdown import entry point, local backup, and +the Phase 7 suggestion-count stepper), `ui/common` (`PokemonSprite`, +`TypeBadge`, `SearchableDropdown`, `EditableComboBox`, `TypeDropdown`, +`DamageClassDropdown`, `AbilityPicker`/`ItemPicker` (Phase 7's +canonical-plus-custom ability field and the item field), `StepperCounter` +(the shared `−`/`+` row Surprise Me and Settings both use), the `PokemonType`/`DamageClass` `displayName()` extensions), `domain/coverage` -(the ported coverage engine), `domain/ability` (the ported `AbilityEffects`), -`domain/suggestion` (the ported suggestion engine + the shared `Scoring.kt`), -`domain/generator` (the ported team generator, injectable `Random`), -`domain/showdown` (`ShowdownFormat.kt`: export/import, contract-complete), -`domain/backup` (`BackupPayload.kt`: versioned DTOs + mapping), -`data/backup` (`BackupArchive.kt` zip read/write, `LocalBackupManager.kt` -SAF plumbing), `data/settings/SettingsPreferences.kt` (theme, language, the -persisted "Enable move slots" toggle, and every other app-wide setting — -include Mega/Dynamax, include legendaries, include customs in analysis), -`data/debug/DebugSeeder.kt` (seeds two teams and two roster entries, wired -from `CoverDexApplication`), and the full `data/pokeapi`, `data/local`, -`data/repository`, `domain/pokeapi`, `domain/sprite`, `domain/model`, -`domain/repository` and `di` packages the tree below describes. +(the ported coverage engine, extended in Phase 7 with the ability/item +effect pipeline), `domain/ability` (`AbilityEffects` — the ported table plus +Phase 7's ten added defensive abilities and the offensive gap), `domain/item` +(Phase 7's `ItemEffects` — the defensive-items-only subset), +`domain/suggestion` (the ported suggestion engine + the shared `Scoring.kt`, +now BST-tie-break-aware), `domain/generator` (the ported team generator, +injectable `Random`), `domain/showdown` (`ShowdownFormat.kt`: export/import, +contract-complete, items round-trip as of Phase 7), +`domain/backup` (`BackupPayload.kt`: versioned DTOs + mapping, format v2 as +of Phase 7), `data/backup` (`BackupArchive.kt` zip read/write, +`LocalBackupManager.kt` SAF plumbing), `data/settings/SettingsPreferences.kt` +(theme, language, the persisted "Enable move slots" toggle, and every other +app-wide setting — include Mega/Dynamax, include legendaries, include +customs in analysis, the Phase 7 suggestion count), `data/debug/DebugSeeder.kt` +(seeds two teams and two roster entries, wired from `CoverDexApplication`), +and the full `data/pokeapi`, `data/local`, `data/repository`, `domain/pokeapi`, +`domain/sprite`, `domain/model`, `domain/repository` and `di` packages the +tree below describes. ``` com.marcogn.coverdex @@ -154,7 +164,8 @@ com.marcogn.coverdex │ ├── pokeapi/ CsvParser, per-file parsers, dataset assembly, SyncStage │ ├── sprite/ SpriteUrlResolver (pure, unit-tested) │ ├── coverage/ the ported coverage engine -│ ├── ability/ AbilityEffects (ported verbatim) +│ ├── ability/ AbilityEffects (ported verbatim + Phase 7 additions) +│ ├── item/ ItemEffects (Phase 7, defensive items only) │ ├── suggestion/ the ported suggestion engine + shared Scoring │ ├── generator/ the ported team generator ("Surprise Me") │ ├── showdown/ export/import, contract-complete @@ -220,7 +231,7 @@ there. - **Do not set `Accept-Encoding` on `HttpURLConnection`.** Left alone it negotiates gzip and decompresses transparently; set it by hand and you get raw gzip bytes. Matters more here than in the sibling app: CSV - compresses very well, so the measured ~208 KB in + compresses very well, so the measured ~565 KB in `docs/plan/reference-pokedata.md` is what crosses the wire uncompressed. - **kotlinx.serialization defaults do not cover an explicit `null`.** A default value only fills a *missing* key; `"field": null` still throws @@ -267,6 +278,15 @@ there. `sourceSets["debug"].assets` instead, which is what actually makes `Migration1To2Test` pass. See `docs/implementation-decisions.md`, "Phase 2", for how this was verified rather than assumed. +- **`ALTER TABLE ... ADD COLUMN` needs a `DEFAULT` when the column is + `NOT NULL`.** SQLite (and therefore Room's own migration SQL) rejects a + `NOT NULL` column added this way with no default — every pre-existing + row would have nothing to put there. `MIGRATION_2_3`'s + `poke_species.baseStatTotal` column needs `DEFAULT 0` in the raw SQL + *and* a matching `@ColumnInfo(defaultValue = "0")` on the Kotlin field, + or `MigrationTestHelper`'s schema validation flags the mismatch. A + nullable added column (`team_member.item`, `custom_pokemon.item`) needs + neither — `NULL` is already a valid default for every existing row. ## Build/test commands diff --git a/README.md b/README.md index e96e5f1..9246f0b 100644 --- a/README.md +++ b/README.md @@ -36,8 +36,8 @@ sync finishes, and never asks for your data. every calculation adapts to match, without touching the real data. - **Speaks Showdown.** Import a team you already built on Pokémon Showdown, or export yours in the same format to share or battle with. -- **Actually offline.** Pokémon data downloads once — about 8 requests - and ~208 KB — then CoverDex never needs the internet again. +- **Actually offline.** Pokémon data downloads once — about 12 requests + and ~565 KB — then CoverDex never needs the internet again. ## Get CoverDex @@ -66,9 +66,13 @@ on-device and every later launch is instant, fully offline included. instant results as you type. - **Per-slot type overrides** for ROM hack typings, kept separate from the underlying species data. -- **Ability field** with known coverage effects (immunities, - multipliers) reflected directly in the analysis, plus free-text entry - for anything a randomizer throws at you. +- **Ability field** offering a species' real abilities by name, with + known coverage effects (immunities, multipliers) reflected directly in + the analysis, plus a "Custom ability…" free-text fallback for anything + a randomizer throws at you. +- **Held items** that affect type coverage (Air Balloon, Iron Ball, Ring + Target, type-resist berries), round-tripping through Showdown and + local backups. - **Four move slots per Pokémon**, from the synced catalogue or entered as custom moves, with move-aware offensive coverage when you use them. - **A personal custom-Pokémon roster** for anything that doesn't exist diff --git a/ROADMAP.md b/ROADMAP.md index bceca7c..efa0161 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,7 +6,8 @@ snapshot of what's implemented versus still open, see [`docs/STATUS.md`](docs/STATUS.md). CoverDex finished its native Android rewrite at the end of Phase 6 of -[`docs/plan/README.md`](docs/plan/README.md); `docs/plan/` stays in the +[`docs/plan/README.md`](docs/plan/README.md), and its engine-accuracy and +customization follow-up at the end of Phase 7. `docs/plan/` stays in the repository as the record of how it was built and why. The items below are this app's actual, current out-of-scope list — see [`docs/plan/native-spec.md`](docs/plan/native-spec.md), "Explicitly out of @@ -35,8 +36,20 @@ scope", for the full reasoning behind each one. - **Migrating data from the old Capacitor build.** Decided against, explicitly, in Phase 0 — see `docs/implementation-decisions.md`. +- **Generational type charts and generational typings.** The pinned + dataset has `type_efficacy_past.csv` (Gen-1 Ghost/Psychic and Bug/Poison + interactions, the pre-Gen-6 Steel/Dark resistance to Ghost, ...) and + `pokemon_types_past.csv` (pre-Fairy-retcon typings — Clefairy et al. + were Normal-type through Gen 5) already downloaded as part of Phase 7's + base-stat sync, and neither is read. The app has no "which + game/generation's rules am I analysing against" concept anywhere else + (the existing suggestion generation filter only restricts *which + species* are eligible, it doesn't change the type chart), and adding + one is a genuine feature, not a bug fix — see + `docs/plan/phase-7-accuracy-and-customization.md` §0.6/§7.4. + ## Ideas not yet committed to Nothing currently — the native rewrite (`docs/plan/README.md`, Phases -0–6) covered everything in `docs/plan/native-spec.md`. A genuinely new +0–7) covered everything in `docs/plan/native-spec.md`. A genuinely new idea belongs in a GitHub Issue first, not here. diff --git a/docs/STATUS.md b/docs/STATUS.md index 7ddf50f..5b1c34b 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -2,9 +2,10 @@ A snapshot of what's implemented, what's known to be missing, and any loose ends — written for whoever (human or agent) picks this project up next. -Last verified 2026-09-05, at the end of Phase 6 of the native Android -migration — the rewrite described in -[`docs/plan/README.md`](plan/README.md) is complete. Re-verify anything here +Last verified 2026-09-06, at the end of Phase 7 (engine accuracy, +abilities/items, BST-aware suggestions) — the native rewrite described in +[`docs/plan/README.md`](plan/README.md) and its Phase 7 follow-up are both +complete. Re-verify anything here before relying on it — this file goes stale the moment someone ships a change without updating it. It complements, not replaces, the other docs: [`CLAUDE.md`](../CLAUDE.md) for rules and @@ -30,8 +31,17 @@ Teams round-trip through Pokémon Showdown's team format (export from a team's overflow menu, import from Settings into a brand-new team), every setting from the old web app is now present, and Settings has a local backup that exports/restores every team and the custom roster to a single -file. That is the full feature set of `docs/plan/native-spec.md`; the -six-phase rewrite in [`docs/plan/README.md`](plan/README.md) is complete. +file. Since Phase 7, abilities and moves show their real names (not raw +PokéAPI slugs), the ability field offers a species' canonical abilities +with a free-text custom fallback, held items affecting type coverage can +be assigned and round-trip through Showdown/backup, base stats feed a +tie-break in the suggestion ranking so a solid team's alternatives lead +with strong Pokémon instead of the lowest catalogue id, the number of +suggestions shown is configurable, and ten previously-unmodelled +abilities (plus an offensive gap) now affect the coverage calculation. +That is the full feature set of `docs/plan/native-spec.md` plus Phase 7's +accuracy/customization work; the native rewrite in +[`docs/plan/README.md`](plan/README.md) is complete. Only the human repository owner's remaining, non-code steps are left: generating the real release keystore, setting the GitHub secrets it needs, and running the `Release` workflow for the first real `2.0.0` build — an @@ -166,17 +176,67 @@ secrets on someone else's behalf (see describe the finished native app — no remaining web/PWA/Capacitor/npm references anywhere in the repository outside `docs/plan/` (kept deliberately, as the historical record of how the app was built). +- **Phase 7 — Room schema v3** (`poke_species.baseStatTotal`, + `team_member.item`, `custom_pokemon.item`, plus the new + `poke_pokemon_ability`/`poke_species_bst_past` cache tables), reached + from v2 by `MIGRATION_2_3` and exercised by `Migration2To3Test`; the + dataset schema version bump forces every existing install to re-sync + and pick up base stats and canonical per-form abilities. +- **Phase 7 — correct ability/move names and the canonical-plus-custom + ability picker.** `PokemonEntry.defaultAbility`, every `TeamMember`'s + ability, and every move name now come from PokéAPI's own English name + data instead of a naive hyphen-to-space conversion; `ui/common/ + AbilityPicker.kt` offers a species' real abilities (normal, then + hidden) with a "Custom ability…" fallback that still accepts anything, + same contract as before. +- **Phase 7 — ten previously-unmodelled abilities plus an offensive + gap.** Heatproof, Water Bubble, Purifying Salt, Filter, Solid Rock, + Prism Armor, Primordial Sea, Desolate Land, Delta Stream, Tera Shell + now affect the coverage calculation; Wonder Guard is a real effect, not + a display-only badge; Scrappy/Mind's Eye and the `-ate`/Normalize + abilities are honoured in the offensive coverage grid. +- **Phase 7 — held items, defensive subset.** `domain/item/ItemEffects.kt` + models Air Balloon, Iron Ball, Ring Target and one resist berry per + type; `TeamMember.item` round-trips through Room, Showdown export/ + import and local backups (format version 2). +- **Phase 7 — BST-aware suggestion ranking and a configurable suggestion + count.** The ranking's tie-break (after composite score and + final-evolution status) is now base stat total, generation-aware via + `bstResolverFor`, before falling back to catalogue id; Settings has a + 5-10 stepper for how many suggestions the Analysis tab shows. +- **Phase 7 — the coverage/suggestion engine verified exhaustively**, not + spot-checked: every one of the 324 type-chart cells, and + `defensiveMultiplier`/`defensiveProfile` against the complete 171-typing + space (18 single types + all 153 unordered dual-type pairs) — see + `docs/post-migration-review.md`, "Phase 7 audit", for the findings this + surfaced that were deliberately deferred rather than fixed in-phase. ## What's known to be missing -Nothing from `docs/plan/native-spec.md` — all six phases of -[`docs/plan/README.md`](plan/README.md) are done. What remains is the -repository owner's own, non-code responsibility (see above): generating the -production signing keystore, setting the five GitHub Actions secrets, and -running the first real `Release` workflow dispatch. +Nothing from `docs/plan/native-spec.md` or `docs/plan/ +phase-7-accuracy-and-customization.md` — all six phases of +[`docs/plan/README.md`](plan/README.md), plus Phase 7, are done. What +remains is the repository owner's own, non-code responsibility (see +above): generating the production signing keystore, setting the five +GitHub Actions secrets, and running the first real `Release` workflow +dispatch. -Also deliberately deferred (not a bug, see `docs/implementation-decisions.md`): +Also deliberately deferred (not a bug, see `docs/implementation-decisions.md` +and, for the Phase 7 items, `docs/post-migration-review.md`'s "Phase 7 +audit"): +- **Phase 7** — generational type charts and generational typings + (`type_efficacy_past.csv`/`pokemon_types_past.csv`, downloaded but + unread) — a genuine feature with no existing "which generation's rules" + concept to hang it on, tracked in `ROADMAP.md`. +- **Phase 7** — `Suggestion.gain`'s meaning differs between addition and + replacement mode, `weaknesses()` counts weakness types rather than + magnitude (so a x4 and a x2 weakness score identically), replacement + mode computes one context's score twice, and a custom roster entry + named after a catalogue species can be silently deduplicated away — all + four are pre-existing suggestion-engine characteristics found while + auditing it for Phase 7, each with its own reason for not being folded + into that phase; see `docs/post-migration-review.md`. - **Phase 2** — the slot editor's species picker never offers the custom roster as a search source, unlike `legacy-web`'s own "Include saved custom Pokémon in search" checkbox — `phase-2-teams-and-roster.md`'s own @@ -200,7 +260,7 @@ say so plainly before they update. ```bash export ANDROID_HOME=... # if a local SDK is available; otherwise rely on CI -./gradlew testDebugUnitTest # 226 tests as of Phase 6 +./gradlew testDebugUnitTest # 329 tests as of Phase 7 ./gradlew lintDebug ./gradlew assembleDebug ``` @@ -215,5 +275,8 @@ addition/replacement modes and filters, every Surprise Me interaction (anchors, constraints, generate, regenerate, Keep), and (new this phase) Showdown export/import (clipboard, SAF file pickers, unknown-move/skipped- species handling), a real local-backup export/restore cycle including -across a reinstall, and (new this phase) a real signed release build via -`.github/workflows/build-apk.yml` or `release.yml`. +across a reinstall, a real signed release build via +`.github/workflows/build-apk.yml` or `release.yml`, and (new this phase) +the canonical-plus-custom ability picker, the item field and its +Showdown/backup round-trip, and an upgrade from a Room-v2 install picking +up base stats and canonical abilities on re-sync. diff --git a/docs/implementation-decisions.md b/docs/implementation-decisions.md index 950411f..79a9077 100644 --- a/docs/implementation-decisions.md +++ b/docs/implementation-decisions.md @@ -837,3 +837,91 @@ findings not yet acted on. removing a candidate's own Ground weakness; a teammate's Levitate changing a shared candidate weakness from "aggravated" to merely "new"). + +## Phase 7 — Engine accuracy, abilities/items, BST ranking + +- **BST is the suggestion ranking's tie-break only, never a term in the + composite score.** Discussed explicitly with the repository owner + before implementation: making a candidate's raw strength part of the + score itself would change what "the best pick" means for a team that + is *not* yet solid (a real coverage gain could be outranked by a + bigger Pokémon with no gain at all), and would touch the `0.5`/`1.0` + weights `Scoring.kt`'s own doc comment calls "load-bearing and shared + with the generator". The comparator instead adds exactly one new step + — `bestScore, then isFinal, then baseStatTotal descending, then + catalogue id ascending` — so it only ever decides among candidates that + already tied on real coverage/weakness math. +- **Held items are the defensive subset only, and `items.csv` is never + downloaded.** Also an explicit decision with the repository owner: + modelling every item (offensive boosts, berries with non-type effects, + weather items) would need the full item catalogue (a further ~60 KB) + for a coverage app that only cares about type effectiveness. The 18 + modelled items (Air Balloon, Iron Ball, Ring Target, one resist berry + per type) are a hardcoded table, the same shape as `ABILITY_EFFECTS`. + **The plan document's own §4.1 table has a gap** — it lists 16 resist + berries plus Chilan and omits Coba Berry (Flying) — caught during + implementation by cross-checking "one berry per type except Normal" + against `PokemonType.entries`; `ItemEffects.kt`'s `ITEM_EFFECTS` map is + the correct, complete 18-item version and a dedicated test + (`ITEM_EFFECTS has one resist berry per type except Normal, plus + Chilan for Normal`) asserts the full set going forward. +- **The item field has no canonical-per-species picker, unlike ability.** + `AbilityPicker`'s canonical/custom split exists because a species' + abilities are a small, fixed, PokéAPI-known set; any Pokémon can hold + any item, so there is no equivalent "canonical list" to offer — + `ItemPicker` is free text with suggestions from the modelled subset + only, the same shape as the ability field's own custom mode. +- **`AbilityPicker`'s free-text suggestion list does not show the + "has an effect" marker that the canonical dropdown does.** + `EditableComboBox` commits whatever suggestion string the user taps + verbatim as the field's value — appending a marker (e.g. "Overgrow ●") + to a suggestion would corrupt the stored ability with that marker + attached. The canonical dropdown avoids this because its `onClick` + commits `ability.displayName` independently of the `Text` content + shown in the row; the free-text list has no such separation available + without changing `EditableComboBox`'s own contract, which several + other screens already depend on. Scope decision: the badge only + appears on canonical picks. +- **`KNOWN_ABILITIES_WITH_EFFECTS` is regenerated from `ABILITY_EFFECTS` + instead of hand-maintained, and its exact strings changed as a result.** + Before Phase 7 it was a hand-tweaked list with one inconsistency of its + own ("well-baked body", replacing only the *second* hyphen in + `well-baked-body` with a space, keeping the first) — a leftover, unused + artifact from the `legacy-web` port that nothing in the app actually + reads (confirmed by grep before touching it). It's now + `ABILITY_EFFECTS.keys.map { it.replace('-', ' ') }`, so `well-baked-body` + becomes "well baked body" (both hyphens replaced) rather than the old + mixed convention. Harmless: nothing consumes this constant, and its own + test was rewritten to assert the regenerated shape, not the old one. +- **Wonder Guard was promoted from `AbilityEffect.BadgeOnly` to a real + effect (`OnlySuperEffective`), not left as a display note.** The + pre-Phase-7 table treated it as UI-only, so `defensiveProfile`/ + `defensiveMultiplier` were actively wrong for the one Pokémon (Shedinja) + the ability applies to: a resisted or neutral hit should deal zero + damage, but the engine reported the real chart multiplier instead. This + is a genuine behavior change (a Wonder Guard holder's non-neutral + matchups now show up in `defensiveProfile.immunities`, not scattered + across weaknesses/resistances), found by auditing `ABILITY_EFFECTS` + against PokéAPI's own `short_effect` text rather than assumed. +- **Gen I's canonical BST is the sum of five stats, not six** — no + Special Attack/Special Defense split existed before Generation II, so + mirroring the single "Special" stat into both halves (making a false + six-stat total) would inflate every Gen-I Pokémon's total and change + relative ordering among special-heavy species. `bstResolverFor`/ + `assembleDataset`'s `bstAt` both apply this rule, and it is the reason + a Gen-I total must never be compared numerically against a later + generation's — documented on `PastBst` itself since it is the one way + this feature can go quietly wrong. +- **`SuggestionEngine.EntryLookup`'s displayName-first precedence can, in + a contrived case, disagree with the pre-Phase-7 single-pass + `pool.find { it.displayName == x || it.name == x.lowercase() }`** — if + one entry's raw identifier lowercased happens to equal another entry's + exact display name, the old code (which evaluates both conditions per + pool element, first match wins by *pool position*) could return a + different entry than the new two-map lookup (which always checks + *every* displayName match before falling back to *any* name match, + regardless of position). This divergence needs a contrived, unrealistic + fixture to observe — real `displayName`/`name` pairs never collide this + way — and the plan's own §5.4 explicitly frames "displayName match + first" as the behavior to preserve, so the two-map version is the + intended, not merely tolerated, semantics. diff --git a/docs/plan/README.md b/docs/plan/README.md index dfd2717..7b16c07 100644 --- a/docs/plan/README.md +++ b/docs/plan/README.md @@ -23,7 +23,7 @@ says so explicitly and tells you what to do about it. | 4 | [`phase-4-suggestions-and-generator.md`](phase-4-suggestions-and-generator.md) | Suggestion engine, composite scoring, Surprise Me generator | | 5 | [`phase-5-import-export-and-settings.md`](phase-5-import-export-and-settings.md) | Showdown import/export, settings, theme and language, local backup | | 6 | [`phase-6-release.md`](phase-6-release.md) | Signing, release pipeline, docs rewrite, `legacy-web/` deleted | -| 7 | [`phase-7-accuracy-and-customization.md`](phase-7-accuracy-and-customization.md) | **Planned, not started.** Base stats + correct English ability/move names in the dataset, canonical-vs-custom ability picking, held items (defensive subset), BST tie-break for suggestions, configurable suggestion count, and the ability-effect gaps the Phase 7 audit found | +| 7 | [`phase-7-accuracy-and-customization.md`](phase-7-accuracy-and-customization.md) | Base stats + correct English ability/move names in the dataset, canonical-vs-custom ability picking, held items (defensive subset), BST tie-break for suggestions, configurable suggestion count, and the ability-effect gaps the Phase 7 audit found | Read before starting any phase: [`../../CLAUDE.md`](../../CLAUDE.md), [`native-spec.md`](native-spec.md), this file, the phase file itself, and diff --git a/docs/plan/native-spec.md b/docs/plan/native-spec.md index 06e5092..0a6be47 100644 --- a/docs/plan/native-spec.md +++ b/docs/plan/native-spec.md @@ -174,7 +174,7 @@ pinning and invalidation — is [`reference-pokedata.md`](reference-pokedata.md), and it is not repeated here. What matters at the spec level: -- The sync is **~8 requests and ~208 KB**, and is fast enough that it does +- The sync is **~12 requests and ~565 KB**, and is fast enough that it does not gate the UI behind a full-screen loader the way the PWA does. Show progress inline; let the user reach Settings while it runs. - Sprite URLs are **derived, never stored**, from the Pokémon's id. diff --git a/docs/plan/phase-7-accuracy-and-customization.md b/docs/plan/phase-7-accuracy-and-customization.md index 724285b..cdda190 100644 --- a/docs/plan/phase-7-accuracy-and-customization.md +++ b/docs/plan/phase-7-accuracy-and-customization.md @@ -1,6 +1,6 @@ # Phase 7 — Engine accuracy, ability/item modelling, BST-aware suggestions -**Status:** planned, not started. +**Status:** done. **Executed by:** one agent session, in the task order below. **Read first:** [`../../CLAUDE.md`](../../CLAUDE.md), [`README.md`](README.md) (working rules), [`native-spec.md`](native-spec.md), diff --git a/docs/plan/reference-pokedata.md b/docs/plan/reference-pokedata.md index e74dfee..142f687 100644 --- a/docs/plan/reference-pokedata.md +++ b/docs/plan/reference-pokedata.md @@ -58,6 +58,11 @@ smaller. ### What the sync downloads — measured +The first eight files are Phase 1's original set; the last four were added +in Phase 7 for base stats and correct English ability/move names (see +`phase-7-accuracy-and-customization.md` §2) — still read at the same +pinned `DATASET_REVISION` as everything else. + | File | Size | Rows | Columns used | |---|---:|---:|---| | `pokemon.csv` | 47,082 B | 1351 | `id`, `identifier`, `species_id`, `is_default` | @@ -68,12 +73,19 @@ smaller. | `moves.csv` | 42,322 B | 937 | `id`, `identifier`, `type_id`, `power`, `damage_class_id` | | `types.csv` | 321 B | 21 | `id`, `identifier` | | `type_efficacy.csv` | 2,883 B | 324 | `damage_type_id`, `target_type_id`, `damage_factor` | -| **Total** | **212,818 B** | | **8 requests** | - -**≈ 208 KiB and 8 requests, against ≈ 426 MB and 3875 requests.** Roughly -2000× fewer bytes and 480× fewer requests. On any usable connection the -sync finishes before a progress bar is worth showing — which is what -"recupero fulmineo" means here, and it is the reason this plan exists. +| `pokemon_stats.csv` | 94,392 B | 8106 | `pokemon_id`, `stat_id`, `base_stat` | +| `pokemon_stats_past.csv` | 3,046 B | 235 | `pokemon_id`, `generation_id`, `stat_id`, `base_stat` | +| `ability_names.csv` | 65,239 B | 3739 (374 English) | `ability_id`, `local_language_id`, `name` | +| `move_names.csv` | 202,670 B | 9532 (937 English) | `move_id`, `local_language_id`, `name` | +| **Total** | **578,165 B** | | **12 requests** | + +**≈ 565 KiB and 12 requests, against ≈ 426 MB and 3875 requests** — +still roughly 750× fewer bytes and 320× fewer requests than the JSON +mirror. `move_names.csv` alone is over a third of the total; it buys +correct move capitalisation (`Double-Edge`, `U-turn`, `Will-O-Wisp`) that +`prettify()` cannot produce — see `phase-7-accuracy-and-customization.md` +§0.3. On any usable connection the sync still finishes well under a +second, which is what "recupero fulmineo" means here. ### Exact headers, as measured @@ -91,6 +103,10 @@ moves.csv id,identifier,generation_id,type_id,power,pp,accuracy,pri contest_effect_id,super_contest_effect_id types.csv id,identifier,generation_id,damage_class_id type_efficacy.csv damage_type_id,target_type_id,damage_factor +pokemon_stats.csv pokemon_id,stat_id,base_stat,effort +pokemon_stats_past.csv pokemon_id,generation_id,stat_id,base_stat,effort +ability_names.csv ability_id,local_language_id,name +move_names.csv move_id,local_language_id,name ``` Do not index columns positionally. Parse the header row and look columns up @@ -280,7 +296,7 @@ real debugging time there. - **Do not set `Accept-Encoding` on `HttpURLConnection`.** Left alone it negotiates gzip and decompresses transparently. Set it by hand and you get raw gzip bytes. This matters more here than it did in Hall of - Memories: CSV compresses extremely well, so the 208 KB figure above is + Memories: CSV compresses extremely well, so the 565 KB figure above is what crosses the wire uncompressed and roughly a quarter of that with the gzip you get for free. - **Parse the CSV properly.** `moves.csv` and friends are plain enough that diff --git a/docs/post-migration-review.md b/docs/post-migration-review.md index 2e54878..32c9914 100644 --- a/docs/post-migration-review.md +++ b/docs/post-migration-review.md @@ -331,3 +331,54 @@ ranking comparator; `analyseTeam`'s mixed-mode handling; `generateTeam`'s quota logic; `applySuggestion`'s slot resolution (custom roster entries always carry `pokedexId = null`, so a custom's ability is not overwritten); the type chart's 18×18 completeness. + +## Phase 7 audit + +Findings surfaced while implementing +`docs/plan/phase-7-accuracy-and-customization.md`, deliberately not fixed +in that phase — each with why. + +1. **`Suggestion.gain` means different things in the two ranking modes.** + Addition mode sets it to `newlyCovered.size` (types the union coverage + gains); replacement mode sets it to `offensiveGain`, which can be + negative (`newUnion.size - currentTeamCoverage.size`, i.e. it already + accounts for the coverage the replaced member is losing). A caller + reading `gain` without checking `kind` first will misinterpret it. + Deferred: fixing it means picking one shared meaning and updating + every UI string/test that reads `gain`, which is a larger change than + this phase's scope (ability/item modelling, BST ranking, the + suggestion count). +2. **`weaknesses()` counts weakness *types*, not their magnitude** — a + ×4 weakness and a ×2 weakness both contribute exactly `1` to + `NEW_WEAKNESS_PENALTY`/`AGGRAVATED_WEAKNESS_PENALTY`. This is exactly + why a Water/Ground candidate (one ×4 Grass weakness) can outscore a + candidate with two separate ×2 weaknesses despite taking worse damage + overall — see §0.1 of the Phase 7 plan for the full worked case. Left + alone deliberately: the repository owner's decision for this phase was + a tie-break only (§5.1), not a change to the composite score formula + itself, which `Scoring.kt`'s own doc comment calls load-bearing and + shared with the team generator. +3. **Replacement mode computes `replacementContexts[0]`'s composite score + twice** — once to seed `bestResult`/`bestScore` before the loop, once + again inside the loop's first iteration. Harmless (the loop's `>` + comparison never lets a tied first result win twice), but wasted work + on every suggestion recomputation. Small enough to fold into a future + pass on `SuggestionEngine.kt` rather than justify its own PR. +4. **`deduped`'s `seen.add(speciesName.lowercase())` silently drops a + custom Pokémon named after a catalogue species.** If a user's custom + roster entry is named e.g. "Pikachu" (case-insensitively), and a real + Pikachu is also in the candidate pool, only whichever one the pool + iteration order visits first survives deduplication — the other never + appears as a suggestion, with no error or indication why. Deferred: + fixing this needs a real identity concept for candidates (e.g. keying + on `pokedexId` when present, name only as a fallback for customs), + which touches the same dedup logic every existing + `SuggestionEngineTest` case already exercises — worth its own + change, not a drive-by fix bundled into ability/item/BST work. +5. **Generational type charts and generational typings are not modelled** + (`type_efficacy_past.csv`, `pokemon_types_past.csv` — both exist at the + pinned dataset revision and are not downloaded). Recorded in + `ROADMAP.md` under "Ideas not yet committed to": the app has no "which + game/generation am I analysing against" concept anywhere else, and + adding one is a larger design decision than anything else in this + phase, not a bug to slip in alongside it. diff --git a/docs/test-plan.md b/docs/test-plan.md index f854275..ec91475 100644 --- a/docs/test-plan.md +++ b/docs/test-plan.md @@ -378,3 +378,60 @@ None yet. ### Known regressions None yet. + +## Phase 7 — Engine accuracy, abilities/items, BST-aware suggestions + +- [ ] **The ability picker's canonical list.** Pick a real species in a + slot — the ability field opens as a dropdown of its real abilities + (normal slots first, then any hidden one marked "(hidden)"), each + showing its correct name (e.g. "Sap Sipper", not `sap-sipper`). An + ability with a coverage effect is marked with a small dot. +- [ ] **"Custom ability…" still accepts anything.** Selecting the + picker's last entry swaps to a free-text field with suggestions from + the full catalogue; typing an ability that exists in no PokéAPI table + (a ROM hack assignment) is still accepted and saved verbatim. A "back + to canonical" action returns to the species' own list. +- [ ] **A hand-typed/roster Pokémon's ability field goes straight to free + text** — no canonical list to show since it has no catalogue species. +- [ ] **Ability and move names show correct capitalization everywhere** — + spot-check `Well-Baked Body`, `Double-Edge`, `U-turn`, `Will-O-Wisp` in + the ability/move pickers and on the Analysis tab's per-Pokémon cards. +- [ ] **The item field.** A new field below ability in the slot editor + and roster editor accepts free text with suggestions from the modelled + set (Air Balloon, Iron Ball, Ring Target, the type-resist berries). + Setting one shows on the team screen's slot card ("@ Item") and on the + Analysis tab's per-Pokémon card with its effect summary when it has + one (e.g. Air Balloon → "immune to Ground"). +- [ ] **Items round-trip.** Export a team with an item set to Showdown + format (overflow menu) — the exported text reads `Species @ Item`. + Re-import it (Settings → Import) and confirm the item is preserved. A + local backup (Settings → Backup) exported and restored also keeps + every item. +- [ ] **The abilities the Phase 7 audit added actually change the + Analysis tab.** Give a slot Wonder Guard and confirm its per-Pokémon + card shows every incoming type bucketed as either "immune" (anything + not super-effective) or its real weakness (anything that is) — not the + old "badge with no calculation effect". Give a slot Scrappy or Mind's + Eye and a Normal move, then check the offensive coverage grid shows a + neutral (not 0×) hit against Ghost-type opponents. +- [ ] **Suggestions on an already-solid team lead with strong Pokémon.** + Build a team whose type coverage is already complete (the Analysis + tab's suggestions section shows the "coverage is already solid" note) + — the alternatives listed should be generally strong species, not the + lowest-numbered Pokédex entries of whatever typing ties on score. Each + card shows its base stat total under the score. +- [ ] **The suggestion-count setting.** Settings → Team Suggestions has a + "Number of suggestions shown" stepper, 5 to 10, default 5; changing it + immediately changes how many cards the Analysis tab's Suggestions + section shows. +- [ ] **A fresh install and an upgrade from a Phase-6-era (Room v2) + database both work.** A fresh install syncs and shows base stats and + canonical abilities as above. An existing install (upgrading from a + build before this phase) re-syncs automatically on next launch (the + cache schema version bump forces this) and ends up in the same state — + no crash, no stale "sap-sipper"-style ability text left over from + before the fix, existing teams and roster entries untouched. + +### Known regressions + +None yet. From dc82350adea3d5e59c683e8dda1d76403c0b6376 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 12:10:01 +0000 Subject: [PATCH 7/7] fix: resolve top-level forward-reference compile error in AbilityEffects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KNOWN_ABILITIES_WITH_EFFECTS was declared before ABILITY_EFFECTS, which it reads at initialization — Kotlin does not allow a top-level property initializer to forward-reference another one declared later in the same file ("Variable 'ABILITY_EFFECTS' must be initialized"). This broke compilation on every commit since it was introduced; move the derived list below the map it derives from. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011j5jieoE4nqU2ZBSM7USHc --- .../coverdex/domain/ability/AbilityEffects.kt | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/marcogn/coverdex/domain/ability/AbilityEffects.kt b/app/src/main/java/com/marcogn/coverdex/domain/ability/AbilityEffects.kt index b2b59a1..79797d7 100644 --- a/app/src/main/java/com/marcogn/coverdex/domain/ability/AbilityEffects.kt +++ b/app/src/main/java/com/marcogn/coverdex/domain/ability/AbilityEffects.kt @@ -29,15 +29,6 @@ sealed interface AbilityEffect { data object OnlySuperEffective : AbilityEffect } -/** Ability slugs (PokéAPI identifier format, e.g. `"sap-sipper"`) that have a coverage-relevant - * effect, in ability-picker display format (spaces instead of hyphens) — regenerated from - * [ABILITY_EFFECTS] rather than hand-maintained a second time, so it cannot drift from the actual - * effect table (phase-7-accuracy-and-customization.md §7.1). Not currently read by any UI; kept - * for whatever surface wants a flat "which abilities matter" list without inspecting - * [ABILITY_EFFECTS] itself — the ability picker's own "has an effect" badge - * (`ui/team/SlotEditorScreen.kt`) checks `abilityKey(name) in ABILITY_EFFECTS` directly instead. */ -val KNOWN_ABILITIES_WITH_EFFECTS: List = ABILITY_EFFECTS.keys.map { it.replace('-', ' ') } - /** * Hardcoded map of ability slugs (lowercase, hyphenated, matching PokéAPI) to their * coverage-relevant effects. Only abilities that alter defensive/offensive type effectiveness or @@ -95,6 +86,15 @@ val ABILITY_EFFECTS: Map> = mapOf( "wonder-guard" to listOf(AbilityEffect.OnlySuperEffective), ) +/** Ability slugs (PokéAPI identifier format, e.g. `"sap-sipper"`) that have a coverage-relevant + * effect, in ability-picker display format (spaces instead of hyphens) — regenerated from + * [ABILITY_EFFECTS] rather than hand-maintained a second time, so it cannot drift from the actual + * effect table (phase-7-accuracy-and-customization.md §7.1). Not currently read by any UI; kept + * for whatever surface wants a flat "which abilities matter" list without inspecting + * [ABILITY_EFFECTS] itself — the ability picker's own "has an effect" badge + * (`ui/team/SlotEditorScreen.kt`) checks `abilityKey(name) in ABILITY_EFFECTS` directly instead. */ +val KNOWN_ABILITIES_WITH_EFFECTS: List = ABILITY_EFFECTS.keys.map { it.replace('-', ' ') } + /** Lowercase, letters and digits only — so `"Well-Baked Body"`, `"well-baked-body"` and * `"wellbakedbody"` all resolve to the same [ABILITY_EFFECTS] entry, mirroring * [com.marcogn.coverdex.domain.pokeapi.searchKey]. Replaces the pre-Phase-7 `normalizeAbilityName`