mirror of
https://github.com/Corfirean/coa-server-manager.git
synced 2026-11-11 03:51:01 +00:00
chore(reborn): MR3.1 semantic audit, i18n localization, and fail-closed cleanup
This commit is contained in:
1 parent
351ace1c56
commit
a48717340b
12 files changed
+140
-64
No files matched your search
@@ -183,13 +183,17 @@ impl<'a> HostService<'a> {
|
||||
});
|
||||
}
|
||||
if let Some((_, rs)) = self.server_id.split_once(':') {
|
||||
if let Ok(host_ruleset) = rs.parse::<crate::portable::Ruleset>() {
|
||||
if offer.ruleset != host_ruleset || model.ruleset != host_ruleset {
|
||||
return Err(PortableError::RulesetMismatch {
|
||||
character: model.ruleset,
|
||||
realm: host_ruleset,
|
||||
});
|
||||
}
|
||||
let host_ruleset = rs.parse::<crate::portable::Ruleset>().map_err(|_| {
|
||||
PortableError::Invalid(format!(
|
||||
"malformed or unknown ruleset suffix in server_id: {:?}",
|
||||
self.server_id
|
||||
))
|
||||
})?;
|
||||
if offer.ruleset != host_ruleset || model.ruleset != host_ruleset {
|
||||
return Err(PortableError::RulesetMismatch {
|
||||
character: model.ruleset,
|
||||
realm: host_ruleset,
|
||||
});
|
||||
}
|
||||
}
|
||||
self.store
|
||||
|
||||
@@ -1561,6 +1561,20 @@ fn reborn_hostile_attacks_are_rejected_with_zero_mutation() {
|
||||
coll_err2.is_err(),
|
||||
"Reborn merging wildcard:vanity must fail"
|
||||
);
|
||||
|
||||
// 9. HostService with malformed server_id ruleset suffixes fails closed
|
||||
for bad_server_id in ["srv-1:unknown", "srv-1:garbage", "srv-1:reborn:extra"] {
|
||||
let mut h_bad = HostService::new(&mut host, bad_server_id, HostConfig::default());
|
||||
let mut offer_bad = OwnerService::new(&mut owner)
|
||||
.offer(id_reb, "srv-1:reborn")
|
||||
.unwrap();
|
||||
offer_bad.server_id = bad_server_id.to_string();
|
||||
let res_bad = h_bad.accept_offer(host_profile, &offer_bad);
|
||||
assert!(
|
||||
res_bad.is_err(),
|
||||
"accept_offer on malformed server id {bad_server_id} must fail closed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,62 +1,84 @@
|
||||
# Warcraft Reborn Portable Characters Audit
|
||||
# Warcraft Reborn Portable Characters Audit & Semantic State
|
||||
|
||||
## 1. Overview & Baseline vs Current Implementation
|
||||
## 1. Historical Baseline
|
||||
The released v0.7.0 master branch codebase contained foundational support for local realm execution:
|
||||
- Mode: `Mode::Reborn`
|
||||
- Realm ID: `3`
|
||||
- Databases: characters DB `acore_characters_reborn`, world DB `acore_world_reborn`, shared auth DB `acore_auth`
|
||||
- Policy: Reborn runs as the active primary realm on the main world port (8085). Simultaneous execution with CoA/Wildcard is strictly unsupported (`"Simultaneous startup supports CoA and Wildcard only."`).
|
||||
|
||||
### 1.1 Baseline Discovered Before Implementation
|
||||
The released v0.7.0 master branch codebase contained foundational support for local realm execution (`Mode::Reborn`, realm ID `3`, characters DB `acore_characters_reborn`, world DB `acore_world_reborn`, shared auth DB `acore_auth`).
|
||||
Portable character transfers into Warcraft Reborn were temporarily guarded by:
|
||||
Before the multi-ruleset implementation, portable character transfers into Warcraft Reborn were temporarily blocked by:
|
||||
```rust
|
||||
// crates/coa-core/src/portable/realm/mod.rs
|
||||
Mode::Reborn => Err(PortableError::Invalid("Portable characters are not supported on Warcraft Reborn realms yet.".into())),
|
||||
```
|
||||
|
||||
### 1.2 Implemented in 58a99f0+ and MR1.1
|
||||
- **RulesetId / Ruleset**: `Ruleset::Reborn` is established across `coa-core`, `coa-registry-proto`, `coa-control-proto`, and TypeScript models (`"reborn"` wire value, display name `"Warcraft Reborn"`).
|
||||
- **SQLite Portable Storage:** Migration `009_multiruleset.sql` upgrades released databases to permit `reborn` characters in the `character` table CHECK constraint without dropping child records.
|
||||
- **Collections:** `reborn:appearance` and `reborn:vanity` defined and carried under exact allowlist in `carried_kind_for`. Partitioned under `(profile_id, 'reborn', kind)`.
|
||||
- **Wire Protocols:** Control protocol v2, Session protocol v3, and Collection protocol v2 bind `ruleset: Ruleset` explicitly in all messages.
|
||||
## 2. Current MR3 Implementation
|
||||
In MR1.1, MR2, and MR3, generic multi-ruleset infrastructure was completed and enabled for Reborn:
|
||||
- **Ruleset Identity**: `Ruleset::Reborn` established across `coa-core`, `coa-registry-proto`, `coa-control-proto`, and frontend TypeScript models (`"reborn"` wire value, display name `"Warcraft Reborn"`).
|
||||
- **SQLite Storage**: Migration `009_multiruleset.sql` upgrades released databases to permit `reborn` in `character` CHECK constraints without dropping existing character rows or foreign keys.
|
||||
- **Collections**: `reborn:appearance` and `reborn:vanity` defined and carried under allowlist in `carried_kind_for`. Partitioned under `(profile_id, 'reborn', kind)`.
|
||||
- **Wire Protocols**: Control Protocol v2, Session Protocol v3, and Collection Protocol v2 bind `ruleset: Ruleset` explicitly in all messages.
|
||||
- **Fail-Closed Routing & Ingress**: `srv-X:reborn` resolves strictly to the Reborn realm and fails closed if inactive. HostService rejects malformed server-id ruleset suffixes.
|
||||
- **Automated Verification**: End-to-end claiming, export, snapshot import, runtime session lifecycle, intermediate/final checkpoints, and logout sync pass.
|
||||
- **Three-Space Isolation**: Torture fixtures prove zero mutation across rulesets:
|
||||
- Reborn character -> CoA / Wildcard realm (rejected with zero mutation).
|
||||
- CoA / Wildcard character -> Reborn realm (rejected with zero mutation).
|
||||
- Reborn collection rejects foreign namespaced entries (`coa:*`, `wildcard:*`).
|
||||
|
||||
### 1.3 Implemented in MR3 Vertical Slice
|
||||
- Concrete support for Warcraft Reborn character import/export, claiming, session lifecycle, checkpoints, and logout sync.
|
||||
- `ruleset_of(&reborn_db)` returns `Ok(Ruleset::Reborn)`.
|
||||
- Reborn character class formatting correctly maps vanilla/standard classes (`reborn:class:1..11`) alongside custom classes.
|
||||
- Direct ingress and routing respect active primary Reborn realm (realm ID 3, `acore_characters_reborn`, `acore_world_reborn`, port 8085) and fail closed when Reborn is inactive.
|
||||
- Frontend UI displays `RulesetBadge` with human-facing labels ("Warcraft Reborn", "Wildcard", "Conquest of Azeroth"), ruleset filter tabs in Portable Characters screen, and ruleset-filtered Character Picker in the JOIN drawer.
|
||||
- Three-space isolation fixture and torture tests prove complete mutation isolation:
|
||||
- Reborn character -> CoA realm (rejected with zero DB mutation).
|
||||
- Reborn character -> Wildcard realm (rejected with zero DB mutation).
|
||||
- CoA character -> Reborn realm (rejected with zero DB mutation).
|
||||
- Wildcard character -> Reborn realm (rejected with zero DB mutation).
|
||||
- Reborn collections (`reborn:appearance`, `reborn:vanity`) reject foreign namespaced collections.
|
||||
- Physical external-client smoke is deferred to the combined manual verification gate.
|
||||
## 3. Actual Reborn Semantic-State Audit
|
||||
|
||||
## 2. Ruleset Identity & DB Schemas
|
||||
- **RulesetId / Ruleset**:
|
||||
- `Ruleset::Reborn` exists in `crates/coa-core/src/portable/model.rs` and `crates/coa-registry-proto/src/caps.rs`.
|
||||
- Serialized string: `"reborn"`.
|
||||
- Display name: `"Warcraft Reborn"`.
|
||||
- Associated Realm ID: `3`.
|
||||
- Character Database: `acore_characters_reborn`.
|
||||
- World Database: `acore_world_reborn`.
|
||||
- Auth Database: `acore_auth` (shared across all modes).
|
||||
- **Collections**:
|
||||
- `reborn:appearance`
|
||||
- `reborn:vanity`
|
||||
- Partitioned under `(profile_id, 'reborn', kind)` in canonical SQLite store (`portable.db`).
|
||||
### 3.1 Gameplay Identity and Class Architecture
|
||||
Warcraft Reborn in this core is configured via `CoA.ClassModel = "coa wcr"` / `wcr` mode:
|
||||
- Classes in Warcraft Reborn are standard classic WoW classes (Warrior, Paladin, Hunter, Rogue, Priest, Death Knight, Shaman, Mage, Warlock, Druid; class IDs 1..11) rather than CoA custom classes (Barbarian, Witch Doctor, etc., IDs 12..32).
|
||||
- Reborn spells, class abilities, talent trees, and creature summons use dedicated spell/creature ID ranges and corrections (e.g. `REBORN_CREATURE_OFFSET = 1100000`, spell twin scripts), which operate on standard AzerothCore character persistence structures.
|
||||
- Reborn characters use the exact same character persistence schema (`acore_characters_reborn`) as vanilla/CoA character databases, cloned directly via `db.clone_structure("acore_characters", &stage_chars, &cache)` in `crates/coa-core/src/realms.rs`.
|
||||
|
||||
## 3. Simultaneous Startup & Multiworld Policy
|
||||
- As verified in `crates/coa-core/src/multiworld.rs` and `crates/coa-core/src/realms.rs`:
|
||||
- `CoA + Wildcard` simultaneous execution is supported.
|
||||
- `Reborn` simultaneous execution is explicitly unsupported (`"Simultaneous startup supports CoA and Wildcard only."`).
|
||||
- This policy is preserved strictly without changes.
|
||||
### 3.2 Table & State Classification Matrix
|
||||
|
||||
## 4. Enabling Reborn in Portable Architecture
|
||||
To complete the Reborn vertical slice with full strict isolation:
|
||||
1. Map `Mode::Reborn => Ok(Ruleset::Reborn)` in `crates/coa-core/src/portable/realm/mod.rs:ruleset_of`.
|
||||
2. Update tests verifying `ruleset_of(&reborn_db).unwrap() == Ruleset::Reborn`.
|
||||
3. Verify Reborn claiming, export, import, session runtime, checkpoints, and collection isolation.
|
||||
4. Verify non-negotiable isolation tests:
|
||||
- Reborn character -> CoA realm (rejected).
|
||||
- Reborn character -> Wildcard realm (rejected).
|
||||
- CoA character -> Reborn realm (rejected).
|
||||
- Wildcard character -> Reborn realm (rejected).
|
||||
| Category / State | Database Table(s) | Portable Character Mapping | Classification |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **Identity (name, race, class, gender)** | `characters` | `PortableCharacter.name`, `.race`, `.class`, `.gender` | **Already covered by generic PortableCharacter** |
|
||||
| **Progression (level, XP, money)** | `characters` | `Progression.level`, `.xp`, `.money` | **Already covered by generic PortableCharacter** |
|
||||
| **Inventory & Equipment** | `character_inventory`, `item_instance` | `Item` records, container links, slot indices, item entry | **Already covered by generic PortableCharacter** |
|
||||
| **Enchants, Gems, Random Properties** | `item_instance` (`enchantments`, `randomPropertyId`, `durability`) | `Item.enchantments`, `random_property_id`, `durability` | **Already covered by generic PortableCharacter** |
|
||||
| **Bags & Bank** | `character_inventory` (bag slots, bank slots) | `Item` container slots; personal bank slots | **Already covered by generic PortableCharacter** |
|
||||
| **Spells** | `character_spell` | `PortableCharacter.spells` (array of spell IDs) | **Already covered by generic PortableCharacter** |
|
||||
| **Talents** | `character_talent` | `PortableCharacter.talents` (spec, talent IDs, ranks) | **Already covered by generic PortableCharacter** |
|
||||
| **Glyphs** | `character_glyphs` | `PortableCharacter.glyphs` (spec, slot, glyph ID) | **Already covered by generic PortableCharacter** |
|
||||
| **Action Bars** | `character_action` | `PortableCharacter.actions` (button, action, type) | **Already covered by generic PortableCharacter** |
|
||||
| **Skills & Weapon proficiencies** | `character_skills` | `PortableCharacter.skills` (skill ID, value, max) | **Already covered by generic PortableCharacter** |
|
||||
| **Quests (Active & Rewarded)** | `character_queststatus`, `character_queststatus_rewarded` | `Quest` records, objective counters, rewarded list | **Already covered by generic PortableCharacter** |
|
||||
| **Reputation** | `character_reputation` | `Reputation` records (faction ID, standing, flags) | **Already covered by generic PortableCharacter** |
|
||||
| **Pets & Pet Spells** | `character_pet`, `pet_spell` | `Pet` records, entry, name, spells | **Already covered by generic PortableCharacter** |
|
||||
| **Character Settings & Keybindings** | `character_settings`, `character_account_data` | `Setting` records (source, data payload) | **Already covered by generic PortableCharacter** |
|
||||
| **Vanity / Transmog Collections** | `account_appearance_collection`, `account_vanity_collection` | `reborn:appearance`, `reborn:vanity` collections | **Account-wide Reborn collection** |
|
||||
| **Active Auras / Buffs** | `character_aura` | Not persisted in portable snapshot | **Realm-local and intentionally not portable** (refreshed on login) |
|
||||
| **Spell Cooldowns** | `character_spell_cooldown` | Not persisted in portable snapshot | **Realm-local and intentionally not portable** |
|
||||
| **Daily / Weekly Quest Resets** | `character_queststatus_daily`, `weekly`, `monthly` | Not persisted in portable snapshot | **Realm-local and intentionally not portable** |
|
||||
| **Instance Bindings** | `character_instance`, `character_homebind` | Not persisted in portable snapshot | **Realm-local and intentionally not portable** |
|
||||
| **Battleground / Arena Ratings** | `character_arena_stats`, `character_battleground_random` | Not persisted in portable snapshot | **Realm-local and intentionally not portable** |
|
||||
| **Manastorm / Challenge states** | `ascension_manastorm_*`, `coa_character_challenge` | Classified as `Blocking` if active | **Blocking** (blocks export while active challenge exists) |
|
||||
| **Custom Reborn Tables** | None exist in database | N/A | **Irrelevant** (no custom tables in `acore_characters_reborn`) |
|
||||
|
||||
### 3.3 Reborn Extension Requirement Decision
|
||||
|
||||
```text
|
||||
Reborn extension required: NO
|
||||
```
|
||||
|
||||
**Rationale:**
|
||||
1. Warcraft Reborn builds persistent character state entirely within standard AzerothCore character tables (`characters`, `character_spell`, `character_talent`, `character_action`, `character_skills`, `character_inventory`, `item_instance`, `character_reputation`, `character_pet`).
|
||||
2. There are no separate Reborn-specific character tables in `acore_characters_reborn` (the schema is identical to `acore_characters`).
|
||||
3. Reborn class abilities, spells, and talent configurations are encoded natively via standard spell IDs and talent IDs supported by `PortableCharacter.spells` and `PortableCharacter.talents`.
|
||||
4. Character settings (such as build selections or client UI configs) persist through `character_settings`, which is already captured in `PortableCharacter.settings`.
|
||||
5. Transmog and cosmetic appearance selections are synchronized cleanly via the `reborn:appearance` and `reborn:vanity` account collections.
|
||||
6. Therefore, adding a custom typed extension is not needed and would introduce redundant schema without semantic benefit. The generic `PortableCharacter` model provides 100% semantic continuity for Reborn character builds.
|
||||
|
||||
## 4. Remaining Manual Verification
|
||||
- **Combined Physical Real-Client Gate**:
|
||||
- Live client connection to a running Warcraft Reborn realm.
|
||||
- Character creation on Reborn, progression, claiming to Manager.
|
||||
- Transfer/import to a secondary Reborn realm and runtime session verification.
|
||||
- Verification that CoA and Wildcard realms remain completely isolated.
|
||||
- Status: **PENDING COMBINED MANUAL GATE**.
|
||||
@@ -7,7 +7,7 @@ have its saved realm updated immediately.
|
||||
|
||||
Accounts are shared with your other local realms. Warcraft Reborn starts with separate characters; switching profiles does not copy existing characters or progress.
|
||||
|
||||
Bots are unavailable on Warcraft Reborn. Other optional modules are experimental. Portable character transfers into Warcraft Reborn are not supported. **Portable characters remain experimental.**
|
||||
Bots are unavailable on Warcraft Reborn. Other optional modules are experimental. Portable Characters support for Warcraft Reborn is implemented experimentally. Automated isolation/import/session tests pass. Physical real-client verification is still pending the combined manual gate. **Portable characters remain experimental.**
|
||||
|
||||
Backups and updates include every created realm. Check the realm recorded in a recovery point before restoring it.
|
||||
|
||||
|
||||
@@ -888,6 +888,12 @@ const de: Partial<Record<keyof typeof en, string>> = {
|
||||
"crash.hideRaw": "Vollständigen Bericht verbergen",
|
||||
"crash.copyReport": "Bericht kopieren",
|
||||
"crash.copied": "Kopiert!",
|
||||
"portable.filter.none": "Keine Charaktere entsprechen dem ausgewählten Spielmodus.",
|
||||
"portable.filter.all": "Alle",
|
||||
"browse.hidden_characters_one": "{n} Charakter aus anderen Spielmodi ist ausgeblendet",
|
||||
"browse.hidden_characters_few": "{n} Charaktere aus anderen Spielmodi sind ausgeblendet",
|
||||
"browse.hidden_characters_many": "{n} Charaktere aus anderen Spielmodi sind ausgeblendet",
|
||||
"browse.hidden_characters_other": "{n} Charaktere aus anderen Spielmodi sind ausgeblendet",
|
||||
};
|
||||
|
||||
export default de;
|
||||
@@ -1029,6 +1029,12 @@ const en = {
|
||||
"portable.err.not_prepared": "Only a realm added from a file can be removed here.",
|
||||
"portable.err.launch": "The game could not be started. Check the client in Settings.",
|
||||
"portable.err.other": "Something went wrong. Nothing was lost.",
|
||||
"portable.filter.none": "No characters match the selected game mode.",
|
||||
"portable.filter.all": "All",
|
||||
"browse.hidden_characters_one": "{n} character from other game modes is hidden",
|
||||
"browse.hidden_characters_few": "{n} characters from other game modes are hidden",
|
||||
"browse.hidden_characters_many": "{n} characters from other game modes are hidden",
|
||||
"browse.hidden_characters_other": "{n} characters from other game modes are hidden",
|
||||
"portable.diag.title": "Diagnostics",
|
||||
"portable.diag.text": "A report for a bug report: realms, versions, sessions and recent problems. No passwords and no character data.",
|
||||
"portable.diag.copy": "Copy diagnostics",
|
||||
|
||||
@@ -888,6 +888,12 @@ const es: Partial<Record<keyof typeof en, string>> = {
|
||||
"crash.hideRaw": "Ocultar informe completo",
|
||||
"crash.copyReport": "Copiar informe",
|
||||
"crash.copied": "¡Copiado!",
|
||||
"portable.filter.none": "Ningún personaje coincide con el modo de juego seleccionado.",
|
||||
"portable.filter.all": "Todos",
|
||||
"browse.hidden_characters_one": "{n} personaje de otros modos de juego está oculto",
|
||||
"browse.hidden_characters_few": "{n} personajes de otros modos de juego están ocultos",
|
||||
"browse.hidden_characters_many": "{n} personajes de otros modos de juego están ocultos",
|
||||
"browse.hidden_characters_other": "{n} personajes de otros modos de juego están ocultos",
|
||||
};
|
||||
|
||||
export default es;
|
||||
@@ -888,6 +888,12 @@ const fr: Partial<Record<keyof typeof en, string>> = {
|
||||
"crash.hideRaw": "Masquer le rapport complet",
|
||||
"crash.copyReport": "Copier le rapport",
|
||||
"crash.copied": "Copié !",
|
||||
"portable.filter.none": "Aucun personnage ne correspond au mode de jeu sélectionné.",
|
||||
"portable.filter.all": "Tous",
|
||||
"browse.hidden_characters_one": "{n} personnage d'autres modes de jeu est masqué",
|
||||
"browse.hidden_characters_few": "{n} personnages d'autres modes de jeu sont masqués",
|
||||
"browse.hidden_characters_many": "{n} personnages d'autres modes de jeu sont masqués",
|
||||
"browse.hidden_characters_other": "{n} personnages d'autres modes de jeu sont masqués",
|
||||
};
|
||||
|
||||
export default fr;
|
||||
@@ -1011,6 +1011,12 @@ const ru: Partial<Record<keyof typeof en, string>> = {
|
||||
"portable.err.not_prepared": "Здесь можно убрать только реалм, добавленный из файла.",
|
||||
"portable.err.launch": "Не удалось запустить игру. Проверьте клиент в настройках.",
|
||||
"portable.err.other": "Что-то пошло не так. Ничего не потеряно.",
|
||||
"portable.filter.none": "Нет персонажей для выбранного игрового режима.",
|
||||
"portable.filter.all": "Все",
|
||||
"browse.hidden_characters_one": "{n} персонаж из других игровых режимов скрыт",
|
||||
"browse.hidden_characters_few": "{n} персонажа из других игровых режимов скрыто",
|
||||
"browse.hidden_characters_many": "{n} персонажей из других игровых режимов скрыто",
|
||||
"browse.hidden_characters_other": "{n} персонажей из других игровых режимов скрыто",
|
||||
"portable.diag.title": "Диагностика",
|
||||
"portable.diag.text": "Отчёт для сообщения об ошибке: реалмы, версии, сессии и недавние проблемы. Без паролей и без данных персонажей.",
|
||||
"portable.diag.copy": "Скопировать диагностику",
|
||||
|
||||
@@ -890,6 +890,12 @@ const zh = {
|
||||
"crash.hideRaw": "隐藏完整日志",
|
||||
"crash.copyReport": "复制崩溃报告",
|
||||
"crash.copied": "已复制!",
|
||||
"portable.filter.none": "没有符合所选游戏模式的角色。",
|
||||
"portable.filter.all": "全部",
|
||||
"browse.hidden_characters_one": "已隐藏 {n} 个其他游戏模式的角色",
|
||||
"browse.hidden_characters_few": "已隐藏 {n} 个其他游戏模式的角色",
|
||||
"browse.hidden_characters_many": "已隐藏 {n} 个其他游戏模式的角色",
|
||||
"browse.hidden_characters_other": "已隐藏 {n} 个其他游戏模式的角色",
|
||||
} as const;
|
||||
|
||||
export default zh;
|
||||
@@ -68,7 +68,7 @@ function SortHeader({ id, sort, order, onSort, children, className }: { id: Sort
|
||||
}
|
||||
|
||||
function Drawer({ summary, catalog, onClose }: { summary: RealmSummary; catalog: Catalog; onClose: () => void }) {
|
||||
const { t, locale } = useI18n();
|
||||
const { t, tn, locale } = useI18n();
|
||||
const { state, refresh: refreshPortable } = usePortable();
|
||||
const [detail, setDetail] = useState<RealmDetail | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -159,7 +159,7 @@ function Drawer({ summary, catalog, onClose }: { summary: RealmSummary; catalog:
|
||||
<p className="text-muted">{t("browse.noCharacter")}</p>
|
||||
{hiddenCount > 0 && (
|
||||
<p className="text-xs text-muted">
|
||||
{hiddenCount} {hiddenCount === 1 ? "character" : "characters"} from other game modes {hiddenCount === 1 ? "is" : "are"} hidden
|
||||
{tn("browse.hidden_characters", hiddenCount)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -171,7 +171,7 @@ function Drawer({ summary, catalog, onClose }: { summary: RealmSummary; catalog:
|
||||
</select>
|
||||
{hiddenCount > 0 && (
|
||||
<p className="text-xs text-muted">
|
||||
{hiddenCount} {hiddenCount === 1 ? "character" : "characters"} from other game modes {hiddenCount === 1 ? "is" : "are"} hidden
|
||||
{tn("browse.hidden_characters", hiddenCount)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -318,7 +318,7 @@ export function PortablePage({ onOpenSettings }: { onOpenSettings?: () => void }
|
||||
rulesetFilter === "" ? "bg-white/10 text-ink shadow-sm" : "text-muted hover:text-ink"
|
||||
)}
|
||||
>
|
||||
{t("browse.any")}
|
||||
{t("portable.filter.all")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -355,7 +355,7 @@ export function PortablePage({ onOpenSettings }: { onOpenSettings?: () => void }
|
||||
{characters.length === 0 && <Card className="mt-3 p-5 text-sm text-muted">{t("portable.characters.none")}</Card>}
|
||||
{characters.length > 0 && filteredCharacters.length === 0 && (
|
||||
<Card className="mt-3 p-5 text-sm text-muted">
|
||||
No characters match the selected ruleset filter.
|
||||
{t("portable.filter.none")}
|
||||
</Card>
|
||||
)}
|
||||
<div className="mt-3 space-y-4">
|
||||
|
||||
Reference in new issue
Block a user