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 c482656..3f4392f 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,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**: ✅ 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 @@ -88,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. @@ -107,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, @@ -116,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 @@ -148,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 @@ -214,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 @@ -261,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/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/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/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/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/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/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..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 @@ -2,39 +2,51 @@ 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 -} -/** 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", -) + /** 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 +} -/** 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 +54,132 @@ 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+"), "-") +/** 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` + * (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/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 2fc502d..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 @@ -1,8 +1,11 @@ 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.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 @@ -19,32 +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 - 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 + + 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 - return result + 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 = @@ -52,11 +73,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 +137,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) @@ -137,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) } @@ -157,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 = @@ -170,11 +199,31 @@ 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 * from each attacking type. */ fun mostVulnerableByType(chart: TypeChart, members: List): 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/PastBst.kt b/app/src/main/java/com/marcogn/coverdex/domain/model/PastBst.kt new file mode 100644 index 0000000..369dbfa --- /dev/null +++ b/app/src/main/java/com/marcogn/coverdex/domain/model/PastBst.kt @@ -0,0 +1,40 @@ +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, +) + +/** + * 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/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/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/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/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/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/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/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/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/roster/RosterEditorScreen.kt b/app/src/main/java/com/marcogn/coverdex/ui/roster/RosterEditorScreen.kt index 2b83f5a..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 @@ -36,7 +36,8 @@ 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.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, ) } @@ -99,8 +104,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 +166,20 @@ 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(), + ) + + ItemPicker( + resetKey = draft.id, + item = draft.item, + onItemChange = { draft = draft.copy(item = it) }, modifier = Modifier.fillMaxWidth(), ) 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/SlotEditorScreen.kt b/app/src/main/java/com/marcogn/coverdex/ui/team/SlotEditorScreen.kt index 47b3457..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 @@ -40,8 +40,9 @@ 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.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, ) @@ -197,16 +202,20 @@ 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(), + ) + + ItemPicker( + resetKey = currentDraft.id, + item = currentDraft.item, + onItemChange = { draft = currentDraft.copy(item = it) }, 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/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/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/PerPokemonCard.kt b/app/src/main/java/com/marcogn/coverdex/ui/team/analysis/PerPokemonCard.kt index d606645..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 @@ -32,10 +32,11 @@ 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 +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 @@ -83,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) @@ -99,16 +100,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)) { @@ -121,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/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 315eb2c..de1d550 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -80,6 +80,10 @@ Team best immune to Only super-effective moves deal damage + super-effective hits + never super-effective against this Pokémon + grounds the holder + removes type immunities Pokémon @@ -96,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 🎲 @@ -145,6 +151,11 @@ Type 2 None Ability + Item + (hidden) + Custom ability… + Back to canonical abilities + Moves Pick or type a move… Custom move name @@ -167,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 94b7e01..5a0d847 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -80,6 +80,10 @@ Miglior team immune a 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 @@ -96,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 🎲 @@ -145,6 +151,11 @@ Tipo 2 Nessuno Abilità + Strumento + (nascosta) + Abilità custom… + Torna alle abilità canoniche + Mosse Scegli o digita una mossa… Nome mossa personalizzata @@ -167,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/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/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/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/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/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/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/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/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 dbb5874..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 @@ -295,19 +295,181 @@ 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, - ) + 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 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) + 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) + } + + // --- 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( - defensiveMultiplier(chart, PokemonType.DARK, PokemonType.GHOST to null), - defensiveMultiplier(chart, PokemonType.DARK, PokemonType.GHOST to null, "wonder-guard"), + 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) @@ -373,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/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/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/pokeapi/DatasetAssemblyTest.kt b/app/src/test/java/com/marcogn/coverdex/domain/pokeapi/DatasetAssemblyTest.kt index 6dbc502..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 @@ -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 @@ -142,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")) 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/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) 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 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 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 9c9a093..7b16c07 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) | 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 new file mode 100644 index 0000000..cdda190 --- /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:** 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), +[`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. 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.