Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 38e4c0341e | |||
| ccf280ea50 |
@@ -1,167 +0,0 @@
|
|||||||
# Integrating `card_game` / `klondike` as the Solitaire Core
|
|
||||||
|
|
||||||
**Context:** A collaborator ([Quaternions](https://git.aleshym.co/Quaternions/card_game)) is building a pure-logic Klondike library in Rust. This document maps what that library currently provides against what Ferrous Solitaire's `solitaire_core` crate requires.
|
|
||||||
|
|
||||||
**Approach:** Most gaps are closed in Ferrous Solitaire's own `solitaire_core` crate via a wrapper/adapter layer. Gaps 1, 3, and 4 have been addressed upstream. Integration is ready to begin.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What `card_game` + `klondike` Already Has
|
|
||||||
|
|
||||||
### `card_game` crate (generic primitives) — v0.4.0
|
|
||||||
| Feature | Notes |
|
|
||||||
|---|---|
|
|
||||||
| `Card` (Deck + Suit + Rank packed in 1 byte) | `NonZeroU8` layout — no heap allocation |
|
|
||||||
| `Suit`, `Rank`, `Deck` enums | Full A→K, 4 suits, up to 4 deck IDs |
|
|
||||||
| `Stack<CAP>` | Const-generic `ArrayVec` wrapper |
|
|
||||||
| `Pile<DN, UP>` | Face-down + face-up stacks; `flip_up`, `pop_flip_up` |
|
|
||||||
| `Game` trait | `possible_instructions`, `is_instruction_valid`, `process_instruction`, `is_win` |
|
|
||||||
| `Session` | Wraps a `Game`; snapshot-based undo (O(1)), score including undo penalty |
|
|
||||||
| `Session::solve()` | Built-in DFS solver with move/state budgets; returns `Solution<G>` or `SolveError` |
|
|
||||||
| `StateSnapshot<G>` | Pre-move state + instruction; used by snapshot history and `Solution` |
|
|
||||||
| `SessionState::score()` | = `game_score + undos × undo_penalty` (−15 by default via `SessionConfig`) |
|
|
||||||
| `SessionConfig` | `undo_penalty`, `solve_moves_budget`, `solve_states_budget` |
|
|
||||||
|
|
||||||
### `klondike` crate (Klondike rules) — v0.3.0
|
|
||||||
| Feature | Notes |
|
|
||||||
|---|---|
|
|
||||||
| 7 tableau + 4 foundation + 1 stock | Fully dealt from a seeded RNG |
|
|
||||||
| Draw-1 / Draw-3 config | `KlondikeConfig::draw_stock` (`DrawStockConfig`) |
|
|
||||||
| `MoveFromFoundationConfig` | `Allowed` (upstream default) / `Disallowed`; controls foundation → tableau rule |
|
|
||||||
| `ScoringConfig` | Configurable deltas: `move_to_foundation` (+10), `flip_up_bonus` (+5), `move_to_tableau` (+5), `move_from_foundation` (−15), `recycle` (0 by default) |
|
|
||||||
| `KlondikeStats::score(&config)` | Computes score from per-event counters × `ScoringConfig` deltas |
|
|
||||||
| `KlondikeStats` counters | `move_to_foundation_count`, `flip_up_bonus_count`, `move_to_tableau_count`, `move_from_foundation_count`, `recycle_count`, `moves` |
|
|
||||||
| Foundation placement (Ace start, suit-matched A→K) | ✅ |
|
|
||||||
| Tableau placement (alternating colour, K on empty) | ✅ |
|
|
||||||
| Multi-card stack moves (via `SkipCards`) | ✅ |
|
|
||||||
| `RotateStock` (recycle waste → stock) | ✅ |
|
|
||||||
| `is_win_trivial` (all face-down cards cleared) | Auto-complete trigger |
|
|
||||||
| `get_auto_move` / `get_sorted_moves` | Priority-ranked move suggestion (take `&KlondikeConfig`) |
|
|
||||||
| Benchmark suite (`klondike-bench`) | 1 000-game throughput test |
|
|
||||||
| CLI display (`klondike-cli`) | Terminal renderer |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What Ferrous Solitaire's `solitaire_core` Needs (Gaps)
|
|
||||||
|
|
||||||
### 1. Scoring — remaining adapter responsibilities
|
|
||||||
Ferrous uses **Windows XP Standard** scoring. The exact table already implemented in `solitaire_core/src/scoring.rs`:
|
|
||||||
|
|
||||||
| Event | Delta | Handled by |
|
|
||||||
|---|---|---|
|
|
||||||
| Any card → foundation | +10 | `KlondikeStats` / `ScoringConfig::move_to_foundation` ✅ |
|
|
||||||
| Waste → tableau | +5 | `KlondikeStats` / `ScoringConfig::move_to_tableau` ✅ |
|
|
||||||
| Flip face-down tableau card | +5 | `KlondikeStats` / `ScoringConfig::flip_up_bonus` ✅ |
|
|
||||||
| Foundation → tableau | −15 | `KlondikeStats` / `ScoringConfig::move_from_foundation` ✅ |
|
|
||||||
| Undo | −15 | `SessionStats` / `SessionConfig::undo_penalty` ✅ |
|
|
||||||
| Recycle (Draw-1, after 1st free) | −100 | **Our adapter** — see below |
|
|
||||||
| Recycle (Draw-3, after 3rd free) | −20 | **Our adapter** — see below |
|
|
||||||
| Score floor | `score.max(0)` always | **Our adapter** |
|
|
||||||
| Time bonus on win | `700_000 / elapsed_seconds` | **Our adapter** (not wasm-portable) |
|
|
||||||
|
|
||||||
Reference: <https://www.solitaireparadise.com/games_list/klondike_solitaire_scoring.html>
|
|
||||||
|
|
||||||
**Undo penalty:** `SessionState::score()` = `KlondikeStats.score(&scoring) + undos × undo_penalty`. The −15 undo penalty is built into `SessionConfig` (default). Once `GameState` fully delegates to `Session`, our `KlondikeAdapter::score_for_undo()` helper becomes redundant.
|
|
||||||
|
|
||||||
**Recycle penalty note:** `ScoringConfig::recycle` is a flat delta (default 0 = always free). WXP allows a fixed number of free recycles before charging a penalty, which the upstream library cannot express with a single delta. Our adapter tracks `recycle_count` from `KlondikeStats` and applies the penalty only beyond the free allowance.
|
|
||||||
|
|
||||||
**In our wrapper:** Configure `ScoringConfig` with the WXP deltas for the five events upstream handles (including undo via `SessionConfig`). Implement recycle-with-free-allowance, score floor, and time bonus in the adapter.
|
|
||||||
|
|
||||||
### 2. Game Modes
|
|
||||||
Ferrous has three modes that alter scoring and undo behaviour:
|
|
||||||
|
|
||||||
| Mode | Scoring | Undo |
|
|
||||||
|---|---|---|
|
|
||||||
| **Classic** | Full WXP scoring (table above) | Allowed (−15 penalty) |
|
|
||||||
| **Zen** | All deltas suppressed — score stays 0 | Allowed (no penalty) |
|
|
||||||
| **Challenge** | Full WXP scoring | **Disabled** — returns an error |
|
|
||||||
|
|
||||||
Zen is intended for relaxed play where the score does not matter. Challenge is a timed daily puzzle where the no-undo constraint is the difficulty mechanic.
|
|
||||||
|
|
||||||
**In our wrapper:** Add `GameMode` to `solitaire_core::GameState`; intercept undo calls and scoring deltas in the adapter before delegating to `KlondikeState`.
|
|
||||||
|
|
||||||
### 3. Solvability Solver *(upstream merged — card_game v0.4.0)*
|
|
||||||
`card_game v0.4.0` ships `Session::solve()` — a budget-bounded DFS that returns `Result<Option<Solution<G>>, SolveError>`. `SolveError` has two variants:
|
|
||||||
- `MovesBudgetExceeded` — equivalent to our `SolverResult::Inconclusive`
|
|
||||||
- `StatesBudgetExceeded` — equivalent to our `SolverResult::Inconclusive`
|
|
||||||
|
|
||||||
`Solution<G>` contains the winning move sequence as `Vec<StateSnapshot<G>>`; `clean_solution()` removes cycles. `Session::solve()` uses `SessionConfig::solve_moves_budget` and `SessionConfig::solve_states_budget` (defaults: 100 000 each).
|
|
||||||
|
|
||||||
Our 767-line `solitaire_core::solver` reimplements the full game rules to run the DFS; `session.solve()` replaces it entirely. The solver will be removed once the `Session<Klondike>` is wired into `GameState`.
|
|
||||||
|
|
||||||
**In our wrapper:** Replace `solitaire_core::solver` with `session.solve()`. Map `Ok(Some(_))` → Winnable, `Ok(None)` → Unwinnable, `Err(_)` → Inconclusive.
|
|
||||||
|
|
||||||
### 4. `take_from_foundation` House Rule *(upstream merged — v0.3.0)*
|
|
||||||
`MoveFromFoundationConfig` is now part of `KlondikeConfig`. When set to `Disallowed`, `is_instruction_valid` blocks foundation → tableau instructions.
|
|
||||||
|
|
||||||
**Important:** The upstream default is `MoveFromFoundationConfig::Allowed`. Ferrous Solitaire uses the standard rule (foundation cards cannot be moved back) as the default, with the house rule as an opt-in. Our adapter explicitly sets `Disallowed` in the default `KlondikeConfig` and switches to `Allowed` only when the user toggles the house-rule option.
|
|
||||||
|
|
||||||
**In our wrapper:** Construct `KlondikeConfig { move_from_foundation: MoveFromFoundationConfig::Disallowed, .. }` by default; mirror the user's settings toggle to `Allowed`. No custom intercept needed — `klondike` enforces the rule automatically.
|
|
||||||
|
|
||||||
### 5. JSON Serialisation / Persistence
|
|
||||||
`solitaire_core::GameState` serialises the full mid-game state to JSON via `serde` so the engine can save on exit and restore on launch. `KlondikeState` derives `Clone` + `Eq` + `Hash` but not `Serialize` / `Deserialize`. No upstream changes are needed — this is handled externally.
|
|
||||||
|
|
||||||
**Session history:** `StateSnapshot<G>` stores the pre-move game state and instruction. On load, the session is reconstructed from the serialised snapshot history — no full replay from seed needed.
|
|
||||||
|
|
||||||
**In our wrapper:** Serialise the `solitaire_core` wrapper struct using newtypes. Define `SavedInstruction` (a `Serialize + Deserialize` mirror of `KlondikeInstruction`) and `SavedStateSnapshot`. Reconstruct `SessionState` from the deserialised history. Schema version field lives on our wrapper.
|
|
||||||
|
|
||||||
### 6. Typed Move Errors
|
|
||||||
`solitaire_core::error::MoveError` returns structured errors the engine uses to trigger UI feedback (wrong-destination toast, stock-empty chime, etc.):
|
|
||||||
|
|
||||||
```
|
|
||||||
GameAlreadyWon
|
|
||||||
UndoStackEmpty
|
|
||||||
StockEmpty
|
|
||||||
InvalidSource
|
|
||||||
InvalidDestination
|
|
||||||
RuleViolation(String)
|
|
||||||
```
|
|
||||||
|
|
||||||
`KlondikeInstruction` is always constructed by game code from valid entity layout, so invalid moves are only detectable at `solitaire_core`'s construction boundary — the error lives there, not inside `klondike`.
|
|
||||||
|
|
||||||
**In our wrapper:** `MoveError` variants are generated when `solitaire_core` fails to construct a `KlondikeInstruction` from the player's requested move. No translation of `is_instruction_valid`'s bool return is required; by the time an instruction reaches `klondike`, it is already known to be structurally valid.
|
|
||||||
|
|
||||||
### 7. Waste Pile as Separate Concept
|
|
||||||
Ferrous tracks `PileType::Waste` as a distinct pile. `klondike` folds waste into `Stock` (the face-up half of the stock `Pile`). The engine's UI and scoring logic reference the waste pile directly; the mapping needs to be explicit.
|
|
||||||
|
|
||||||
**In our wrapper:** Project the face-up half of `klondike`'s stock `Pile` as `PileType::Waste` when building pile snapshots for the engine.
|
|
||||||
|
|
||||||
### 8. Undo Stack Approach *(resolved — not an issue)*
|
|
||||||
`card_game v0.4.0` `Session` uses snapshot-based undo: `SessionState` stores `Vec<StateSnapshot<G>>` where each entry holds the pre-move game state and the instruction. Undo pops the last snapshot and restores state directly — O(1), matching our existing `GameState.undo_stack`.
|
|
||||||
|
|
||||||
**Resolution:** Use `Session`'s built-in snapshot history. Our `GameState.undo_stack: VecDeque<StateSnapshot>` will be removed once `GameState` is fully migrated to delegate to `Session`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Integration Path (All work in `solitaire_core`)
|
|
||||||
|
|
||||||
Steps in dependency order. Upstream issues #10, #11, and the solver are all merged.
|
|
||||||
|
|
||||||
1. ✅ **Add `klondike = "0.3.0"` / `card_game = "0.4.0"` as dependencies** of `solitaire_core`; `KlondikeAdapter` wraps `KlondikeConfig` and exposes scoring helpers.
|
|
||||||
2. **Map pile types** — project `klondike`'s stock face-up half as `PileType::Waste`; expose the same `HashMap<PileType, Pile>` the engine already reads. Wire `Session<Klondike>` into `KlondikeAdapter` (gap 7).
|
|
||||||
3. ✅ **Configure `KlondikeConfig`** — set `move_from_foundation: MoveFromFoundationConfig::Disallowed` by default; wire the user's house-rule toggle to `Allowed` (gap 4, upstream).
|
|
||||||
4. ✅ **Port scoring** — pass WXP deltas into `ScoringConfig`; `SessionConfig::undo_penalty` handles undo; implement recycle-with-free-allowance, score floor, and time bonus in the adapter (gap 1).
|
|
||||||
5. ✅ **Port `GameMode`** — intercept undo + scoring in the adapter based on mode (gap 2).
|
|
||||||
6. **Replace solver** — call `session.solve()` with budgets from our `SolverConfig`; map `Ok(Some)` → Winnable, `Ok(None)` → Unwinnable, `Err` → Inconclusive (gap 3, upstream).
|
|
||||||
7. **Implement `serde`** — define `SavedInstruction` + `SavedStateSnapshot` newtypes; serialise session history; migrate save-file schema (gap 5).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What Does NOT Need to Change
|
|
||||||
|
|
||||||
- The `solitaire_engine` Bevy layer — it works against `solitaire_core` types; changes are isolated to `solitaire_core`.
|
|
||||||
- The `solitaire_sync` merge logic — operates on a `SyncPayload` DTO, independent of core card types.
|
|
||||||
- The `solitaire_server` — speaks only `SyncPayload` JSON, unaffected.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- Quaternions' repo: <https://git.aleshym.co/Quaternions/card_game>
|
|
||||||
- `card_game v0.4.0` release commit: `fa098f0d`
|
|
||||||
- `klondike v0.3.0` release commit: `f4c4e350`
|
|
||||||
- Upstream scoring + config PRs: #12 (closes #11), #13 (closes #10)
|
|
||||||
- Upstream solver PR: #14
|
|
||||||
- `solitaire_core` source: `solitaire_core/src/`
|
|
||||||
- Scoring spec: `solitaire_core/src/scoring.rs`
|
|
||||||
- Architecture overview: `ARCHITECTURE.md`
|
|
||||||
@@ -172,13 +172,16 @@ fn build_app_with_settings(
|
|||||||
// a 1-second ceiling when the app is backgrounded cuts wake-up frequency
|
// a 1-second ceiling when the app is backgrounded cuts wake-up frequency
|
||||||
// from ~60 Hz to ≤1 Hz, dramatically reducing background battery drain.
|
// from ~60 Hz to ≤1 Hz, dramatically reducing background battery drain.
|
||||||
//
|
//
|
||||||
// The focused mode stays Continuous so that card-slide animations remain
|
// focused_mode uses reactive_low_power(100 ms) so the CPU only wakes when
|
||||||
// smooth. PresentMode::AutoVsync (set above) keeps the GPU capped at the
|
// an event arrives (touch, resize, etc.) or an animation system writes
|
||||||
// display refresh rate (~60 Hz) when foregrounded, which already prevents
|
// RequestRedraw. The 100 ms ceiling is a fallback that ensures the game
|
||||||
// the GPU from spinning at 200+ fps between vsync intervals.
|
// timer ticks at least 10×/s even with no input, while keeping the GPU
|
||||||
|
// completely idle between frames when the board is static.
|
||||||
|
// PresentMode::AutoVsync (set above) still caps the GPU at the display
|
||||||
|
// refresh rate when frames do render.
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
app.insert_resource(WinitSettings {
|
app.insert_resource(WinitSettings {
|
||||||
focused_mode: UpdateMode::Continuous,
|
focused_mode: UpdateMode::reactive_low_power(std::time::Duration::from_millis(100)),
|
||||||
unfocused_mode: UpdateMode::reactive_low_power(std::time::Duration::from_secs(1)),
|
unfocused_mode: UpdateMode::reactive_low_power(std::time::Duration::from_secs(1)),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
use bevy::window::RequestRedraw;
|
||||||
use solitaire_data::{AnimSpeed, Settings};
|
use solitaire_data::{AnimSpeed, Settings};
|
||||||
|
|
||||||
use crate::achievement_plugin::display_name_for;
|
use crate::achievement_plugin::display_name_for;
|
||||||
@@ -180,6 +181,7 @@ impl Plugin for AnimationPlugin {
|
|||||||
.add_message::<MoveRejectedEvent>()
|
.add_message::<MoveRejectedEvent>()
|
||||||
.add_message::<WarningToastEvent>()
|
.add_message::<WarningToastEvent>()
|
||||||
.add_message::<XpAwardedEvent>()
|
.add_message::<XpAwardedEvent>()
|
||||||
|
.add_message::<RequestRedraw>()
|
||||||
.init_resource::<EffectiveSlideDuration>()
|
.init_resource::<EffectiveSlideDuration>()
|
||||||
.init_resource::<ToastQueue>()
|
.init_resource::<ToastQueue>()
|
||||||
.init_resource::<ActiveToast>()
|
.init_resource::<ActiveToast>()
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
//! returns `None` (e.g. a transient state), the plugin retries next tick.
|
//! returns `None` (e.g. a transient state), the plugin retries next tick.
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
use bevy::window::RequestRedraw;
|
||||||
|
|
||||||
use crate::audio_plugin::{AudioState, SoundLibrary};
|
use crate::audio_plugin::{AudioState, SoundLibrary};
|
||||||
use crate::events::{MoveRequestEvent, StateChangedEvent};
|
use crate::events::{MoveRequestEvent, StateChangedEvent};
|
||||||
@@ -39,7 +40,9 @@ pub struct AutoCompletePlugin;
|
|||||||
|
|
||||||
impl Plugin for AutoCompletePlugin {
|
impl Plugin for AutoCompletePlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.init_resource::<AutoCompleteState>().add_systems(
|
app.init_resource::<AutoCompleteState>()
|
||||||
|
.add_message::<RequestRedraw>()
|
||||||
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
detect_auto_complete,
|
detect_auto_complete,
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ pub use timing::{
|
|||||||
pub use tuning::{AnimationTuning, InputPlatform};
|
pub use tuning::{AnimationTuning, InputPlatform};
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
use bevy::window::RequestRedraw;
|
||||||
|
|
||||||
use crate::card_plugin::CardEntity;
|
use crate::card_plugin::CardEntity;
|
||||||
use crate::events::{DrawRequestEvent, GameWonEvent, MoveRequestEvent, UndoRequestEvent};
|
use crate::events::{DrawRequestEvent, GameWonEvent, MoveRequestEvent, UndoRequestEvent};
|
||||||
@@ -125,6 +126,7 @@ impl Plugin for CardAnimationPlugin {
|
|||||||
.add_message::<DrawRequestEvent>()
|
.add_message::<DrawRequestEvent>()
|
||||||
.add_message::<UndoRequestEvent>()
|
.add_message::<UndoRequestEvent>()
|
||||||
.add_message::<GameWonEvent>()
|
.add_message::<GameWonEvent>()
|
||||||
|
.add_message::<RequestRedraw>()
|
||||||
.init_resource::<DragState>()
|
.init_resource::<DragState>()
|
||||||
.init_resource::<HoverState>()
|
.init_resource::<HoverState>()
|
||||||
.init_resource::<InputBuffer>()
|
.init_resource::<InputBuffer>()
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ use std::f32::consts::PI;
|
|||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
use bevy::window::RequestRedraw;
|
||||||
use solitaire_core::pile::PileType;
|
use solitaire_core::pile::PileType;
|
||||||
use solitaire_data::AnimSpeed;
|
use solitaire_data::AnimSpeed;
|
||||||
|
|
||||||
@@ -204,6 +205,7 @@ impl Plugin for FeedbackAnimPlugin {
|
|||||||
.add_message::<MoveRejectedEvent>()
|
.add_message::<MoveRejectedEvent>()
|
||||||
.add_message::<NewGameRequestEvent>()
|
.add_message::<NewGameRequestEvent>()
|
||||||
.add_message::<FoundationCompletedEvent>()
|
.add_message::<FoundationCompletedEvent>()
|
||||||
|
.add_message::<RequestRedraw>()
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -191,6 +191,7 @@ fn toggle_leaderboard_screen(
|
|||||||
keys: Res<ButtonInput<KeyCode>>,
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
mut requests: MessageReader<ToggleLeaderboardRequestEvent>,
|
mut requests: MessageReader<ToggleLeaderboardRequestEvent>,
|
||||||
screens: Query<Entity, With<LeaderboardScreen>>,
|
screens: Query<Entity, With<LeaderboardScreen>>,
|
||||||
|
other_modal_scrims: Query<(), (With<ModalScrim>, Without<LeaderboardScreen>)>,
|
||||||
data: Res<LeaderboardResource>,
|
data: Res<LeaderboardResource>,
|
||||||
provider: Option<Res<SyncProviderResource>>,
|
provider: Option<Res<SyncProviderResource>>,
|
||||||
settings: Option<Res<SettingsResource>>,
|
settings: Option<Res<SettingsResource>>,
|
||||||
@@ -208,6 +209,11 @@ fn toggle_leaderboard_screen(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Don't stack a second modal scrim over one that is already open.
|
||||||
|
if !other_modal_scrims.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Spawn the panel immediately with whatever data we have so far.
|
// Spawn the panel immediately with whatever data we have so far.
|
||||||
let remote_available = provider
|
let remote_available = provider
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
|||||||
Reference in New Issue
Block a user