Compare commits
97 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f6e7de1093 | |||
| b5a780ddf4 | |||
| 3322fd4250 | |||
| 90eb5fd207 | |||
| 76cf41e7a9 | |||
| fae5933d29 | |||
| 6cd8c6c013 | |||
| ec94cb34aa | |||
| 40768f3b0a | |||
| 2186f55913 | |||
| e0f369d322 | |||
| ea98774ccb | |||
| ea9dd848fd | |||
| a328059933 | |||
| 18659d19d1 | |||
| 7840ef9eb2 | |||
| 6d061d23a1 | |||
| 25f22231a6 | |||
| c66ff26d1d | |||
| cd792b20b2 | |||
| 73c7f50f74 | |||
| 83c40116af | |||
| 347d5a4b4f | |||
| 93f2ceaabe | |||
| e390b72222 | |||
| 3650788dc5 | |||
| 39cf8dcd6c | |||
| 456b4d42e3 | |||
| e1c8ae0743 | |||
| 8f86d66ffe | |||
| 87aec5bdf2 | |||
| 6f5cebdb02 | |||
| 9c96e2fade | |||
| eb6c93fb55 | |||
| 4aafc0a53d | |||
| c8878d6e8b | |||
| 2e52f544f1 | |||
| 2301cc65d3 | |||
| 0ecc1a92fd | |||
| 132fea911c | |||
| 18d7937b51 | |||
| fa84152429 | |||
| ffed6b27e9 | |||
| 7fc98f8801 | |||
| a4dfb0c6db | |||
| 67271266e1 | |||
| aa7b0f6eed | |||
| 69c6e88188 | |||
| 1eb40433a9 | |||
| f8f1f26d64 | |||
| 3bb3ddb6f8 | |||
| d3d8094ebb | |||
| 04e99a8d24 | |||
| 980312c22c | |||
| 9623bdeede | |||
| 4df13695fc | |||
| df22338c8a | |||
| 7f450aab17 | |||
| d8f67dcad3 | |||
| ccb77f76b8 | |||
| da54faf8e2 | |||
| f3d01b5890 | |||
| faefca0445 | |||
| 24d83c9ae3 | |||
| 9d4234cded | |||
| e48f652454 | |||
| c24c7f6b61 | |||
| 686f57252c | |||
| 059af2ac28 | |||
| 858012d926 | |||
| f6be961419 | |||
| 8a145154db | |||
| e17667d034 | |||
| 005e29d1ab | |||
| 9d3cc94831 | |||
| a9285ccb41 | |||
| 648c3ed11d | |||
| 102506f799 | |||
| 9b00af29d9 | |||
| ea28121675 | |||
| ba17c026a3 | |||
| 6cedf36b01 | |||
| eb0831893d | |||
| ad9ac9c7bb | |||
| 5f9f2745f9 | |||
| a18bcb84d3 | |||
| d5c7a149cb | |||
| fceb2be381 | |||
| d761a150d7 | |||
| d105fee319 | |||
| 94c68a46a4 | |||
| 58f33da6bf | |||
| 1b3fcca0d5 | |||
| 4e480d7cb5 | |||
| 42a0a0bb8a | |||
| ca5d8a9c55 | |||
| 48befd7e9b |
@@ -0,0 +1,7 @@
|
|||||||
|
# Claude Flow runtime files
|
||||||
|
data/
|
||||||
|
logs/
|
||||||
|
sessions/
|
||||||
|
neural/
|
||||||
|
*.log
|
||||||
|
*.tmp
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
# RuFlo V3 - Complete Capabilities Reference
|
||||||
|
> Generated: 2026-05-19T00:18:20.864Z
|
||||||
|
> Full documentation: https://github.com/ruvnet/claude-flow
|
||||||
|
|
||||||
|
## 📋 Table of Contents
|
||||||
|
|
||||||
|
1. [Overview](#overview)
|
||||||
|
2. [Swarm Orchestration](#swarm-orchestration)
|
||||||
|
3. [Available Agents (60+)](#available-agents)
|
||||||
|
4. [CLI Commands (26 Commands, 140+ Subcommands)](#cli-commands)
|
||||||
|
5. [Hooks System (27 Hooks + 12 Workers)](#hooks-system)
|
||||||
|
6. [Memory & Intelligence (RuVector)](#memory--intelligence)
|
||||||
|
7. [Hive-Mind Consensus](#hive-mind-consensus)
|
||||||
|
8. [Performance Targets](#performance-targets)
|
||||||
|
9. [Integration Ecosystem](#integration-ecosystem)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
RuFlo V3 is a domain-driven design architecture for multi-agent AI coordination with:
|
||||||
|
|
||||||
|
- **15-Agent Swarm Coordination** with hierarchical and mesh topologies
|
||||||
|
- **HNSW Vector Search** - 150x-12,500x faster pattern retrieval
|
||||||
|
- **SONA Neural Learning** - Self-optimizing with <0.05ms adaptation
|
||||||
|
- **Byzantine Fault Tolerance** - Queen-led consensus mechanisms
|
||||||
|
- **MCP Server Integration** - Model Context Protocol support
|
||||||
|
|
||||||
|
### Current Configuration
|
||||||
|
| Setting | Value |
|
||||||
|
|---------|-------|
|
||||||
|
| Topology | hierarchical-mesh |
|
||||||
|
| Max Agents | 15 |
|
||||||
|
| Memory Backend | hybrid |
|
||||||
|
| HNSW Indexing | Enabled |
|
||||||
|
| Neural Learning | Enabled |
|
||||||
|
| LearningBridge | Enabled (SONA + ReasoningBank) |
|
||||||
|
| Knowledge Graph | Enabled (PageRank + Communities) |
|
||||||
|
| Agent Scopes | Enabled (project/local/user) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Swarm Orchestration
|
||||||
|
|
||||||
|
### Topologies
|
||||||
|
| Topology | Description | Best For |
|
||||||
|
|----------|-------------|----------|
|
||||||
|
| `hierarchical` | Queen controls workers directly | Anti-drift, tight control |
|
||||||
|
| `mesh` | Fully connected peer network | Distributed tasks |
|
||||||
|
| `hierarchical-mesh` | V3 hybrid (recommended) | 10+ agents |
|
||||||
|
| `ring` | Circular communication | Sequential workflows |
|
||||||
|
| `star` | Central coordinator | Simple coordination |
|
||||||
|
| `adaptive` | Dynamic based on load | Variable workloads |
|
||||||
|
|
||||||
|
### Strategies
|
||||||
|
- `balanced` - Even distribution across agents
|
||||||
|
- `specialized` - Clear roles, no overlap (anti-drift)
|
||||||
|
- `adaptive` - Dynamic task routing
|
||||||
|
|
||||||
|
### Quick Commands
|
||||||
|
```bash
|
||||||
|
# Initialize swarm
|
||||||
|
npx @claude-flow/cli@latest swarm init --topology hierarchical --max-agents 8 --strategy specialized
|
||||||
|
|
||||||
|
# Check status
|
||||||
|
npx @claude-flow/cli@latest swarm status
|
||||||
|
|
||||||
|
# Monitor activity
|
||||||
|
npx @claude-flow/cli@latest swarm monitor
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Available Agents
|
||||||
|
|
||||||
|
### Core Development (5)
|
||||||
|
`coder`, `reviewer`, `tester`, `planner`, `researcher`
|
||||||
|
|
||||||
|
### V3 Specialized (4)
|
||||||
|
`security-architect`, `security-auditor`, `memory-specialist`, `performance-engineer`
|
||||||
|
|
||||||
|
### Swarm Coordination (5)
|
||||||
|
`hierarchical-coordinator`, `mesh-coordinator`, `adaptive-coordinator`, `collective-intelligence-coordinator`, `swarm-memory-manager`
|
||||||
|
|
||||||
|
### Consensus & Distributed (7)
|
||||||
|
`byzantine-coordinator`, `raft-manager`, `gossip-coordinator`, `consensus-builder`, `crdt-synchronizer`, `quorum-manager`, `security-manager`
|
||||||
|
|
||||||
|
### Performance & Optimization (5)
|
||||||
|
`perf-analyzer`, `performance-benchmarker`, `task-orchestrator`, `memory-coordinator`, `smart-agent`
|
||||||
|
|
||||||
|
### GitHub & Repository (9)
|
||||||
|
`github-modes`, `pr-manager`, `code-review-swarm`, `issue-tracker`, `release-manager`, `workflow-automation`, `project-board-sync`, `repo-architect`, `multi-repo-swarm`
|
||||||
|
|
||||||
|
### SPARC Methodology (6)
|
||||||
|
`sparc-coord`, `sparc-coder`, `specification`, `pseudocode`, `architecture`, `refinement`
|
||||||
|
|
||||||
|
### Specialized Development (8)
|
||||||
|
`backend-dev`, `mobile-dev`, `ml-developer`, `cicd-engineer`, `api-docs`, `system-architect`, `code-analyzer`, `base-template-generator`
|
||||||
|
|
||||||
|
### Testing & Validation (2)
|
||||||
|
`tdd-london-swarm`, `production-validator`
|
||||||
|
|
||||||
|
### Agent Routing by Task
|
||||||
|
| Task Type | Recommended Agents | Topology |
|
||||||
|
|-----------|-------------------|----------|
|
||||||
|
| Bug Fix | researcher, coder, tester | mesh |
|
||||||
|
| New Feature | coordinator, architect, coder, tester, reviewer | hierarchical |
|
||||||
|
| Refactoring | architect, coder, reviewer | mesh |
|
||||||
|
| Performance | researcher, perf-engineer, coder | hierarchical |
|
||||||
|
| Security | security-architect, auditor, reviewer | hierarchical |
|
||||||
|
| Docs | researcher, api-docs | mesh |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CLI Commands
|
||||||
|
|
||||||
|
### Core Commands (12)
|
||||||
|
| Command | Subcommands | Description |
|
||||||
|
|---------|-------------|-------------|
|
||||||
|
| `init` | 4 | Project initialization |
|
||||||
|
| `agent` | 8 | Agent lifecycle management |
|
||||||
|
| `swarm` | 6 | Multi-agent coordination |
|
||||||
|
| `memory` | 11 | AgentDB with HNSW search |
|
||||||
|
| `mcp` | 9 | MCP server management |
|
||||||
|
| `task` | 6 | Task assignment |
|
||||||
|
| `session` | 7 | Session persistence |
|
||||||
|
| `config` | 7 | Configuration |
|
||||||
|
| `status` | 3 | System monitoring |
|
||||||
|
| `workflow` | 6 | Workflow templates |
|
||||||
|
| `hooks` | 17 | Self-learning hooks |
|
||||||
|
| `hive-mind` | 6 | Consensus coordination |
|
||||||
|
|
||||||
|
### Advanced Commands (14)
|
||||||
|
| Command | Subcommands | Description |
|
||||||
|
|---------|-------------|-------------|
|
||||||
|
| `daemon` | 5 | Background workers |
|
||||||
|
| `neural` | 5 | Pattern training |
|
||||||
|
| `security` | 6 | Security scanning |
|
||||||
|
| `performance` | 5 | Profiling & benchmarks |
|
||||||
|
| `providers` | 5 | AI provider config |
|
||||||
|
| `plugins` | 5 | Plugin management |
|
||||||
|
| `deployment` | 5 | Deploy management |
|
||||||
|
| `embeddings` | 4 | Vector embeddings |
|
||||||
|
| `claims` | 4 | Authorization |
|
||||||
|
| `migrate` | 5 | V2→V3 migration |
|
||||||
|
| `process` | 4 | Process management |
|
||||||
|
| `doctor` | 1 | Health diagnostics |
|
||||||
|
| `completions` | 4 | Shell completions |
|
||||||
|
|
||||||
|
### Example Commands
|
||||||
|
```bash
|
||||||
|
# Initialize
|
||||||
|
npx @claude-flow/cli@latest init --wizard
|
||||||
|
|
||||||
|
# Spawn agent
|
||||||
|
npx @claude-flow/cli@latest agent spawn -t coder --name my-coder
|
||||||
|
|
||||||
|
# Memory operations
|
||||||
|
npx @claude-flow/cli@latest memory store --key "pattern" --value "data" --namespace patterns
|
||||||
|
npx @claude-flow/cli@latest memory search --query "authentication"
|
||||||
|
|
||||||
|
# Diagnostics
|
||||||
|
npx @claude-flow/cli@latest doctor --fix
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hooks System
|
||||||
|
|
||||||
|
### 27 Available Hooks
|
||||||
|
|
||||||
|
#### Core Hooks (6)
|
||||||
|
| Hook | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `pre-edit` | Context before file edits |
|
||||||
|
| `post-edit` | Record edit outcomes |
|
||||||
|
| `pre-command` | Risk assessment |
|
||||||
|
| `post-command` | Command metrics |
|
||||||
|
| `pre-task` | Task start + agent suggestions |
|
||||||
|
| `post-task` | Task completion learning |
|
||||||
|
|
||||||
|
#### Session Hooks (4)
|
||||||
|
| Hook | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `session-start` | Start/restore session |
|
||||||
|
| `session-end` | Persist state |
|
||||||
|
| `session-restore` | Restore previous |
|
||||||
|
| `notify` | Cross-agent notifications |
|
||||||
|
|
||||||
|
#### Intelligence Hooks (5)
|
||||||
|
| Hook | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `route` | Optimal agent routing |
|
||||||
|
| `explain` | Routing decisions |
|
||||||
|
| `pretrain` | Bootstrap intelligence |
|
||||||
|
| `build-agents` | Generate configs |
|
||||||
|
| `transfer` | Pattern transfer |
|
||||||
|
|
||||||
|
#### Coverage Hooks (3)
|
||||||
|
| Hook | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `coverage-route` | Coverage-based routing |
|
||||||
|
| `coverage-suggest` | Improvement suggestions |
|
||||||
|
| `coverage-gaps` | Gap analysis |
|
||||||
|
|
||||||
|
### 12 Background Workers
|
||||||
|
| Worker | Priority | Purpose |
|
||||||
|
|--------|----------|---------|
|
||||||
|
| `ultralearn` | normal | Deep knowledge |
|
||||||
|
| `optimize` | high | Performance |
|
||||||
|
| `consolidate` | low | Memory consolidation |
|
||||||
|
| `predict` | normal | Predictive preload |
|
||||||
|
| `audit` | critical | Security |
|
||||||
|
| `map` | normal | Codebase mapping |
|
||||||
|
| `preload` | low | Resource preload |
|
||||||
|
| `deepdive` | normal | Deep analysis |
|
||||||
|
| `document` | normal | Auto-docs |
|
||||||
|
| `refactor` | normal | Suggestions |
|
||||||
|
| `benchmark` | normal | Benchmarking |
|
||||||
|
| `testgaps` | normal | Coverage gaps |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Memory & Intelligence
|
||||||
|
|
||||||
|
### RuVector Intelligence System
|
||||||
|
- **SONA**: Self-Optimizing Neural Architecture (<0.05ms)
|
||||||
|
- **MoE**: Mixture of Experts routing
|
||||||
|
- **HNSW**: 150x-12,500x faster search
|
||||||
|
- **EWC++**: Prevents catastrophic forgetting
|
||||||
|
- **Flash Attention**: 2.49x-7.47x speedup
|
||||||
|
- **Int8 Quantization**: 3.92x memory reduction
|
||||||
|
|
||||||
|
### 4-Step Intelligence Pipeline
|
||||||
|
1. **RETRIEVE** - HNSW pattern search
|
||||||
|
2. **JUDGE** - Success/failure verdicts
|
||||||
|
3. **DISTILL** - LoRA learning extraction
|
||||||
|
4. **CONSOLIDATE** - EWC++ preservation
|
||||||
|
|
||||||
|
### Self-Learning Memory (ADR-049)
|
||||||
|
|
||||||
|
| Component | Status | Description |
|
||||||
|
|-----------|--------|-------------|
|
||||||
|
| **LearningBridge** | ✅ Enabled | Connects insights to SONA/ReasoningBank neural pipeline |
|
||||||
|
| **MemoryGraph** | ✅ Enabled | PageRank knowledge graph + community detection |
|
||||||
|
| **AgentMemoryScope** | ✅ Enabled | 3-scope agent memory (project/local/user) |
|
||||||
|
|
||||||
|
**LearningBridge** - Insights trigger learning trajectories. Confidence evolves: +0.03 on access, -0.005/hour decay. Consolidation runs the JUDGE/DISTILL/CONSOLIDATE pipeline.
|
||||||
|
|
||||||
|
**MemoryGraph** - Builds a knowledge graph from entry references. PageRank identifies influential insights. Communities group related knowledge. Graph-aware ranking blends vector + structural scores.
|
||||||
|
|
||||||
|
**AgentMemoryScope** - Maps Claude Code 3-scope directories:
|
||||||
|
- `project`: `<gitRoot>/.claude/agent-memory/<agent>/`
|
||||||
|
- `local`: `<gitRoot>/.claude/agent-memory-local/<agent>/`
|
||||||
|
- `user`: `~/.claude/agent-memory/<agent>/`
|
||||||
|
|
||||||
|
High-confidence insights (>0.8) can transfer between agents.
|
||||||
|
|
||||||
|
### Memory Commands
|
||||||
|
```bash
|
||||||
|
# Store pattern
|
||||||
|
npx @claude-flow/cli@latest memory store --key "name" --value "data" --namespace patterns
|
||||||
|
|
||||||
|
# Semantic search
|
||||||
|
npx @claude-flow/cli@latest memory search --query "authentication"
|
||||||
|
|
||||||
|
# List entries
|
||||||
|
npx @claude-flow/cli@latest memory list --namespace patterns
|
||||||
|
|
||||||
|
# Initialize database
|
||||||
|
npx @claude-flow/cli@latest memory init --force
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hive-Mind Consensus
|
||||||
|
|
||||||
|
### Queen Types
|
||||||
|
| Type | Role |
|
||||||
|
|------|------|
|
||||||
|
| Strategic Queen | Long-term planning |
|
||||||
|
| Tactical Queen | Execution coordination |
|
||||||
|
| Adaptive Queen | Dynamic optimization |
|
||||||
|
|
||||||
|
### Worker Types (8)
|
||||||
|
`researcher`, `coder`, `analyst`, `tester`, `architect`, `reviewer`, `optimizer`, `documenter`
|
||||||
|
|
||||||
|
### Consensus Mechanisms
|
||||||
|
| Mechanism | Fault Tolerance | Use Case |
|
||||||
|
|-----------|-----------------|----------|
|
||||||
|
| `byzantine` | f < n/3 faulty | Adversarial |
|
||||||
|
| `raft` | f < n/2 failed | Leader-based |
|
||||||
|
| `gossip` | Eventually consistent | Large scale |
|
||||||
|
| `crdt` | Conflict-free | Distributed |
|
||||||
|
| `quorum` | Configurable | Flexible |
|
||||||
|
|
||||||
|
### Hive-Mind Commands
|
||||||
|
```bash
|
||||||
|
# Initialize
|
||||||
|
npx @claude-flow/cli@latest hive-mind init --queen-type strategic
|
||||||
|
|
||||||
|
# Status
|
||||||
|
npx @claude-flow/cli@latest hive-mind status
|
||||||
|
|
||||||
|
# Spawn workers
|
||||||
|
npx @claude-flow/cli@latest hive-mind spawn --count 5 --type worker
|
||||||
|
|
||||||
|
# Consensus
|
||||||
|
npx @claude-flow/cli@latest hive-mind consensus --propose "task"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Performance Targets
|
||||||
|
|
||||||
|
| Metric | Target | Status |
|
||||||
|
|--------|--------|--------|
|
||||||
|
| HNSW Search | 150x-12,500x faster | ✅ Implemented |
|
||||||
|
| Memory Reduction | 50-75% | ✅ Implemented (3.92x) |
|
||||||
|
| SONA Integration | Pattern learning | ✅ Implemented |
|
||||||
|
| Flash Attention | 2.49x-7.47x | 🔄 In Progress |
|
||||||
|
| MCP Response | <100ms | ✅ Achieved |
|
||||||
|
| CLI Startup | <500ms | ✅ Achieved |
|
||||||
|
| SONA Adaptation | <0.05ms | 🔄 In Progress |
|
||||||
|
| Graph Build (1k) | <200ms | ✅ 2.78ms (71.9x headroom) |
|
||||||
|
| PageRank (1k) | <100ms | ✅ 12.21ms (8.2x headroom) |
|
||||||
|
| Insight Recording | <5ms/each | ✅ 0.12ms (41x headroom) |
|
||||||
|
| Consolidation | <500ms | ✅ 0.26ms (1,955x headroom) |
|
||||||
|
| Knowledge Transfer | <100ms | ✅ 1.25ms (80x headroom) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Integration Ecosystem
|
||||||
|
|
||||||
|
### Integrated Packages
|
||||||
|
| Package | Version | Purpose |
|
||||||
|
|---------|---------|---------|
|
||||||
|
| agentic-flow | 3.0.0-alpha.1 | Core coordination + ReasoningBank + Router |
|
||||||
|
| agentdb | 3.0.0-alpha.10 | Vector database + 8 controllers |
|
||||||
|
| @ruvector/attention | 0.1.3 | Flash attention |
|
||||||
|
| @ruvector/sona | 0.1.5 | Neural learning |
|
||||||
|
|
||||||
|
### Optional Integrations
|
||||||
|
| Package | Command |
|
||||||
|
|---------|---------|
|
||||||
|
| ruv-swarm | `npx ruv-swarm mcp start` |
|
||||||
|
| flow-nexus | `npx flow-nexus@latest mcp start` |
|
||||||
|
| agentic-jujutsu | `npx agentic-jujutsu@latest` |
|
||||||
|
|
||||||
|
### MCP Server Setup
|
||||||
|
```bash
|
||||||
|
# Add Ruflo MCP
|
||||||
|
claude mcp add ruflo -- npx -y ruflo@latest
|
||||||
|
|
||||||
|
# Optional servers
|
||||||
|
claude mcp add ruv-swarm -- npx -y ruv-swarm mcp start
|
||||||
|
claude mcp add flow-nexus -- npx -y flow-nexus@latest mcp start
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
### Essential Commands
|
||||||
|
```bash
|
||||||
|
# Setup
|
||||||
|
npx ruflo@latest init --wizard
|
||||||
|
npx ruflo@latest daemon start
|
||||||
|
npx ruflo@latest doctor --fix
|
||||||
|
|
||||||
|
# Swarm
|
||||||
|
npx ruflo@latest swarm init --topology hierarchical --max-agents 8
|
||||||
|
npx ruflo@latest swarm status
|
||||||
|
|
||||||
|
# Agents
|
||||||
|
npx ruflo@latest agent spawn -t coder
|
||||||
|
npx ruflo@latest agent list
|
||||||
|
|
||||||
|
# Memory
|
||||||
|
npx ruflo@latest memory search --query "patterns"
|
||||||
|
|
||||||
|
# Hooks
|
||||||
|
npx ruflo@latest hooks pre-task --description "task"
|
||||||
|
npx ruflo@latest hooks worker dispatch --trigger optimize
|
||||||
|
```
|
||||||
|
|
||||||
|
### File Structure
|
||||||
|
```
|
||||||
|
.claude-flow/
|
||||||
|
├── config.yaml # Runtime configuration
|
||||||
|
├── CAPABILITIES.md # This file
|
||||||
|
├── data/ # Memory storage
|
||||||
|
├── logs/ # Operation logs
|
||||||
|
├── sessions/ # Session state
|
||||||
|
├── hooks/ # Custom hooks
|
||||||
|
├── agents/ # Agent configs
|
||||||
|
└── workflows/ # Workflow templates
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Full Documentation**: https://github.com/ruvnet/claude-flow
|
||||||
|
**Issues**: https://github.com/ruvnet/claude-flow/issues
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# RuFlo V3 Runtime Configuration
|
||||||
|
# Generated: 2026-05-19T00:18:20.863Z
|
||||||
|
|
||||||
|
version: "3.0.0"
|
||||||
|
|
||||||
|
swarm:
|
||||||
|
topology: hierarchical-mesh
|
||||||
|
maxAgents: 15
|
||||||
|
autoScale: true
|
||||||
|
coordinationStrategy: consensus
|
||||||
|
|
||||||
|
memory:
|
||||||
|
backend: hybrid
|
||||||
|
enableHNSW: true
|
||||||
|
persistPath: .claude-flow/data
|
||||||
|
cacheSize: 100
|
||||||
|
# ADR-049: Self-Learning Memory
|
||||||
|
learningBridge:
|
||||||
|
enabled: true
|
||||||
|
sonaMode: balanced
|
||||||
|
confidenceDecayRate: 0.005
|
||||||
|
accessBoostAmount: 0.03
|
||||||
|
consolidationThreshold: 10
|
||||||
|
memoryGraph:
|
||||||
|
enabled: true
|
||||||
|
pageRankDamping: 0.85
|
||||||
|
maxNodes: 5000
|
||||||
|
similarityThreshold: 0.8
|
||||||
|
agentScopes:
|
||||||
|
enabled: true
|
||||||
|
defaultScope: project
|
||||||
|
|
||||||
|
neural:
|
||||||
|
enabled: true
|
||||||
|
modelPath: .claude-flow/neural
|
||||||
|
|
||||||
|
hooks:
|
||||||
|
enabled: true
|
||||||
|
autoExecute: true
|
||||||
|
|
||||||
|
mcp:
|
||||||
|
autoStart: false
|
||||||
|
port: 3000
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"initialized": "2026-05-19T00:18:20.864Z",
|
||||||
|
"routing": {
|
||||||
|
"accuracy": 0,
|
||||||
|
"decisions": 0
|
||||||
|
},
|
||||||
|
"patterns": {
|
||||||
|
"shortTerm": 0,
|
||||||
|
"longTerm": 0,
|
||||||
|
"quality": 0
|
||||||
|
},
|
||||||
|
"sessions": {
|
||||||
|
"total": 0,
|
||||||
|
"current": null
|
||||||
|
},
|
||||||
|
"_note": "Intelligence grows as you use Ruflo"
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"timestamp": "2026-05-19T00:18:20.864Z",
|
||||||
|
"processes": {
|
||||||
|
"agentic_flow": 0,
|
||||||
|
"mcp_server": 0,
|
||||||
|
"estimated_agents": 0
|
||||||
|
},
|
||||||
|
"swarm": {
|
||||||
|
"active": false,
|
||||||
|
"agent_count": 0,
|
||||||
|
"coordination_active": false
|
||||||
|
},
|
||||||
|
"integration": {
|
||||||
|
"agentic_flow_active": false,
|
||||||
|
"mcp_active": false
|
||||||
|
},
|
||||||
|
"_initialized": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"version": "3.0.0",
|
||||||
|
"initialized": "2026-05-19T00:18:20.864Z",
|
||||||
|
"domains": {
|
||||||
|
"completed": 0,
|
||||||
|
"total": 5,
|
||||||
|
"status": "INITIALIZING"
|
||||||
|
},
|
||||||
|
"ddd": {
|
||||||
|
"progress": 0,
|
||||||
|
"modules": 0,
|
||||||
|
"totalFiles": 0,
|
||||||
|
"totalLines": 0
|
||||||
|
},
|
||||||
|
"swarm": {
|
||||||
|
"activeAgents": 0,
|
||||||
|
"maxAgents": 15,
|
||||||
|
"topology": "hierarchical-mesh"
|
||||||
|
},
|
||||||
|
"learning": {
|
||||||
|
"status": "READY",
|
||||||
|
"patternsLearned": 0,
|
||||||
|
"sessionsCompleted": 0
|
||||||
|
},
|
||||||
|
"_note": "Metrics will update as you use Ruflo. Run: npx ruflo@latest daemon start"
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"initialized": "2026-05-19T00:18:20.864Z",
|
||||||
|
"status": "PENDING",
|
||||||
|
"cvesFixed": 0,
|
||||||
|
"totalCves": 3,
|
||||||
|
"lastScan": null,
|
||||||
|
"_note": "Run: npx @claude-flow/cli@latest security scan"
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
name: Android Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: 'Release tag (e.g. v0.36.2)'
|
||||||
|
required: true
|
||||||
|
default: 'v0.36.2'
|
||||||
|
|
||||||
|
env:
|
||||||
|
APK_OUT: target/release/apk/ferrous-solitaire.apk
|
||||||
|
GITEA_URL: https://git.aleshym.co
|
||||||
|
REPO: funman300/Ferrous-Solitaire
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-apk:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: git.aleshym.co/funman300/android-builder:latest
|
||||||
|
credentials:
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.CI_TOKEN }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Cache Cargo registry
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
/usr/local/cargo/registry/index
|
||||||
|
/usr/local/cargo/registry/cache
|
||||||
|
/usr/local/cargo/git/db
|
||||||
|
key: cargo-registry-android-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
restore-keys: cargo-registry-android-
|
||||||
|
|
||||||
|
- name: Cache sccache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: /root/.cache/sccache
|
||||||
|
key: sccache-android-aarch64-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
restore-keys: sccache-android-aarch64-
|
||||||
|
|
||||||
|
- name: Get tag name
|
||||||
|
id: tag
|
||||||
|
run: |
|
||||||
|
if [ -n "${{ github.event.inputs.tag }}" ]; then
|
||||||
|
echo "name=${{ github.event.inputs.tag }}" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "name=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Decode release keystore
|
||||||
|
run: echo "${{ secrets.RELEASE_KEYSTORE_B64 }}" | base64 -d > release.jks
|
||||||
|
|
||||||
|
- name: Build release APK
|
||||||
|
env:
|
||||||
|
PROFILE: release
|
||||||
|
ABIS: arm64-v8a
|
||||||
|
KEYSTORE: ./release.jks
|
||||||
|
KEYSTORE_PASS: ${{ secrets.RELEASE_KEYSTORE_PASS }}
|
||||||
|
KEY_ALIAS: release
|
||||||
|
KEY_PASS: ${{ secrets.RELEASE_KEYSTORE_PASS }}
|
||||||
|
VERSION_NAME: ${{ steps.tag.outputs.name }}
|
||||||
|
RUSTC_WRAPPER: sccache
|
||||||
|
SCCACHE_DIR: /root/.cache/sccache
|
||||||
|
run: bash scripts/build_android_apk.sh
|
||||||
|
|
||||||
|
- name: sccache stats
|
||||||
|
if: always()
|
||||||
|
run: sccache --show-stats
|
||||||
|
|
||||||
|
- name: Create or get Gitea release
|
||||||
|
id: release
|
||||||
|
run: |
|
||||||
|
TAG="${{ steps.tag.outputs.name }}"
|
||||||
|
BASE="${{ env.GITEA_URL }}/api/v1/repos/${{ env.REPO }}"
|
||||||
|
AUTH="Authorization: token ${{ secrets.CI_TOKEN }}"
|
||||||
|
|
||||||
|
ID=$(curl -sf -H "$AUTH" "$BASE/releases/tags/$TAG" \
|
||||||
|
| python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" \
|
||||||
|
2>/dev/null || true)
|
||||||
|
|
||||||
|
if [ -z "$ID" ]; then
|
||||||
|
ID=$(curl -sf -X POST \
|
||||||
|
-H "$AUTH" -H "Content-Type: application/json" \
|
||||||
|
"$BASE/releases" \
|
||||||
|
-d "{
|
||||||
|
\"tag_name\": \"$TAG\",
|
||||||
|
\"name\": \"$TAG\",
|
||||||
|
\"body\": \"## Android release $TAG\n\n**Install / update with Obtainium** — add this source URL:\n\`\`\`\nhttps://git.aleshym.co/funman300/Ferrous-Solitaire\n\`\`\`\n\nOr download \`ferrous-solitaire.apk\` below and sideload it directly.\",
|
||||||
|
\"draft\": false,
|
||||||
|
\"prerelease\": false
|
||||||
|
}" \
|
||||||
|
| python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
||||||
|
fi
|
||||||
|
echo "id=$ID" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Upload APK to release
|
||||||
|
run: |
|
||||||
|
BASE="${{ env.GITEA_URL }}/api/v1/repos/${{ env.REPO }}"
|
||||||
|
AUTH="Authorization: token ${{ secrets.CI_TOKEN }}"
|
||||||
|
RELEASE_ID="${{ steps.release.outputs.id }}"
|
||||||
|
|
||||||
|
# Remove any existing APK assets so re-runs don't accumulate duplicates.
|
||||||
|
curl -sf -H "$AUTH" "$BASE/releases/$RELEASE_ID/assets" \
|
||||||
|
| python3 -c "import sys,json; [print(a['id']) for a in json.load(sys.stdin) if a['name'].endswith('.apk')]" \
|
||||||
|
| while read AID; do
|
||||||
|
curl -sf -X DELETE -H "$AUTH" "$BASE/releases/$RELEASE_ID/assets/$AID"
|
||||||
|
done
|
||||||
|
|
||||||
|
curl -sf -X POST \
|
||||||
|
-H "$AUTH" \
|
||||||
|
-F "attachment=@${{ env.APK_OUT }};type=application/vnd.android.package-archive" \
|
||||||
|
"$BASE/releases/$RELEASE_ID/assets"
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
name: Build Android Builder Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
paths:
|
||||||
|
- 'docker/android-builder.Dockerfile'
|
||||||
|
- '.gitea/workflows/builder-image.yml'
|
||||||
|
|
||||||
|
env:
|
||||||
|
IMAGE: git.aleshym.co/funman300/android-builder
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Log in to Gitea registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: git.aleshym.co
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.CI_TOKEN }}
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
with:
|
||||||
|
driver-opts: network=host
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: docker/android-builder.Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: ${{ env.IMAGE }}:latest
|
||||||
|
cache-from: type=registry,ref=${{ env.IMAGE }}:buildcache
|
||||||
|
cache-to: type=registry,ref=${{ env.IMAGE }}:buildcache,mode=max
|
||||||
@@ -3,11 +3,13 @@ name: Build and Deploy
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [master]
|
branches: [master]
|
||||||
# Only run when server code changes, not when CI itself updates deploy/.
|
paths:
|
||||||
paths-ignore:
|
- 'solitaire_server/**'
|
||||||
- 'deploy/**'
|
- 'solitaire_sync/**'
|
||||||
- 'argocd/**'
|
- 'solitaire_core/**'
|
||||||
- '**.md'
|
- 'Cargo.toml'
|
||||||
|
- 'Cargo.lock'
|
||||||
|
- '.gitea/workflows/docker-build.yml'
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: git.aleshym.co
|
REGISTRY: git.aleshym.co
|
||||||
@@ -55,7 +57,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Install kustomize
|
- name: Install kustomize
|
||||||
run: |
|
run: |
|
||||||
curl -sL "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" | bash
|
curl -sL https://github.com/kubernetes-sigs/kustomize/releases/download/kustomize%2Fv5.4.3/kustomize_v5.4.3_linux_amd64.tar.gz | tar xz
|
||||||
sudo mv kustomize /usr/local/bin/kustomize
|
sudo mv kustomize /usr/local/bin/kustomize
|
||||||
|
|
||||||
- name: Pin image tag in deploy manifests
|
- name: Pin image tag in deploy manifests
|
||||||
@@ -68,6 +70,9 @@ jobs:
|
|||||||
git config user.email "ci@gitea.local"
|
git config user.email "ci@gitea.local"
|
||||||
git config user.name "Gitea CI"
|
git config user.name "Gitea CI"
|
||||||
git add deploy/kustomization.yaml
|
git add deploy/kustomization.yaml
|
||||||
git commit -m "chore(deploy): bump image to ${{ steps.meta.outputs.sha }} [skip ci]" || true
|
git diff --cached --quiet && exit 0 # nothing to commit — skip push
|
||||||
git pull --rebase origin master
|
git commit -m "chore(deploy): bump image to ${{ steps.meta.outputs.sha }} [skip ci]"
|
||||||
git push
|
for i in 1 2 3; do
|
||||||
|
git pull --rebase origin master && git push && break
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
|||||||
@@ -8,6 +8,10 @@
|
|||||||
data/
|
data/
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
|
# ruflo runtime state
|
||||||
|
agentdb.rvf
|
||||||
|
agentdb.rvf.lock
|
||||||
|
|
||||||
# IDE project files
|
# IDE project files
|
||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"ruflo": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"ruflo@latest",
|
||||||
|
"mcp",
|
||||||
|
"start"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"npm_config_update_notifier": "false",
|
||||||
|
"CLAUDE_FLOW_MODE": "v3",
|
||||||
|
"CLAUDE_FLOW_HOOKS_ENABLED": "true",
|
||||||
|
"CLAUDE_FLOW_TOPOLOGY": "hierarchical-mesh",
|
||||||
|
"CLAUDE_FLOW_MAX_AGENTS": "15",
|
||||||
|
"CLAUDE_FLOW_MEMORY_BACKEND": "hybrid"
|
||||||
|
},
|
||||||
|
"autoStart": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-8
@@ -58,7 +58,7 @@ Pure-core, no-panics-in-game-logic, and UI-first-interaction constraints are enf
|
|||||||
## 2. Workspace Structure
|
## 2. Workspace Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
solitaire_quest/
|
ferrous_solitaire/
|
||||||
│
|
│
|
||||||
├── Cargo.toml # Workspace manifest
|
├── Cargo.toml # Workspace manifest
|
||||||
├── .env.example # Server environment variable template
|
├── .env.example # Server environment variable template
|
||||||
@@ -366,12 +366,12 @@ Minimum window: 800×600. At this size cards are small but usable.
|
|||||||
|
|
||||||
### Local Storage
|
### Local Storage
|
||||||
|
|
||||||
All files stored under `dirs::data_dir() / "solitaire_quest"/`:
|
All files stored under `dirs::data_dir() / "ferrous_solitaire"/`:
|
||||||
|
|
||||||
```
|
```
|
||||||
~/.local/share/solitaire_quest/ (Linux)
|
~/.local/share/ferrous_solitaire/ (Linux)
|
||||||
~/Library/Application Support/solitaire_quest/ (macOS)
|
~/Library/Application Support/ferrous_solitaire/ (macOS)
|
||||||
%APPDATA%\solitaire_quest\ (Windows)
|
%APPDATA%\ferrous_solitaire\ (Windows)
|
||||||
│
|
│
|
||||||
├── stats.json # StatsSnapshot
|
├── stats.json # StatsSnapshot
|
||||||
├── progress.json # PlayerProgress (XP, level, unlocks, daily challenge)
|
├── progress.json # PlayerProgress (XP, level, unlocks, daily challenge)
|
||||||
@@ -426,7 +426,7 @@ pub enum SyncBackend {
|
|||||||
url: String,
|
url: String,
|
||||||
username: String,
|
username: String,
|
||||||
// JWT access + refresh tokens stored in OS keychain
|
// JWT access + refresh tokens stored in OS keychain
|
||||||
// key: "solitaire_quest_server_{username}"
|
// key: "ferrous_solitaire_server_{username}"
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -980,8 +980,8 @@ Add `--features bevy/dynamic_linking` during development to dramatically reduce
|
|||||||
### Docker Compose (Recommended)
|
### Docker Compose (Recommended)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/yourname/solitaire_quest
|
git clone https://github.com/yourname/ferrous_solitaire
|
||||||
cd solitaire_quest
|
cd ferrous_solitaire
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# Edit .env — set JWT_SECRET and SERVER_PORT
|
# Edit .env — set JWT_SECRET and SERVER_PORT
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
|
|||||||
+82
-4
@@ -6,6 +6,84 @@ project follows [Semantic Versioning](https://semver.org/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.33.0] — 2026-05-16
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Face-down cards render as tiny red squares (startup ordering bug)**. The
|
||||||
|
`load_initial_theme` system fell back to `"dark"` when `SettingsResource` was
|
||||||
|
not yet available at `Startup`, which happens on every fresh run before the
|
||||||
|
settings file is read. The dark theme's near-black card back (#151515) renders
|
||||||
|
as fully-off pixels on AMOLED screens, leaving only a 24×32 px red badge
|
||||||
|
visible. Changed the fallback to `"classic"` so startup behaviour matches the
|
||||||
|
`default_theme_id()` set in v0.31.0. Cascade-collapse and top-row legibility
|
||||||
|
issues were visual consequences of the same invisible-card-back problem, not
|
||||||
|
separate layout bugs.
|
||||||
|
|
||||||
|
## [0.32.0] — 2026-05-16
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Stock-count badge overlaps waste pile on Android** (Bug 1). The badge was
|
||||||
|
centred 12 px inward from the stock pile's right edge, but its half-width of
|
||||||
|
17 px pushed it 5 px past the edge. On Android (`H_GAP_DIVISOR = 32`) the
|
||||||
|
inter-pile gap is only ~4 px, so the badge's top-right corner covered the
|
||||||
|
left edge of the adjacent waste card at `Z_STOCK_BADGE = 30` (above the
|
||||||
|
card's Z ≈ 1). Fixed by moving the inset to 20 px so the badge right edge
|
||||||
|
sits 3 px inside the stock card on every device.
|
||||||
|
- **Oversized grey header bar** (Bug 2). The top HUD band was a full-width
|
||||||
|
`Node` with an opaque dark-grey `BackgroundColor` sized to `HUD_BAND_HEIGHT`
|
||||||
|
(64 px desktop / 80 px Android). Typical gameplay only shows one tier of
|
||||||
|
score text (~30 px), leaving a large empty grey block. Removed the
|
||||||
|
`BackgroundColor` from the band entity; the green felt now shows through and
|
||||||
|
only the score text and avatar button are visible in the header area.
|
||||||
|
|
||||||
|
## [0.31.0] — 2026-05-16
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Face-down cards rendered as solid red squares on AMOLED phones** (Bug 1).
|
||||||
|
The dark theme's card back (`back.svg`) uses a near-black background
|
||||||
|
(`#151515`) which AMOLED screens render as fully-off pixels, leaving only a
|
||||||
|
tiny `#a54242` red badge visible — exactly what was reported. Fixed by
|
||||||
|
changing the fresh-install default theme from "dark" to "classic" (white
|
||||||
|
background with navy diamond pattern, clearly readable on all display types).
|
||||||
|
Also corrected stale asset paths in `load_card_images` (`cards/backs/back_N`
|
||||||
|
→ `cards/backs/classic/back_N`, `cards/faces/XY` → `cards/faces/classic/XY`)
|
||||||
|
so the PNG fallback loads correctly when the embedded theme hasn't arrived yet.
|
||||||
|
|
||||||
|
## [0.30.0] — 2026-05-16
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Tableau card spacing tightened.** Face-up card fan reduced from 25% to 18%
|
||||||
|
of card height; face-down from 20% to 14%. Cards on tableau piles sit closer
|
||||||
|
together while still showing enough of each card to read the pile depth.
|
||||||
|
|
||||||
|
## [0.29.0] — 2026-05-16
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **APK versionCode hardcoded to 1** (`AndroidManifest.xml`, `build_android_apk.sh`).
|
||||||
|
Every release shipped with `versionCode="1"` / `versionName="1.0"`, so Android
|
||||||
|
silently refused upgrades and Obtainium permanently showed a false update
|
||||||
|
notification. The CI now derives the version code from the release tag
|
||||||
|
(e.g. v0.29.0 → 2900) and stamps it into the APK via `aapt2 link
|
||||||
|
--version-code / --version-name`.
|
||||||
|
- **CI kustomize install flaky** (`.gitea/workflows/docker-build.yml`).
|
||||||
|
The `curl | bash install_kustomize.sh` pattern hit GitHub API rate limits
|
||||||
|
on the shared runner IP, causing a `tar: no such file` failure. Replaced
|
||||||
|
with a direct pinned tarball download (kustomize v5.4.3).
|
||||||
|
|
||||||
|
## [0.28.0] — 2026-05-14
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Rename: Solitaire Quest → Ferrous Solitaire.** Android package id changed
|
||||||
|
from `com.solitairequest.app` to `com.ferrousapp.solitaire`; existing installs
|
||||||
|
must be uninstalled first (Android treats the new id as a new app).
|
||||||
|
Data directory renamed from `solitaire_quest/` to `ferrous_solitaire/`.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **BUG-3: Multi-modal stacking** (`hud_plugin.rs`). `handle_menu_button`
|
- **BUG-3: Multi-modal stacking** (`hud_plugin.rs`). `handle_menu_button`
|
||||||
@@ -1431,7 +1509,7 @@ candidate — the app-icon round — stays open.
|
|||||||
- **Android build target — first working APK** (`fb8b2ac`).
|
- **Android build target — first working APK** (`fb8b2ac`).
|
||||||
`cargo apk build -p solitaire_app --target x86_64-linux-android`
|
`cargo apk build -p solitaire_app --target x86_64-linux-android`
|
||||||
now produces a 54 MB debug-signed APK at
|
now produces a 54 MB debug-signed APK at
|
||||||
`target/debug/apk/solitaire-quest.apk`. Five gating points
|
`target/debug/apk/ferrous-solitaire.apk`. Five gating points
|
||||||
resolved end-to-end:
|
resolved end-to-end:
|
||||||
- **`solitaire_app` split into bin + lib.** cargo-apk needs a
|
- **`solitaire_app` split into bin + lib.** cargo-apk needs a
|
||||||
`cdylib` to bundle as `libmain.so`; pure-bin crates panic
|
`cdylib` to bundle as `libmain.so`; pure-bin crates panic
|
||||||
@@ -1548,7 +1626,7 @@ candidate — the app-icon round — stays open.
|
|||||||
achievements, replays, game-state, time-attack sessions, user
|
achievements, replays, game-state, time-attack sessions, user
|
||||||
themes). New `solitaire_data::platform::data_dir()` shim falls
|
themes). New `solitaire_data::platform::data_dir()` shim falls
|
||||||
through to `dirs::data_dir()` on desktop and returns the per-app
|
through to `dirs::data_dir()` on desktop and returns the per-app
|
||||||
sandbox at `/data/data/com.solitairequest.app/files` on Android
|
sandbox at `/data/data/com.ferrousapp.solitaire/files` on Android
|
||||||
— no JNI needed, since the package id is pinned in
|
— no JNI needed, since the package id is pinned in
|
||||||
`[package.metadata.android]`. Six call sites across
|
`[package.metadata.android]`. Six call sites across
|
||||||
`solitaire_data` plus `solitaire_engine/assets/user_dir.rs`
|
`solitaire_data` plus `solitaire_engine/assets/user_dir.rs`
|
||||||
@@ -1690,7 +1768,7 @@ fully reverted and is not part of this release.
|
|||||||
The test's single-frame `app.update()` was sensitive to
|
The test's single-frame `app.update()` was sensitive to
|
||||||
first-frame `Time::delta_secs()` variance under heavy parallel
|
first-frame `Time::delta_secs()` variance under heavy parallel
|
||||||
cargo-test load, and to production-disk
|
cargo-test load, and to production-disk
|
||||||
`~/.local/share/solitaire_quest/game_state.json` state leaking
|
`~/.local/share/ferrous_solitaire/game_state.json` state leaking
|
||||||
into the test world via `GamePlugin::build`'s load path.
|
into the test world via `GamePlugin::build`'s load path.
|
||||||
`test_app` now resets `PendingRestoredGame(None)` after plugin
|
`test_app` now resets `PendingRestoredGame(None)` after plugin
|
||||||
build (preventing the dev machine's saved-game state from
|
build (preventing the dev machine's saved-game state from
|
||||||
@@ -2386,7 +2464,7 @@ the binary shipped with bundled artwork.
|
|||||||
patterns.
|
patterns.
|
||||||
- **Ambient audio loop** wired through the kira mixer.
|
- **Ambient audio loop** wired through the kira mixer.
|
||||||
- **Arch Linux PKGBUILDs** for the game client and sync server (under
|
- **Arch Linux PKGBUILDs** for the game client and sync server (under
|
||||||
the separate `solitaire-quest-pkgbuild` directory).
|
the separate `ferrous-solitaire-pkgbuild` directory).
|
||||||
- **Workspace README, CI workflow, migration guide.**
|
- **Workspace README, CI workflow, migration guide.**
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -355,7 +355,7 @@ Must always be handled explicitly:
|
|||||||
* The gesture/navigation bar at the bottom (≈132px physical on common
|
* The gesture/navigation bar at the bottom (≈132px physical on common
|
||||||
devices) is inside the Bevy viewport; use `SafeAreaInsets.bottom` to
|
devices) is inside the Bevy viewport; use `SafeAreaInsets.bottom` to
|
||||||
avoid placing interactive elements in that zone
|
avoid placing interactive elements in that zone
|
||||||
* `HUD_BAND_HEIGHT` is 128px on Android (two-row wrap) vs 64px on desktop;
|
* `HUD_BAND_HEIGHT` is 112px on Android vs 64px on desktop;
|
||||||
layout constants are `#[cfg(target_os = "android")]` gated
|
layout constants are `#[cfg(target_os = "android")]` gated
|
||||||
* JNI calls must use `attach_current_thread_permanently` — not
|
* JNI calls must use `attach_current_thread_permanently` — not
|
||||||
`attach_current_thread` — to avoid detach-on-drop panics
|
`attach_current_thread` — to avoid detach-on-drop panics
|
||||||
@@ -447,7 +447,7 @@ raw `z_index` values — they drift and cause ordering bugs.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo apk build --package solitaire_app --lib
|
cargo apk build --package solitaire_app --lib
|
||||||
adb install -r target/debug/apk/solitaire-quest.apk
|
adb install -r target/debug/apk/ferrous-solitaire.apk
|
||||||
```
|
```
|
||||||
|
|
||||||
## 15.2 Coordinate system reminder
|
## 15.2 Coordinate system reminder
|
||||||
|
|||||||
@@ -31,6 +31,23 @@ optional self-hosted sync so your stats follow you across machines.
|
|||||||
- **Color-blind mode** — blue tint on red-suit cards alongside the suit
|
- **Color-blind mode** — blue tint on red-suit cards alongside the suit
|
||||||
glyph
|
glyph
|
||||||
|
|
||||||
|
## Android Install
|
||||||
|
|
||||||
|
### Obtainium (recommended — automatic updates)
|
||||||
|
|
||||||
|
1. Install [Obtainium](https://github.com/ImranR98/Obtainium/releases) on your device
|
||||||
|
2. Tap the badge below on your Android device — the source type is pre-configured, no manual selection needed:
|
||||||
|
|
||||||
|
[<img src="https://raw.githubusercontent.com/ImranR98/Obtainium/main/assets/graphics/badge_obtainium.png" alt="Get it on Obtainium" height="40">](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22com.ferrousapp.solitaire%22%2C%22url%22%3A%22https%3A//git.aleshym.co/funman300/Ferrous-Solitaire%22%2C%22author%22%3A%22funman300%22%2C%22name%22%3A%22Ferrous%20Solitaire%22%2C%22installedVersion%22%3Anull%2C%22latestVersion%22%3Anull%2C%22apkUrls%22%3A%22%5B%5D%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%7D%22%2C%22lastUpdateCheck%22%3Anull%2C%22pinned%22%3Afalse%2C%22categories%22%3A%5B%5D%2C%22releaseDate%22%3Anull%2C%22changeLog%22%3Anull%2C%22overrideSource%22%3A%22Codeberg%22%2C%22allowIdChange%22%3Afalse%2C%22otherAssetUrls%22%3A%22%5B%5D%22%7D)
|
||||||
|
|
||||||
|
3. Tap **Install** to download the current release — Obtainium will notify you when updates are available.
|
||||||
|
|
||||||
|
### Direct APK
|
||||||
|
|
||||||
|
Download the latest `ferrous-solitaire.apk` from the
|
||||||
|
[Releases](https://git.aleshym.co/funman300/Ferrous-Solitaire/releases) page,
|
||||||
|
enable **Install from unknown sources** in your device settings, and open the file.
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
**Prerequisites**
|
**Prerequisites**
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# Ferrous Solitaire — Session Handoff
|
||||||
|
|
||||||
|
**Last updated:** 2026-05-18 — Three leaderboard bugs fixed, tagged v0.35.1. All commits on origin/master.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current state
|
||||||
|
|
||||||
|
- **HEAD on origin/master:** `8f86d66` (fix: three leaderboard bugs)
|
||||||
|
- **Latest tag:** `v0.35.1`
|
||||||
|
- **Working tree:** clean
|
||||||
|
- **Build:** `cargo clippy --workspace -- -D warnings` clean
|
||||||
|
- **Tests:** 1277 passing / 0 failing across the workspace
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What shipped since the last handoff (v0.23.0 → v0.35.1)
|
||||||
|
|
||||||
|
### v0.34.0 — Android polish + code-quality sweep (2026-05-16/17)
|
||||||
|
|
||||||
|
| Commit | Summary |
|
||||||
|
|--------|---------|
|
||||||
|
| `9623bde` | Wire FiraMono to Android corner label; CardImageSet load tests |
|
||||||
|
| `980312c` | Fix wrong bottom-right suit symbol on JS/QS/KS card assets |
|
||||||
|
| `04e99a8` | Correct Android waste fan overlap and resume layout desync |
|
||||||
|
| `3bb3ddb` | Eliminate panics, fix dismiss hit-test scope, guard home respawn |
|
||||||
|
| `f8f1f26` | Adaptive drop zones, touch event correctness, modal lifecycle guards |
|
||||||
|
| `1eb4043` | Auth-guard avatar serving; atomic write; user_id assertion in merge |
|
||||||
|
| `69c6e88` | Deterministic pile serialization, undo skip, url-encode bytes, merge_at |
|
||||||
|
| `aa7b0f6` | Gate frame-hot ECS systems on resource changes (perf) |
|
||||||
|
| `6727126` | Consolidate APP_DIR_NAME; add `#[must_use]` on pure fns |
|
||||||
|
| `a4dfb0c` | Differentiate leaderboard opt-in vs opt-out error toasts (M-12) |
|
||||||
|
| `7fc98f8` | WASM: state() and step() return Result, errors throw JS exceptions (CR-6) |
|
||||||
|
| `ffed6b2` | Share Tokio runtime across all network tasks (M-16) |
|
||||||
|
| `fa84152` | Correct Android help hint label `→` to `!` (M-17) |
|
||||||
|
| `18d7937` | Derive Copy for DrawMode; drop redundant .clone() calls (M-18) |
|
||||||
|
| `132fea9` | Use saturating_add for move_count increments (M-19) |
|
||||||
|
| `0ecc1a9` | Add missing derives to AchievementContext (M-20) |
|
||||||
|
| `2301cc6` | Align android_keystore temp extension with cleanup glob (M-21) |
|
||||||
|
| `2e52f54` | Enforce 32-char display_name limit at sync client boundary (M-22) |
|
||||||
|
| `c8878d6` | Fix stale FOCUS_RING colour comment (M-23) |
|
||||||
|
| `4aafc0a` | Name HUD popover Z-layers; replace raw Z arithmetic (M-24) |
|
||||||
|
|
||||||
|
### v0.35.0 — Accessibility + sync reliability (2026-05-18)
|
||||||
|
|
||||||
|
| Commit | Summary |
|
||||||
|
|--------|---------|
|
||||||
|
| `eb6c93f` | Silence B0004 by adding Transform to ModalScrim |
|
||||||
|
| `6f5cebd` | Fire WarningToastEvent on sync pull failure (was InfoToastEvent) |
|
||||||
|
| `87aec5b` | Gate all decorative motion animations under `reduce_motion_mode` |
|
||||||
|
|
||||||
|
`reduce_motion_mode` now gates: score pulse, score floater, streak flourish
|
||||||
|
(hud_plugin), card-shake on rejected move, foundation completion flourish
|
||||||
|
(feedback_anim_plugin). Pattern: gate at the trigger/start system, never at
|
||||||
|
the tick system — if the component isn't inserted, the tick path never runs.
|
||||||
|
|
||||||
|
### v0.35.1 — Leaderboard bug fixes (2026-05-18)
|
||||||
|
|
||||||
|
| Commit | Summary |
|
||||||
|
|--------|---------|
|
||||||
|
| `8f86d66` | Fix three leaderboard bugs: wrong toast type, stale label, name not synced |
|
||||||
|
|
||||||
|
Three bugs fixed:
|
||||||
|
|
||||||
|
1. **Wrong toast type on error** — `poll_opt_in_task` / `poll_opt_out_task` error
|
||||||
|
branches now fire `WarningToastEvent` instead of `InfoToastEvent`.
|
||||||
|
|
||||||
|
2. **Display name not pushed to server on change** — `Settings` gains
|
||||||
|
`leaderboard_opted_in: bool` (serde-defaulted `false`). Set `true`/`false` when
|
||||||
|
opt-in/out tasks succeed and persisted to `settings.json`. `handle_display_name_confirm`
|
||||||
|
now spawns an `opt_in_leaderboard` task when already opted in — the server's upsert
|
||||||
|
endpoint updates only `display_name` without re-opting-in.
|
||||||
|
|
||||||
|
3. **"Public name" label stale after name change** — `LeaderboardPublicNameText` marker
|
||||||
|
component added to the label node. `update_leaderboard_public_name_label` system
|
||||||
|
rewrites the text each frame the panel is open; O(0) cost when panel is closed.
|
||||||
|
|
||||||
|
5 new regression tests cover all three bugs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open punch list
|
||||||
|
|
||||||
|
### 1. CHANGELOG documentation debt
|
||||||
|
|
||||||
|
CHANGELOG.md currently ends at v0.33.0. Entries for v0.34.0, v0.35.0, and v0.35.1
|
||||||
|
are missing. Low priority (git log is authoritative) but worth closing before the
|
||||||
|
next release.
|
||||||
|
|
||||||
|
### 2. Android APK launch verification (Option A)
|
||||||
|
|
||||||
|
Physical device test: install the latest APK on a real Android device (not AVD),
|
||||||
|
confirm:
|
||||||
|
- App launches without crash
|
||||||
|
- Safe area insets arrive and shift HUD correctly after ~3 frames
|
||||||
|
- All modal Done buttons are above the gesture bar
|
||||||
|
- Drag-and-drop works on all pile types
|
||||||
|
- Leaderboard panel opens and the "Public name" label updates correctly after
|
||||||
|
using "Set Name"
|
||||||
|
|
||||||
|
This has never been gated in CI. AVD `adb shell input tap` doesn't deliver real
|
||||||
|
touch events, so physical-device smoke testing is the only gate.
|
||||||
|
|
||||||
|
### 3. Matomo analytics wiring
|
||||||
|
|
||||||
|
`Settings` has `analytics_enabled: bool` and `matomo_url: Option<String>` but no
|
||||||
|
engine code consumes them — the analytics toggle in Settings is a no-op. If
|
||||||
|
analytics are ever needed, the Matomo HTTP Tracking API client needs to be written
|
||||||
|
and wired to `GameStateResource` events.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architectural notes for next session
|
||||||
|
|
||||||
|
- **Reduce-motion pattern:** always gate in the `start_*` / `detect_*` system
|
||||||
|
(the trigger), not the `tick_*` system. If the component is never inserted, the
|
||||||
|
tick path never runs. See `hud_plugin.rs::detect_score_change` and
|
||||||
|
`feedback_anim_plugin.rs::start_shake_anim` for the canonical pattern.
|
||||||
|
|
||||||
|
- **Leaderboard server upsert:** `POST /api/leaderboard/opt-in` is idempotent —
|
||||||
|
calling it when already opted in just updates `display_name`. Safe to call from
|
||||||
|
`handle_display_name_confirm` without tracking a separate "needs update" flag.
|
||||||
|
|
||||||
|
- **`Messages<T>` API (Bevy 0.18.1):** write with
|
||||||
|
`resource_mut::<Messages<T>>().write(value)`; read in tests with
|
||||||
|
`msgs.get_cursor()` + `cursor.read(msgs).next()`.
|
||||||
|
|
||||||
|
- **Test input-state pitfall:** `MinimalPlugins` has no input-tick system, so
|
||||||
|
`ButtonInput::just_pressed` state persists across frames unless explicitly cleared
|
||||||
|
with `input.release(key); input.clear()` between updates.
|
||||||
@@ -6,7 +6,7 @@ metadata:
|
|||||||
spec:
|
spec:
|
||||||
project: default
|
project: default
|
||||||
source:
|
source:
|
||||||
repoURL: https://git.aleshym.co/funman300/Rusty_Solitare.git
|
repoURL: https://git.aleshym.co/funman300/Ferrous-Solitaire.git
|
||||||
targetRevision: master
|
targetRevision: master
|
||||||
path: deploy
|
path: deploy
|
||||||
destination:
|
destination:
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.2 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 6.0 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 7.0 KiB |
@@ -20,4 +20,4 @@ resources:
|
|||||||
images:
|
images:
|
||||||
- name: solitaire-server
|
- name: solitaire-server
|
||||||
newName: git.aleshym.co/funman300/solitaire-server
|
newName: git.aleshym.co/funman300/solitaire-server
|
||||||
newTag: c35c045f
|
newTag: 90eb5fd2
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
FROM ubuntu:22.04
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive \
|
||||||
|
ANDROID_HOME=/opt/android-sdk \
|
||||||
|
NDK_VERSION=30.0.14904198 \
|
||||||
|
BUILD_TOOLS_VERSION=36.1.0 \
|
||||||
|
PLATFORM=android-34 \
|
||||||
|
RUSTUP_HOME=/usr/local/rustup \
|
||||||
|
CARGO_HOME=/usr/local/cargo
|
||||||
|
|
||||||
|
ENV ANDROID_NDK_HOME=${ANDROID_HOME}/ndk/${NDK_VERSION} \
|
||||||
|
PATH=/usr/local/cargo/bin:$PATH
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
openjdk-17-jdk-headless \
|
||||||
|
build-essential wget unzip curl ca-certificates git zip python3 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Node.js 20 — required by Gitea Actions composite actions (checkout, cache, etc.)
|
||||||
|
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||||
|
&& apt-get install -y --no-install-recommends nodejs \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Android SDK command-line tools
|
||||||
|
RUN mkdir -p "$ANDROID_HOME/cmdline-tools" \
|
||||||
|
&& wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip \
|
||||||
|
-O /tmp/cmdtools.zip \
|
||||||
|
&& unzip -q /tmp/cmdtools.zip -d "$ANDROID_HOME/cmdline-tools" \
|
||||||
|
&& mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest" \
|
||||||
|
&& rm /tmp/cmdtools.zip \
|
||||||
|
&& yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses >/dev/null 2>&1 || true \
|
||||||
|
&& "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" \
|
||||||
|
"ndk;${NDK_VERSION}" \
|
||||||
|
"build-tools;${BUILD_TOOLS_VERSION}" \
|
||||||
|
"platforms;${PLATFORM}"
|
||||||
|
|
||||||
|
# Rust stable + aarch64-linux-android target
|
||||||
|
RUN curl -sSf https://sh.rustup.rs | sh -s -- -y --no-modify-path --default-toolchain stable \
|
||||||
|
&& rustup target add aarch64-linux-android
|
||||||
|
|
||||||
|
# cargo-ndk (compiled once into the image)
|
||||||
|
RUN cargo install cargo-ndk --version 4.1.2 --locked \
|
||||||
|
&& rm -rf "$CARGO_HOME/registry" "$CARGO_HOME/git"
|
||||||
|
|
||||||
|
# sccache — pre-built musl binary, no Rust compile needed
|
||||||
|
RUN curl -sL "https://github.com/mozilla/sccache/releases/download/v0.8.1/sccache-v0.8.1-x86_64-unknown-linux-musl.tar.gz" \
|
||||||
|
| tar xz -C /tmp \
|
||||||
|
&& mv /tmp/sccache-v0.8.1-x86_64-unknown-linux-musl/sccache /usr/local/bin/sccache \
|
||||||
|
&& rm -rf /tmp/sccache-v0.8.1-x86_64-unknown-linux-musl \
|
||||||
|
&& chmod +x /usr/local/bin/sccache
|
||||||
+8
-8
@@ -6,7 +6,7 @@ later sections document what's known to compile, what's stubbed, and
|
|||||||
the next milestones.
|
the next milestones.
|
||||||
|
|
||||||
> **Status (2026-05-07):** First working APK at `fb8b2ac`. 54 MB
|
> **Status (2026-05-07):** First working APK at `fb8b2ac`. 54 MB
|
||||||
> debug-signed `solitaire-quest.apk` for `x86_64-linux-android`. Has
|
> debug-signed `ferrous-solitaire.apk` for `x86_64-linux-android`. Has
|
||||||
> NOT yet been verified to launch on a device or emulator — that's
|
> NOT yet been verified to launch on a device or emulator — that's
|
||||||
> the next milestone.
|
> the next milestone.
|
||||||
|
|
||||||
@@ -121,7 +121,7 @@ cargo apk build -p solitaire_app --target x86_64-linux-android
|
|||||||
Output:
|
Output:
|
||||||
|
|
||||||
```
|
```
|
||||||
target/debug/apk/solitaire-quest.apk
|
target/debug/apk/ferrous-solitaire.apk
|
||||||
```
|
```
|
||||||
|
|
||||||
Targets shipped via `[package.metadata.android].build_targets` in
|
Targets shipped via `[package.metadata.android].build_targets` in
|
||||||
@@ -164,8 +164,8 @@ Physical device:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
adb devices # confirm connection
|
adb devices # confirm connection
|
||||||
adb install target/debug/apk/solitaire-quest.apk
|
adb install target/debug/apk/ferrous-solitaire.apk
|
||||||
adb shell am start -n com.solitairequest.app/android.app.NativeActivity
|
adb shell am start -n com.ferrousapp.solitaire/android.app.NativeActivity
|
||||||
adb logcat | grep -iE "RustStdoutStderr|solitaire|panic"
|
adb logcat | grep -iE "RustStdoutStderr|solitaire|panic"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -174,7 +174,7 @@ Emulator:
|
|||||||
```bash
|
```bash
|
||||||
emulator -avd bevy_test -no-window -gpu swiftshader_indirect &
|
emulator -avd bevy_test -no-window -gpu swiftshader_indirect &
|
||||||
adb wait-for-device
|
adb wait-for-device
|
||||||
adb install target/debug/apk/solitaire-quest.apk
|
adb install target/debug/apk/ferrous-solitaire.apk
|
||||||
# ... same start + logcat steps as above.
|
# ... same start + logcat steps as above.
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -203,7 +203,7 @@ What's NOT yet ported / not yet measured:
|
|||||||
- `dirs::data_dir()` returns `None` on Android. Callers in
|
- `dirs::data_dir()` returns `None` on Android. Callers in
|
||||||
`solitaire_data/src/storage.rs`, `progress.rs`, `replay.rs`,
|
`solitaire_data/src/storage.rs`, `progress.rs`, `replay.rs`,
|
||||||
`achievements.rs`, `settings.rs` all need an Android-aware
|
`achievements.rs`, `settings.rs` all need an Android-aware
|
||||||
helper (likely `/data/data/com.solitairequest.app/files`).
|
helper (likely `/data/data/com.ferrousapp.solitaire/files`).
|
||||||
- Touch UX pass — hit-target sizes, modal scaling on small screens,
|
- Touch UX pass — hit-target sizes, modal scaling on small screens,
|
||||||
app lifecycle (suspend / resume), font scaling.
|
app lifecycle (suspend / resume), font scaling.
|
||||||
- Android Keystore via JNI for `auth_tokens`.
|
- Android Keystore via JNI for `auth_tokens`.
|
||||||
@@ -221,8 +221,8 @@ cargo build -p solitaire_app # desktop sanity
|
|||||||
cargo clippy --workspace --all-targets -- -D warnings # gate
|
cargo clippy --workspace --all-targets -- -D warnings # gate
|
||||||
cargo test --workspace # gate
|
cargo test --workspace # gate
|
||||||
cargo apk build -p solitaire_app --target x86_64-linux-android --lib
|
cargo apk build -p solitaire_app --target x86_64-linux-android --lib
|
||||||
adb install -r target/debug/apk/solitaire-quest.apk # `-r` reinstalls
|
adb install -r target/debug/apk/ferrous-solitaire.apk # `-r` reinstalls
|
||||||
adb logcat -c && adb shell am start -n com.solitairequest.app/android.app.NativeActivity
|
adb logcat -c && adb shell am start -n com.ferrousapp.solitaire/android.app.NativeActivity
|
||||||
adb logcat | grep -iE "RustStdoutStderr|solitaire"
|
adb logcat | grep -iE "RustStdoutStderr|solitaire"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -39,13 +39,13 @@ Before starting, delete any existing local save files to ensure a clean state:
|
|||||||
|
|
||||||
```
|
```
|
||||||
# Linux
|
# Linux
|
||||||
rm -rf ~/.local/share/solitaire_quest/
|
rm -rf ~/.local/share/ferrous_solitaire/
|
||||||
|
|
||||||
# macOS
|
# macOS
|
||||||
rm -rf ~/Library/Application\ Support/solitaire_quest/
|
rm -rf ~/Library/Application\ Support/ferrous_solitaire/
|
||||||
|
|
||||||
# Windows
|
# Windows
|
||||||
rmdir /s %APPDATA%\solitaire_quest\
|
rmdir /s %APPDATA%\ferrous_solitaire\
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -130,10 +130,10 @@ On the machine where you want to test (Linux example):
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# List keychain entries (uses secret-tool on GNOME)
|
# List keychain entries (uses secret-tool on GNOME)
|
||||||
secret-tool search service solitaire_quest_server
|
secret-tool search service ferrous_solitaire_server
|
||||||
|
|
||||||
# Overwrite alice's access token with a deliberately invalid value
|
# Overwrite alice's access token with a deliberately invalid value
|
||||||
secret-tool store --label="alice_access" service solitaire_quest_server account alice_access <<< "invalid.token.value"
|
secret-tool store --label="alice_access" service ferrous_solitaire_server account alice_access <<< "invalid.token.value"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 2 — Trigger a sync with the expired/invalid token
|
### Step 2 — Trigger a sync with the expired/invalid token
|
||||||
@@ -148,7 +148,7 @@ secret-tool store --label="alice_access" service solitaire_quest_server account
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Extract the new token from the keychain
|
# Extract the new token from the keychain
|
||||||
secret-tool lookup service solitaire_quest_server account alice_access | head -c 50
|
secret-tool lookup service ferrous_solitaire_server account alice_access | head -c 50
|
||||||
# Should look like a valid JWT (three base64 segments separated by dots)
|
# Should look like a valid JWT (three base64 segments separated by dots)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -157,8 +157,8 @@ secret-tool lookup service solitaire_quest_server account alice_access | head -c
|
|||||||
1. Corrupt both the access token and the refresh token in the keychain:
|
1. Corrupt both the access token and the refresh token in the keychain:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
secret-tool store --label="alice_access" service solitaire_quest_server account alice_access <<< "bad"
|
secret-tool store --label="alice_access" service ferrous_solitaire_server account alice_access <<< "bad"
|
||||||
secret-tool store --label="alice_refresh" service solitaire_quest_server account alice_refresh <<< "bad"
|
secret-tool store --label="alice_refresh" service ferrous_solitaire_server account alice_refresh <<< "bad"
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Launch the game and trigger a sync.
|
2. Launch the game and trigger a sync.
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# Maintainer: funman300 <funman300@gmail.com>
|
# Maintainer: funman300 <funman300@gmail.com>
|
||||||
|
|
||||||
pkgname=solitaire-quest-server
|
pkgname=ferrous-solitaire-server
|
||||||
pkgver=0.1.0
|
pkgver=0.1.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc='Self-hosted sync server for Solitaire Quest (stats, achievements, leaderboards)'
|
pkgdesc='Self-hosted sync server for Ferrous Solitaire (stats, achievements, leaderboards)'
|
||||||
url='https://github.com/funman300/solitaire-quest'
|
url='https://github.com/funman300/ferrous-solitaire'
|
||||||
license=('MIT')
|
license=('MIT')
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
makedepends=('cargo' 'rust')
|
makedepends=('cargo' 'rust')
|
||||||
@@ -12,12 +12,12 @@ depends=(
|
|||||||
'gcc-libs'
|
'gcc-libs'
|
||||||
'glibc'
|
'glibc'
|
||||||
)
|
)
|
||||||
backup=('etc/solitaire-quest-server/server.env')
|
backup=('etc/ferrous-solitaire-server/server.env')
|
||||||
|
|
||||||
# Build from the local workspace (two levels above this PKGBUILD).
|
# Build from the local workspace (two levels above this PKGBUILD).
|
||||||
_srcdir="$startdir/../.."
|
_srcdir="$startdir/../.."
|
||||||
source=(
|
source=(
|
||||||
'solitaire-quest-server.service'
|
'ferrous-solitaire-server.service'
|
||||||
'server.env'
|
'server.env'
|
||||||
)
|
)
|
||||||
b2sums=('SKIP'
|
b2sums=('SKIP'
|
||||||
@@ -49,12 +49,12 @@ package() {
|
|||||||
install -Dm0755 -t "$pkgdir/usr/bin/" "target/release/solitaire_server"
|
install -Dm0755 -t "$pkgdir/usr/bin/" "target/release/solitaire_server"
|
||||||
|
|
||||||
# systemd service
|
# systemd service
|
||||||
install -Dm0644 "$srcdir/solitaire-quest-server.service" \
|
install -Dm0644 "$srcdir/ferrous-solitaire-server.service" \
|
||||||
"$pkgdir/usr/lib/systemd/system/solitaire-quest-server.service"
|
"$pkgdir/usr/lib/systemd/system/ferrous-solitaire-server.service"
|
||||||
|
|
||||||
# Environment file (contains JWT_SECRET, DATABASE_URL, SERVER_PORT)
|
# Environment file (contains JWT_SECRET, DATABASE_URL, SERVER_PORT)
|
||||||
install -Dm0640 "$srcdir/server.env" \
|
install -Dm0640 "$srcdir/server.env" \
|
||||||
"$pkgdir/etc/solitaire-quest-server/server.env"
|
"$pkgdir/etc/ferrous-solitaire-server/server.env"
|
||||||
|
|
||||||
# License and docs
|
# License and docs
|
||||||
install -Dm0644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
install -Dm0644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Ferrous Solitaire Sync Server
|
||||||
|
Documentation=https://github.com/funman300/ferrous-solitaire/blob/main/README_SERVER.md
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=ferrous-solitaire
|
||||||
|
Group=ferrous-solitaire
|
||||||
|
EnvironmentFile=/etc/ferrous-solitaire-server/server.env
|
||||||
|
ExecStart=/usr/bin/solitaire_server
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5s
|
||||||
|
|
||||||
|
# Harden the service
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
ReadWritePaths=/var/lib/ferrous-solitaire-server
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
# Solitaire Quest Server — environment configuration
|
# Ferrous Solitaire Server — environment configuration
|
||||||
# This file is installed to /etc/solitaire-quest-server/server.env (mode 0640).
|
# This file is installed to /etc/ferrous-solitaire-server/server.env (mode 0640).
|
||||||
# Edit these values before starting the service.
|
# Edit these values before starting the service.
|
||||||
|
|
||||||
# Path to the SQLite database file.
|
# Path to the SQLite database file.
|
||||||
# The directory must be writable by the solitaire-quest service user.
|
# The directory must be writable by the ferrous-solitaire service user.
|
||||||
DATABASE_URL=sqlite:///var/lib/solitaire-quest-server/solitaire.db
|
DATABASE_URL=sqlite:///var/lib/ferrous-solitaire-server/solitaire.db
|
||||||
|
|
||||||
# HS256 signing secret for JWT tokens.
|
# HS256 signing secret for JWT tokens.
|
||||||
# Generate a strong secret with: openssl rand -hex 32
|
# Generate a strong secret with: openssl rand -hex 32
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
# Maintainer: funman300 <funman300@gmail.com>
|
# Maintainer: funman300 <funman300@gmail.com>
|
||||||
|
|
||||||
pkgname=solitaire-quest
|
pkgname=ferrous-solitaire
|
||||||
pkgver=0.1.0
|
pkgver=0.1.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc='Cross-platform Klondike Solitaire with progression, achievements, and optional sync'
|
pkgdesc='Cross-platform Klondike Solitaire with progression, achievements, and optional sync'
|
||||||
url='https://github.com/funman300/solitaire-quest'
|
url='https://github.com/funman300/ferrous-solitaire'
|
||||||
license=('MIT')
|
license=('MIT')
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
makedepends=('cargo' 'rust')
|
makedepends=('cargo' 'rust')
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Solitaire Quest Sync Server
|
|
||||||
Documentation=https://github.com/funman300/solitaire-quest/blob/main/README_SERVER.md
|
|
||||||
After=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=solitaire-quest
|
|
||||||
Group=solitaire-quest
|
|
||||||
EnvironmentFile=/etc/solitaire-quest-server/server.env
|
|
||||||
ExecStart=/usr/bin/solitaire_server
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=5s
|
|
||||||
|
|
||||||
# Harden the service
|
|
||||||
NoNewPrivileges=true
|
|
||||||
PrivateTmp=true
|
|
||||||
ProtectSystem=strict
|
|
||||||
ProtectHome=true
|
|
||||||
ReadWritePaths=/var/lib/solitaire-quest-server
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
# "arm64-v8a armeabi-v7a x86_64"). Reduce in CI to
|
# "arm64-v8a armeabi-v7a x86_64"). Reduce in CI to
|
||||||
# fit the runner's disk budget — a full three-ABI
|
# fit the runner's disk budget — a full three-ABI
|
||||||
# debug build can exceed 25 GB of target/ output.
|
# debug build can exceed 25 GB of target/ output.
|
||||||
# APK_OUT Output APK path (default: target/$PROFILE/apk/solitaire-quest.apk)
|
# APK_OUT Output APK path (default: target/$PROFILE/apk/ferrous-solitaire.apk)
|
||||||
# KEYSTORE Path to keystore for signing (default: generates a debug keystore)
|
# KEYSTORE Path to keystore for signing (default: generates a debug keystore)
|
||||||
# KEYSTORE_PASS Keystore password (default: "android" for the generated debug keystore)
|
# KEYSTORE_PASS Keystore password (default: "android" for the generated debug keystore)
|
||||||
# KEY_ALIAS Key alias (default: "androiddebugkey")
|
# KEY_ALIAS Key alias (default: "androiddebugkey")
|
||||||
@@ -35,7 +35,7 @@ set -euo pipefail
|
|||||||
|
|
||||||
PROFILE="${PROFILE:-debug}"
|
PROFILE="${PROFILE:-debug}"
|
||||||
ABIS="${ABIS:-arm64-v8a armeabi-v7a x86_64}"
|
ABIS="${ABIS:-arm64-v8a armeabi-v7a x86_64}"
|
||||||
APK_OUT="${APK_OUT:-target/${PROFILE}/apk/solitaire-quest.apk}"
|
APK_OUT="${APK_OUT:-target/${PROFILE}/apk/ferrous-solitaire.apk}"
|
||||||
|
|
||||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
cd "$REPO_ROOT"
|
cd "$REPO_ROOT"
|
||||||
@@ -75,12 +75,27 @@ if [ -d "$RES_DIR" ]; then
|
|||||||
"$BT/aapt2" compile --dir "$RES_DIR" -o "$STAGING/compiled-res"
|
"$BT/aapt2" compile --dir "$RES_DIR" -o "$STAGING/compiled-res"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Derive versionCode/versionName from VERSION_NAME env var (e.g. "v0.28.0" → code 2800, name "0.28.0").
|
||||||
|
# AndroidManifest.xml intentionally has no versionCode/versionName — aapt2's --version-* flags only
|
||||||
|
# inject when absent, so the manifest must be clean for CI injection to work. Local debug builds
|
||||||
|
# fall back to code=1 / name="0.0.0-dev".
|
||||||
|
if [ -n "${VERSION_NAME:-}" ]; then
|
||||||
|
VN="${VERSION_NAME#v}"
|
||||||
|
IFS='.' read -r _MAJ _MIN _PAT <<< "$VN"
|
||||||
|
VERSION_CODE=$(( ${_MAJ:-0} * 10000 + ${_MIN:-0} * 100 + ${_PAT:-0} ))
|
||||||
|
else
|
||||||
|
VERSION_CODE=1
|
||||||
|
VERSION_NAME="0.0.0-dev"
|
||||||
|
fi
|
||||||
|
|
||||||
LINK_ARGS=(
|
LINK_ARGS=(
|
||||||
link
|
link
|
||||||
-o "$STAGING/app-unsigned.apk"
|
-o "$STAGING/app-unsigned.apk"
|
||||||
-I "$PLATFORM_JAR"
|
-I "$PLATFORM_JAR"
|
||||||
--manifest "$MANIFEST"
|
--manifest "$MANIFEST"
|
||||||
)
|
)
|
||||||
|
[ -n "$VERSION_CODE" ] && LINK_ARGS+=( --version-code "$VERSION_CODE" )
|
||||||
|
[ -n "${VERSION_NAME:-}" ] && LINK_ARGS+=( --version-name "${VERSION_NAME#v}" )
|
||||||
[ -d "$ASSETS_DIR" ] && LINK_ARGS+=( -A "$ASSETS_DIR" )
|
[ -d "$ASSETS_DIR" ] && LINK_ARGS+=( -A "$ASSETS_DIR" )
|
||||||
# Add compiled resources if any
|
# Add compiled resources if any
|
||||||
shopt -s nullglob
|
shopt -s nullglob
|
||||||
|
|||||||
@@ -56,8 +56,8 @@ tiny-skia = { workspace = true }
|
|||||||
# already uses ships into the APK without copy-tree gymnastics.
|
# already uses ships into the APK without copy-tree gymnastics.
|
||||||
# `apk_name` keeps the output filename predictable across machines.
|
# `apk_name` keeps the output filename predictable across machines.
|
||||||
[package.metadata.android]
|
[package.metadata.android]
|
||||||
package = "com.solitairequest.app"
|
package = "com.ferrousapp.solitaire"
|
||||||
apk_name = "solitaire-quest"
|
apk_name = "ferrous-solitaire"
|
||||||
build_targets = ["aarch64-linux-android", "armv7-linux-androideabi", "x86_64-linux-android"]
|
build_targets = ["aarch64-linux-android", "armv7-linux-androideabi", "x86_64-linux-android"]
|
||||||
assets = "../assets"
|
assets = "../assets"
|
||||||
# Density-bucketed launcher icons. `aapt` processes `res/mipmap-*/` and
|
# Density-bucketed launcher icons. `aapt` processes `res/mipmap-*/` and
|
||||||
|
|||||||
@@ -13,9 +13,7 @@
|
|||||||
shared object name without the `lib` prefix or `.so` suffix.
|
shared object name without the `lib` prefix or `.so` suffix.
|
||||||
-->
|
-->
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
package="com.solitairequest.app"
|
package="com.ferrousapp.solitaire">
|
||||||
android:versionCode="1"
|
|
||||||
android:versionName="1.0">
|
|
||||||
|
|
||||||
<uses-sdk
|
<uses-sdk
|
||||||
android:minSdkVersion="26"
|
android:minSdkVersion="26"
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ pub fn run() {
|
|||||||
title: "Ferrous Solitaire".into(),
|
title: "Ferrous Solitaire".into(),
|
||||||
// X11/Wayland WM_CLASS so taskbar managers group
|
// X11/Wayland WM_CLASS so taskbar managers group
|
||||||
// multiple windows of this app correctly.
|
// multiple windows of this app correctly.
|
||||||
name: Some("solitaire-quest".into()),
|
name: Some("ferrous-solitaire".into()),
|
||||||
resolution: window_resolution,
|
resolution: window_resolution,
|
||||||
position: window_position,
|
position: window_position,
|
||||||
// AutoNoVsync prefers Mailbox (triple-buffered) and
|
// AutoNoVsync prefers Mailbox (triple-buffered) and
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ fn main() {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let cfg = SolverConfig { move_budget, state_budget };
|
let cfg = SolverConfig { move_budget, state_budget };
|
||||||
match try_solve(seed, draw_mode.clone(), &cfg) {
|
match try_solve(seed, draw_mode, &cfg) {
|
||||||
SolverResult::Winnable => {
|
SolverResult::Winnable => {
|
||||||
buckets[i].push(seed);
|
buckets[i].push(seed);
|
||||||
eprintln!(
|
eprintln!(
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ fn main() {
|
|||||||
while found.len() < count {
|
while found.len() < count {
|
||||||
tried += 1;
|
tried += 1;
|
||||||
if matches!(
|
if matches!(
|
||||||
try_solve(seed, draw_mode.clone(), &cfg),
|
try_solve(seed, draw_mode, &cfg),
|
||||||
SolverResult::Winnable
|
SolverResult::Winnable
|
||||||
) {
|
) {
|
||||||
found.push(seed);
|
found.push(seed);
|
||||||
|
|||||||
@@ -8,9 +8,11 @@
|
|||||||
//! walks `ALL_ACHIEVEMENTS`, evaluates each `condition`, and emits an
|
//! walks `ALL_ACHIEVEMENTS`, evaluates each `condition`, and emits an
|
||||||
//! unlock event for any `AchievementDef` whose record is not yet unlocked.
|
//! unlock event for any `AchievementDef` whose record is not yet unlocked.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// Fields needed by achievement conditions. Constructed by the engine from
|
/// Fields needed by achievement conditions. Constructed by the engine from
|
||||||
/// `StatsSnapshot`, the final `GameState`, and wall-clock time.
|
/// `StatsSnapshot`, the final `GameState`, and wall-clock time.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct AchievementContext {
|
pub struct AchievementContext {
|
||||||
/// Total number of games played (after this win has been recorded).
|
/// Total number of games played (after this win has been recorded).
|
||||||
pub games_played: u32,
|
pub games_played: u32,
|
||||||
|
|||||||
+88
-33
@@ -10,6 +10,9 @@ pub enum Suit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Suit {
|
impl Suit {
|
||||||
|
/// All four suits in declaration order.
|
||||||
|
pub const SUITS: [Self; 4] = [Self::Clubs, Self::Diamonds, Self::Hearts, Self::Spades];
|
||||||
|
|
||||||
/// Returns `true` for red suits (Diamonds, Hearts).
|
/// Returns `true` for red suits (Diamonds, Hearts).
|
||||||
pub fn is_red(self) -> bool {
|
pub fn is_red(self) -> bool {
|
||||||
matches!(self, Suit::Diamonds | Suit::Hearts)
|
matches!(self, Suit::Diamonds | Suit::Hearts)
|
||||||
@@ -24,38 +27,63 @@ impl Suit {
|
|||||||
/// Card rank, Ace through King.
|
/// Card rank, Ace through King.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
pub enum Rank {
|
pub enum Rank {
|
||||||
Ace,
|
Ace = 1,
|
||||||
Two,
|
Two = 2,
|
||||||
Three,
|
Three = 3,
|
||||||
Four,
|
Four = 4,
|
||||||
Five,
|
Five = 5,
|
||||||
Six,
|
Six = 6,
|
||||||
Seven,
|
Seven = 7,
|
||||||
Eight,
|
Eight = 8,
|
||||||
Nine,
|
Nine = 9,
|
||||||
Ten,
|
Ten = 10,
|
||||||
Jack,
|
Jack = 11,
|
||||||
Queen,
|
Queen = 12,
|
||||||
King,
|
King = 13,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Rank {
|
impl Rank {
|
||||||
|
/// All thirteen ranks in ascending order.
|
||||||
|
pub const RANKS: [Self; 13] = [
|
||||||
|
Self::Ace, Self::Two, Self::Three, Self::Four, Self::Five,
|
||||||
|
Self::Six, Self::Seven, Self::Eight, Self::Nine, Self::Ten,
|
||||||
|
Self::Jack, Self::Queen, Self::King,
|
||||||
|
];
|
||||||
|
|
||||||
/// Numeric value: Ace = 1, King = 13.
|
/// Numeric value: Ace = 1, King = 13.
|
||||||
pub fn value(self) -> u8 {
|
pub fn value(self) -> u8 {
|
||||||
match self {
|
self as u8
|
||||||
Rank::Ace => 1,
|
}
|
||||||
Rank::Two => 2,
|
|
||||||
Rank::Three => 3,
|
const fn new(n: u8) -> Option<Self> {
|
||||||
Rank::Four => 4,
|
match n {
|
||||||
Rank::Five => 5,
|
1 => Some(Self::Ace),
|
||||||
Rank::Six => 6,
|
2 => Some(Self::Two),
|
||||||
Rank::Seven => 7,
|
3 => Some(Self::Three),
|
||||||
Rank::Eight => 8,
|
4 => Some(Self::Four),
|
||||||
Rank::Nine => 9,
|
5 => Some(Self::Five),
|
||||||
Rank::Ten => 10,
|
6 => Some(Self::Six),
|
||||||
Rank::Jack => 11,
|
7 => Some(Self::Seven),
|
||||||
Rank::Queen => 12,
|
8 => Some(Self::Eight),
|
||||||
Rank::King => 13,
|
9 => Some(Self::Nine),
|
||||||
|
10 => Some(Self::Ten),
|
||||||
|
11 => Some(Self::Jack),
|
||||||
|
12 => Some(Self::Queen),
|
||||||
|
13 => Some(Self::King),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the rank `n` steps above `self`, or `None` if it would exceed King.
|
||||||
|
pub const fn checked_add(self, n: u8) -> Option<Self> {
|
||||||
|
Self::new((self as u8).saturating_add(n))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the rank `n` steps below `self`, or `None` if it would go below Ace.
|
||||||
|
pub const fn checked_sub(self, n: u8) -> Option<Self> {
|
||||||
|
match (self as u8).checked_sub(n) {
|
||||||
|
Some(v) => Self::new(v),
|
||||||
|
None => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -79,16 +107,43 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rank_values_are_sequential() {
|
fn rank_values_are_sequential() {
|
||||||
let ranks = [
|
for (i, r) in Rank::RANKS.iter().enumerate() {
|
||||||
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five,
|
|
||||||
Rank::Six, Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten,
|
|
||||||
Rank::Jack, Rank::Queen, Rank::King,
|
|
||||||
];
|
|
||||||
for (i, r) in ranks.iter().enumerate() {
|
|
||||||
assert_eq!(r.value(), (i + 1) as u8);
|
assert_eq!(r.value(), (i + 1) as u8);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rank_as_u8_matches_value() {
|
||||||
|
for r in Rank::RANKS {
|
||||||
|
assert_eq!(r as u8, r.value());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rank_checked_add_boundary() {
|
||||||
|
assert_eq!(Rank::King.checked_add(1), None);
|
||||||
|
assert_eq!(Rank::Queen.checked_add(1), Some(Rank::King));
|
||||||
|
assert_eq!(Rank::Ace.checked_add(1), Some(Rank::Two));
|
||||||
|
assert_eq!(Rank::Five.checked_add(3), Some(Rank::Eight));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rank_checked_sub_boundary() {
|
||||||
|
assert_eq!(Rank::Ace.checked_sub(1), None);
|
||||||
|
assert_eq!(Rank::Two.checked_sub(1), Some(Rank::Ace));
|
||||||
|
assert_eq!(Rank::King.checked_sub(1), Some(Rank::Queen));
|
||||||
|
assert_eq!(Rank::Five.checked_sub(3), Some(Rank::Two));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn suit_suits_contains_all_four() {
|
||||||
|
assert_eq!(Suit::SUITS.len(), 4);
|
||||||
|
assert!(Suit::SUITS.contains(&Suit::Clubs));
|
||||||
|
assert!(Suit::SUITS.contains(&Suit::Diamonds));
|
||||||
|
assert!(Suit::SUITS.contains(&Suit::Hearts));
|
||||||
|
assert!(Suit::SUITS.contains(&Suit::Spades));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn suit_red_and_black_are_complementary() {
|
fn suit_red_and_black_are_complementary() {
|
||||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ mod pile_map_serde {
|
|||||||
use crate::pile::{Pile, PileType};
|
use crate::pile::{Pile, PileType};
|
||||||
|
|
||||||
pub fn serialize<S: Serializer>(map: &HashMap<PileType, Pile>, s: S) -> Result<S::Ok, S::Error> {
|
pub fn serialize<S: Serializer>(map: &HashMap<PileType, Pile>, s: S) -> Result<S::Ok, S::Error> {
|
||||||
let entries: Vec<(&PileType, &Pile)> = map.iter().collect();
|
let mut entries: Vec<(&PileType, &Pile)> = map.iter().collect();
|
||||||
|
entries.sort_by_key(|(k, _)| *k);
|
||||||
entries.serialize(s)
|
entries.serialize(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +43,7 @@ mod pile_map_serde {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Whether cards are drawn one at a time or three at a time from the stock.
|
/// Whether cards are drawn one at a time or three at a time from the stock.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub enum DrawMode {
|
pub enum DrawMode {
|
||||||
/// Draw one card from stock per turn.
|
/// Draw one card from stock per turn.
|
||||||
DrawOne,
|
DrawOne,
|
||||||
@@ -154,6 +155,7 @@ pub struct GameState {
|
|||||||
/// [`GAME_STATE_SCHEMA_VERSION`].
|
/// [`GAME_STATE_SCHEMA_VERSION`].
|
||||||
#[serde(default = "schema_v1")]
|
#[serde(default = "schema_v1")]
|
||||||
pub schema_version: u32,
|
pub schema_version: u32,
|
||||||
|
#[serde(skip)]
|
||||||
undo_stack: VecDeque<StateSnapshot>,
|
undo_stack: VecDeque<StateSnapshot>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,10 +226,10 @@ impl GameState {
|
|||||||
return Err(MoveError::GameAlreadyWon);
|
return Err(MoveError::GameAlreadyWon);
|
||||||
}
|
}
|
||||||
|
|
||||||
let stock_len = self.piles[&PileType::Stock].cards.len();
|
let stock_len = self.piles.get(&PileType::Stock).ok_or(MoveError::InvalidSource)?.cards.len();
|
||||||
|
|
||||||
if stock_len == 0 {
|
if stock_len == 0 {
|
||||||
let waste_len = self.piles[&PileType::Waste].cards.len();
|
let waste_len = self.piles.get(&PileType::Waste).ok_or(MoveError::InvalidSource)?.cards.len();
|
||||||
if waste_len == 0 {
|
if waste_len == 0 {
|
||||||
return Err(MoveError::StockEmpty);
|
return Err(MoveError::StockEmpty);
|
||||||
}
|
}
|
||||||
@@ -245,7 +247,7 @@ impl GameState {
|
|||||||
stock.cards.push(card);
|
stock.cards.push(card);
|
||||||
}
|
}
|
||||||
self.recycle_count = self.recycle_count.saturating_add(1);
|
self.recycle_count = self.recycle_count.saturating_add(1);
|
||||||
self.move_count += 1;
|
self.move_count = self.move_count.saturating_add(1);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,7 +273,7 @@ impl GameState {
|
|||||||
waste.cards.push(card);
|
waste.cards.push(card);
|
||||||
}
|
}
|
||||||
|
|
||||||
self.move_count += 1;
|
self.move_count = self.move_count.saturating_add(1);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,7 +381,7 @@ impl GameState {
|
|||||||
self.piles.get_mut(&to).ok_or(MoveError::InvalidDestination)?.cards.append(&mut moved);
|
self.piles.get_mut(&to).ok_or(MoveError::InvalidDestination)?.cards.append(&mut moved);
|
||||||
|
|
||||||
self.score = (self.score + score_delta).max(0);
|
self.score = (self.score + score_delta).max(0);
|
||||||
self.move_count += 1;
|
self.move_count = self.move_count.saturating_add(1);
|
||||||
|
|
||||||
self.is_won = self.check_win();
|
self.is_won = self.check_win();
|
||||||
if !self.is_won {
|
if !self.is_won {
|
||||||
@@ -405,7 +407,7 @@ impl GameState {
|
|||||||
self.score = if self.mode == GameMode::Zen {
|
self.score = if self.mode == GameMode::Zen {
|
||||||
0
|
0
|
||||||
} else {
|
} else {
|
||||||
(snapshot.score + scoring_undo()).max(0)
|
(self.score + scoring_undo()).max(0)
|
||||||
};
|
};
|
||||||
self.move_count = snapshot.move_count;
|
self.move_count = snapshot.move_count;
|
||||||
self.is_won = false;
|
self.is_won = false;
|
||||||
@@ -414,12 +416,25 @@ impl GameState {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns `true` when all four foundation slots each contain 13 cards.
|
/// Returns `true` when all four foundation slots each contain a valid A→K
|
||||||
|
/// sequence of a single suit.
|
||||||
|
///
|
||||||
|
/// Counting 13 cards is not sufficient — a corrupt save could produce 13
|
||||||
|
/// arbitrary cards per pile and permanently lock the game via `GameAlreadyWon`.
|
||||||
pub fn check_win(&self) -> bool {
|
pub fn check_win(&self) -> bool {
|
||||||
(0..4_u8).all(|slot| {
|
(0..4_u8).all(|slot| self.is_valid_foundation_pile(slot))
|
||||||
self.piles
|
}
|
||||||
.get(&PileType::Foundation(slot))
|
|
||||||
.is_some_and(|p| p.cards.len() == 13)
|
fn is_valid_foundation_pile(&self, slot: u8) -> bool {
|
||||||
|
let Some(pile) = self.piles.get(&PileType::Foundation(slot)) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if pile.cards.len() != 13 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let suit = pile.cards[0].suit;
|
||||||
|
pile.cards.iter().enumerate().all(|(i, card)| {
|
||||||
|
card.suit == suit && card.rank.value() == (i as u8 + 1)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -428,17 +443,101 @@ impl GameState {
|
|||||||
pub fn check_auto_complete(&self) -> bool {
|
pub fn check_auto_complete(&self) -> bool {
|
||||||
// Stock must be empty; waste may still have cards (they are resolved
|
// Stock must be empty; waste may still have cards (they are resolved
|
||||||
// by draw() calls inside next_auto_complete_move / auto_complete_step).
|
// by draw() calls inside next_auto_complete_move / auto_complete_step).
|
||||||
if !self.piles[&PileType::Stock].cards.is_empty() {
|
if self.piles.get(&PileType::Stock).is_none_or(|p| !p.cards.is_empty()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
(0..7).all(|i| {
|
(0..7).all(|i| {
|
||||||
self.piles[&PileType::Tableau(i)]
|
self.piles
|
||||||
.cards
|
.get(&PileType::Tableau(i))
|
||||||
.iter()
|
.is_some_and(|p| p.cards.iter().all(|c| c.face_up))
|
||||||
.all(|c| c.face_up)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns all currently valid `move_cards` calls as `(from, to, count)` triples.
|
||||||
|
///
|
||||||
|
/// Does not include stock draws — callers check `piles[&PileType::Stock]` directly.
|
||||||
|
/// Every returned triple is guaranteed to succeed when passed to `move_cards`.
|
||||||
|
pub fn possible_instructions(&self) -> Vec<(PileType, PileType, usize)> {
|
||||||
|
if self.is_won {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut moves = Vec::new();
|
||||||
|
|
||||||
|
// Waste top card → foundation or tableau
|
||||||
|
if let Some(waste_top) = self.piles.get(&PileType::Waste).and_then(|p| p.cards.last()) {
|
||||||
|
for slot in 0..4_u8 {
|
||||||
|
if let Some(f) = self.piles.get(&PileType::Foundation(slot))
|
||||||
|
&& can_place_on_foundation(waste_top, f)
|
||||||
|
{
|
||||||
|
moves.push((PileType::Waste, PileType::Foundation(slot), 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for dst in 0..7_usize {
|
||||||
|
if let Some(t) = self.piles.get(&PileType::Tableau(dst))
|
||||||
|
&& can_place_on_tableau(waste_top, t)
|
||||||
|
{
|
||||||
|
moves.push((PileType::Waste, PileType::Tableau(dst), 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tableau sources
|
||||||
|
for src in 0..7_usize {
|
||||||
|
let Some(src_pile) = self.piles.get(&PileType::Tableau(src)) else { continue };
|
||||||
|
if src_pile.cards.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let run_len = src_pile.cards.iter().rev().take_while(|c| c.face_up).count();
|
||||||
|
if run_len == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for count in 1..=run_len {
|
||||||
|
let seq_start = src_pile.cards.len() - count;
|
||||||
|
if !is_valid_tableau_sequence(&src_pile.cards[seq_start..]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let bottom = &src_pile.cards[seq_start];
|
||||||
|
if count == 1 {
|
||||||
|
for slot in 0..4_u8 {
|
||||||
|
if let Some(f) = self.piles.get(&PileType::Foundation(slot))
|
||||||
|
&& can_place_on_foundation(bottom, f)
|
||||||
|
{
|
||||||
|
moves.push((PileType::Tableau(src), PileType::Foundation(slot), 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for dst in 0..7_usize {
|
||||||
|
if dst == src {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(t) = self.piles.get(&PileType::Tableau(dst))
|
||||||
|
&& can_place_on_tableau(bottom, t)
|
||||||
|
{
|
||||||
|
moves.push((PileType::Tableau(src), PileType::Tableau(dst), count));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Foundation top → tableau (only when house rule is enabled)
|
||||||
|
if self.take_from_foundation {
|
||||||
|
for slot in 0..4_u8 {
|
||||||
|
let Some(f) = self.piles.get(&PileType::Foundation(slot)) else { continue };
|
||||||
|
let Some(top) = f.cards.last() else { continue };
|
||||||
|
for dst in 0..7_usize {
|
||||||
|
if let Some(t) = self.piles.get(&PileType::Tableau(dst))
|
||||||
|
&& can_place_on_tableau(top, t)
|
||||||
|
{
|
||||||
|
moves.push((PileType::Foundation(slot), PileType::Tableau(dst), 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
moves
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns the next `(from, to)` move that advances auto-complete, or
|
/// Returns the next `(from, to)` move that advances auto-complete, or
|
||||||
/// `None` if no such move exists (or `is_auto_completable` is not set).
|
/// `None` if no such move exists (or `is_auto_completable` is not set).
|
||||||
///
|
///
|
||||||
@@ -461,7 +560,8 @@ impl GameState {
|
|||||||
// Check waste top first — when stock is exhausted the waste may still
|
// Check waste top first — when stock is exhausted the waste may still
|
||||||
// contain cards that can go directly to a foundation.
|
// contain cards that can go directly to a foundation.
|
||||||
let waste = PileType::Waste;
|
let waste = PileType::Waste;
|
||||||
if let Some((card, slot)) = self.piles[&waste].cards.last()
|
if let Some((card, slot)) = self.piles.get(&waste)
|
||||||
|
.and_then(|p| p.cards.last())
|
||||||
.and_then(|c| self.foundation_slot_for(c).map(|s| (c, s)))
|
.and_then(|c| self.foundation_slot_for(c).map(|s| (c, s)))
|
||||||
{
|
{
|
||||||
let _ = card; // borrow ends here
|
let _ = card; // borrow ends here
|
||||||
@@ -469,7 +569,8 @@ impl GameState {
|
|||||||
}
|
}
|
||||||
for i in 0..7 {
|
for i in 0..7 {
|
||||||
let tableau = PileType::Tableau(i);
|
let tableau = PileType::Tableau(i);
|
||||||
if let Some(slot) = self.piles[&tableau].cards.last()
|
if let Some(slot) = self.piles.get(&tableau)
|
||||||
|
.and_then(|p| p.cards.last())
|
||||||
.and_then(|c| self.foundation_slot_for(c))
|
.and_then(|c| self.foundation_slot_for(c))
|
||||||
{
|
{
|
||||||
return Some((tableau, PileType::Foundation(slot)));
|
return Some((tableau, PileType::Foundation(slot)));
|
||||||
@@ -487,7 +588,7 @@ impl GameState {
|
|||||||
let mut candidate: Option<u8> = None;
|
let mut candidate: Option<u8> = None;
|
||||||
let mut empty_slot: Option<u8> = None;
|
let mut empty_slot: Option<u8> = None;
|
||||||
for slot in 0..4_u8 {
|
for slot in 0..4_u8 {
|
||||||
let pile = &self.piles[&PileType::Foundation(slot)];
|
let Some(pile) = self.piles.get(&PileType::Foundation(slot)) else { continue };
|
||||||
if pile.cards.is_empty() {
|
if pile.cards.is_empty() {
|
||||||
if empty_slot.is_none() {
|
if empty_slot.is_none() {
|
||||||
empty_slot = Some(slot);
|
empty_slot = Some(slot);
|
||||||
@@ -501,7 +602,8 @@ impl GameState {
|
|||||||
if card.rank.value() == 1 { empty_slot } else { None }
|
if card.rank.value() == 1 { empty_slot } else { None }
|
||||||
});
|
});
|
||||||
target.filter(|&slot| {
|
target.filter(|&slot| {
|
||||||
can_place_on_foundation(card, &self.piles[&PileType::Foundation(slot)])
|
self.piles.get(&PileType::Foundation(slot))
|
||||||
|
.is_some_and(|p| can_place_on_foundation(card, p))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1306,12 +1408,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn take_from_foundation_allowed_by_default() {
|
fn take_from_foundation_enabled_by_default() {
|
||||||
let mut g = setup_take_from_foundation_game();
|
let g = setup_take_from_foundation_game();
|
||||||
assert!(g.take_from_foundation, "standard Klondike allows take-from-foundation by default");
|
assert!(g.take_from_foundation, "take_from_foundation is on by default (standard Klondike rule)");
|
||||||
g.move_cards(PileType::Foundation(0), PileType::Tableau(0), 1).unwrap();
|
|
||||||
assert_eq!(g.piles[&PileType::Foundation(0)].cards.len(), 1);
|
|
||||||
assert_eq!(g.piles[&PileType::Tableau(0)].cards.len(), 2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1362,4 +1461,78 @@ mod tests {
|
|||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(matches!(err, MoveError::RuleViolation(_)));
|
assert!(matches!(err, MoveError::RuleViolation(_)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- possible_instructions ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn possible_instructions_empty_when_won() {
|
||||||
|
let mut g = new_game();
|
||||||
|
g.is_won = true;
|
||||||
|
assert!(g.possible_instructions().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn possible_instructions_includes_ace_to_foundation() {
|
||||||
|
let mut g = new_game();
|
||||||
|
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||||
|
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||||
|
for i in 0..7 {
|
||||||
|
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||||
|
}
|
||||||
|
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
|
||||||
|
id: 1, suit: Suit::Clubs, rank: Rank::Ace, face_up: true,
|
||||||
|
});
|
||||||
|
let moves = g.possible_instructions();
|
||||||
|
assert!(
|
||||||
|
moves.contains(&(PileType::Tableau(0), PileType::Foundation(0), 1)),
|
||||||
|
"Ace must be moveable to empty foundation slot 0; got {moves:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn possible_instructions_all_valid_on_fresh_game() {
|
||||||
|
// Every triple returned must actually succeed when applied to a clone of the state.
|
||||||
|
let g = new_game();
|
||||||
|
for (from, to, count) in g.possible_instructions() {
|
||||||
|
let mut clone = g.clone();
|
||||||
|
assert!(
|
||||||
|
clone.move_cards(from.clone(), to.clone(), count).is_ok(),
|
||||||
|
"instruction ({from:?}, {to:?}, {count}) from possible_instructions must succeed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn possible_instructions_no_face_down_sources() {
|
||||||
|
let g = new_game();
|
||||||
|
for (from, _, count) in g.possible_instructions() {
|
||||||
|
if let PileType::Tableau(i) = from {
|
||||||
|
let pile = &g.piles[&PileType::Tableau(i)];
|
||||||
|
let run_len = pile.cards.iter().rev().take_while(|c| c.face_up).count();
|
||||||
|
assert!(
|
||||||
|
count <= run_len,
|
||||||
|
"count {count} exceeds face-up run {run_len} for Tableau({i})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn possible_instructions_waste_top_included() {
|
||||||
|
let mut g = new_game();
|
||||||
|
// Clear board, put a King on waste, and an empty tableau pile — waste→tableau must appear.
|
||||||
|
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||||
|
for i in 0..7 {
|
||||||
|
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||||||
|
}
|
||||||
|
g.piles.get_mut(&PileType::Waste).unwrap().cards.push(Card {
|
||||||
|
id: 99, suit: Suit::Spades, rank: Rank::King, face_up: true,
|
||||||
|
});
|
||||||
|
let moves = g.possible_instructions();
|
||||||
|
// King goes on any of the 7 empty tableau piles
|
||||||
|
assert!(
|
||||||
|
(0..7).any(|dst| moves.contains(&(PileType::Waste, PileType::Tableau(dst), 1))),
|
||||||
|
"King on waste must be moveable to an empty tableau column"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use crate::card::{Card, Suit};
|
use crate::card::{Card, Suit};
|
||||||
|
|
||||||
/// Identifies which pile on the board a set of cards belongs to.
|
/// Identifies which pile on the board a set of cards belongs to.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||||
pub enum PileType {
|
pub enum PileType {
|
||||||
/// The face-down draw pile.
|
/// The face-down draw pile.
|
||||||
Stock,
|
Stock,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::card::Card;
|
use crate::card::{Card, Rank};
|
||||||
use crate::pile::Pile;
|
use crate::pile::Pile;
|
||||||
|
|
||||||
/// Returns `true` if `card` can be placed on the foundation `pile`.
|
/// Returns `true` if `card` can be placed on the foundation `pile`.
|
||||||
@@ -9,22 +9,24 @@ use crate::pile::Pile;
|
|||||||
/// [`Pile::claimed_suit`](crate::pile::Pile::claimed_suit)).
|
/// [`Pile::claimed_suit`](crate::pile::Pile::claimed_suit)).
|
||||||
/// - When the pile is non-empty, the next card must match the top card's
|
/// - When the pile is non-empty, the next card must match the top card's
|
||||||
/// suit and be exactly one rank higher.
|
/// suit and be exactly one rank higher.
|
||||||
|
#[must_use]
|
||||||
pub fn can_place_on_foundation(card: &Card, pile: &Pile) -> bool {
|
pub fn can_place_on_foundation(card: &Card, pile: &Pile) -> bool {
|
||||||
match pile.cards.last() {
|
match pile.cards.last() {
|
||||||
None => card.rank.value() == 1,
|
None => card.rank == Rank::Ace,
|
||||||
Some(top) => card.suit == top.suit && card.rank.value() == top.rank.value() + 1,
|
Some(top) => card.suit == top.suit && card.rank.checked_sub(1) == Some(top.rank),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns `true` if `card` (or the bottom card of a sequence) can be placed on `pile` in the tableau.
|
/// Returns `true` if `card` (or the bottom card of a sequence) can be placed on `pile` in the tableau.
|
||||||
///
|
///
|
||||||
/// Tableau rules: Kings go on empty piles; otherwise alternating colour, one rank lower.
|
/// Tableau rules: Kings go on empty piles; otherwise alternating colour, one rank lower.
|
||||||
|
#[must_use]
|
||||||
pub fn can_place_on_tableau(card: &Card, pile: &Pile) -> bool {
|
pub fn can_place_on_tableau(card: &Card, pile: &Pile) -> bool {
|
||||||
match pile.cards.last() {
|
match pile.cards.last() {
|
||||||
None => card.rank.value() == 13,
|
None => card.rank == Rank::King,
|
||||||
Some(top) => {
|
Some(top) => {
|
||||||
top.face_up
|
top.face_up
|
||||||
&& card.rank.value() + 1 == top.rank.value()
|
&& card.rank.checked_add(1) == Some(top.rank)
|
||||||
&& card.suit.is_red() != top.suit.is_red()
|
&& card.suit.is_red() != top.suit.is_red()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -36,9 +38,10 @@ pub fn can_place_on_tableau(card: &Card, pile: &Pile) -> bool {
|
|||||||
/// only validates the sequence's *internal* structure, which the tableau
|
/// only validates the sequence's *internal* structure, which the tableau
|
||||||
/// move path must enforce so a player can't smuggle an arbitrary stack
|
/// move path must enforce so a player can't smuggle an arbitrary stack
|
||||||
/// onto another column when the bottom card happens to land legally.
|
/// onto another column when the bottom card happens to land legally.
|
||||||
|
#[must_use]
|
||||||
pub fn is_valid_tableau_sequence(cards: &[Card]) -> bool {
|
pub fn is_valid_tableau_sequence(cards: &[Card]) -> bool {
|
||||||
cards.windows(2).all(|w| {
|
cards.windows(2).all(|w| {
|
||||||
w[0].rank.value() == w[1].rank.value() + 1 && w[0].suit.is_red() != w[1].suit.is_red()
|
w[0].rank.checked_sub(1) == Some(w[1].rank) && w[0].suit.is_red() != w[1].suit.is_red()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ use crate::pile::PileType;
|
|||||||
pub fn score_move(from: &PileType, to: &PileType) -> i32 {
|
pub fn score_move(from: &PileType, to: &PileType) -> i32 {
|
||||||
match to {
|
match to {
|
||||||
PileType::Foundation(_) => 10,
|
PileType::Foundation(_) => 10,
|
||||||
PileType::Tableau(_) => {
|
PileType::Tableau(_) => match from {
|
||||||
if matches!(from, PileType::Waste) { 5 } else { 0 }
|
PileType::Waste => 5,
|
||||||
}
|
PileType::Foundation(_) => -15,
|
||||||
|
_ => 0,
|
||||||
|
},
|
||||||
_ => 0,
|
_ => 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,13 +73,12 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn non_waste_to_tableau_scores_zero() {
|
fn foundation_to_tableau_penalises_fifteen() {
|
||||||
// Foundation → Tableau is impossible in practice but must score 0.
|
// Moving a card back off a foundation (take_from_foundation rule) costs -15.
|
||||||
assert_eq!(score_move(&PileType::Foundation(0), &PileType::Tableau(0)), 0);
|
assert_eq!(score_move(&PileType::Foundation(0), &PileType::Tableau(0)), -15);
|
||||||
// Tableau → Tableau (restack) scores 0.
|
|
||||||
assert_eq!(score_move(&PileType::Tableau(1), &PileType::Tableau(2)), 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn move_to_stock_or_waste_scores_zero() {
|
fn move_to_stock_or_waste_scores_zero() {
|
||||||
// These destinations are illegal moves in practice, but the function
|
// These destinations are illegal moves in practice, but the function
|
||||||
|
|||||||
@@ -298,9 +298,16 @@ impl SolverState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True when every foundation slot has 13 cards.
|
/// True when every foundation slot holds a complete Ace-through-King sequence.
|
||||||
fn is_won(&self) -> bool {
|
fn is_won(&self) -> bool {
|
||||||
self.foundation.iter().all(|f| f.len() == 13)
|
self.foundation.iter().all(|pile| {
|
||||||
|
pile.len() == 13
|
||||||
|
&& pile[0].rank == crate::card::Rank::Ace
|
||||||
|
&& pile.windows(2).all(|w| {
|
||||||
|
w[0].suit == w[1].suit
|
||||||
|
&& w[1].rank.value() == w[0].rank.value() + 1
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the foundation slot that already claims `suit`, or the
|
/// Returns the foundation slot that already claims `suit`, or the
|
||||||
@@ -665,7 +672,7 @@ impl SolverState {
|
|||||||
foundation,
|
foundation,
|
||||||
stock,
|
stock,
|
||||||
waste,
|
waste,
|
||||||
draw_mode: game.draw_mode.clone(),
|
draw_mode: game.draw_mode,
|
||||||
just_drew: false,
|
just_drew: false,
|
||||||
consecutive_draws: 0,
|
consecutive_draws: 0,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,12 +10,11 @@ use std::path::{Path, PathBuf};
|
|||||||
|
|
||||||
pub use solitaire_sync::AchievementRecord;
|
pub use solitaire_sync::AchievementRecord;
|
||||||
|
|
||||||
const APP_DIR_NAME: &str = "solitaire_quest";
|
|
||||||
const FILE_NAME: &str = "achievements.json";
|
const FILE_NAME: &str = "achievements.json";
|
||||||
|
|
||||||
/// Platform-specific default path for `achievements.json`.
|
/// Platform-specific default path for `achievements.json`.
|
||||||
pub fn achievements_file_path() -> Option<PathBuf> {
|
pub fn achievements_file_path() -> Option<PathBuf> {
|
||||||
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(FILE_NAME))
|
crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(FILE_NAME))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load achievements from an explicit path. Returns `Vec::new()` if the file
|
/// Load achievements from an explicit path. Returns `Vec::new()` if the file
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ use std::path::PathBuf;
|
|||||||
|
|
||||||
use crate::auth_tokens::TokenError;
|
use crate::auth_tokens::TokenError;
|
||||||
|
|
||||||
const KEY_ALIAS: &str = "solitaire_quest_token_key";
|
const KEY_ALIAS: &str = "ferrous_solitaire_token_key";
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
struct TokenBlob {
|
struct TokenBlob {
|
||||||
@@ -295,9 +295,9 @@ fn read_file_bytes() -> Result<Vec<u8>, TokenError> {
|
|||||||
fn write_file_bytes(data: &[u8]) -> Result<(), TokenError> {
|
fn write_file_bytes(data: &[u8]) -> Result<(), TokenError> {
|
||||||
let path = token_file_path()
|
let path = token_file_path()
|
||||||
.ok_or_else(|| TokenError::KeychainUnavailable("no data dir".into()))?;
|
.ok_or_else(|| TokenError::KeychainUnavailable("no data dir".into()))?;
|
||||||
let tmp = path.with_extension("tmp");
|
let tmp = path.with_extension("bin.tmp");
|
||||||
std::fs::write(&tmp, data)
|
std::fs::write(&tmp, data)
|
||||||
.map_err(|e| TokenError::Keyring(format!("write auth_tokens.tmp: {e}")))?;
|
.map_err(|e| TokenError::Keyring(format!("write auth_tokens.bin.tmp: {e}")))?;
|
||||||
std::fs::rename(&tmp, &path)
|
std::fs::rename(&tmp, &path)
|
||||||
.map_err(|e| TokenError::Keyring(format!("rename auth_tokens: {e}")))
|
.map_err(|e| TokenError::Keyring(format!("rename auth_tokens: {e}")))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//! Secure storage for JWT access and refresh tokens using the OS keychain.
|
//! Secure storage for JWT access and refresh tokens using the OS keychain.
|
||||||
//!
|
//!
|
||||||
//! Tokens are stored under service name `"solitaire_quest_server"` with entry
|
//! Tokens are stored under service name `"ferrous_solitaire_server"` with entry
|
||||||
//! keys `"{username}_access"` and `"{username}_refresh"`.
|
//! keys `"{username}_access"` and `"{username}_refresh"`.
|
||||||
//!
|
//!
|
||||||
//! On Linux this requires a running secret service (GNOME Keyring / KWallet).
|
//! On Linux this requires a running secret service (GNOME Keyring / KWallet).
|
||||||
@@ -46,7 +46,7 @@ pub enum TokenError {
|
|||||||
|
|
||||||
/// Service name used to namespace all keychain entries for this application.
|
/// Service name used to namespace all keychain entries for this application.
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
const SERVICE: &str = "solitaire_quest_server";
|
const SERVICE: &str = "ferrous_solitaire_server";
|
||||||
|
|
||||||
/// Map a `keyring_core::Error` to the appropriate `TokenError`.
|
/// Map a `keyring_core::Error` to the appropriate `TokenError`.
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
|
|||||||
@@ -168,3 +168,6 @@ pub use matomo_client::MatomoClient;
|
|||||||
|
|
||||||
pub mod platform;
|
pub mod platform;
|
||||||
pub use platform::data_dir;
|
pub use platform::data_dir;
|
||||||
|
|
||||||
|
/// Application data subdirectory name, shared by all persistence modules.
|
||||||
|
pub(crate) const APP_DIR_NAME: &str = "ferrous_solitaire";
|
||||||
|
|||||||
@@ -111,12 +111,12 @@ impl MatomoClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn url_encode(s: &str) -> String {
|
fn url_encode(s: &str) -> String {
|
||||||
s.chars()
|
s.bytes()
|
||||||
.flat_map(|c| match c {
|
.flat_map(|b| match b {
|
||||||
'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => {
|
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||||
vec![c]
|
vec![b as char]
|
||||||
}
|
}
|
||||||
c => format!("%{:02X}", c as u32).chars().collect(),
|
b => format!("%{b:02X}").chars().collect(),
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
//! The rest of `solitaire_data` (settings, stats, achievements,
|
//! The rest of `solitaire_data` (settings, stats, achievements,
|
||||||
//! replays, progress, game state) and the engine's user-themes
|
//! replays, progress, game state) and the engine's user-themes
|
||||||
//! discovery all need a base path under which to nest
|
//! discovery all need a base path under which to nest
|
||||||
//! `solitaire_quest/<file>`. On desktop the right answer is
|
//! `ferrous_solitaire/<file>`. On desktop the right answer is
|
||||||
//! `dirs::data_dir()` (which resolves to platform-appropriate
|
//! `dirs::data_dir()` (which resolves to platform-appropriate
|
||||||
//! locations: `~/.local/share` on Linux, `~/Library/Application
|
//! locations: `~/.local/share` on Linux, `~/Library/Application
|
||||||
//! Support` on macOS, `%APPDATA%` on Windows). On Android the
|
//! Support` on macOS, `%APPDATA%` on Windows). On Android the
|
||||||
@@ -12,9 +12,9 @@
|
|||||||
//!
|
//!
|
||||||
//! [`data_dir`] is a thin shim that returns the right base path
|
//! [`data_dir`] is a thin shim that returns the right base path
|
||||||
//! per target. Callers continue to append
|
//! per target. Callers continue to append
|
||||||
//! `solitaire_quest/<file>` themselves, so the on-disk layout is
|
//! `ferrous_solitaire/<file>` themselves, so the on-disk layout is
|
||||||
//! identical across platforms (the per-app Android sandbox makes
|
//! identical across platforms (the per-app Android sandbox makes
|
||||||
//! the extra `solitaire_quest/` segment harmless, and a `tar`
|
//! the extra `ferrous_solitaire/` segment harmless, and a `tar`
|
||||||
//! export from one platform deserialises cleanly on another).
|
//! export from one platform deserialises cleanly on another).
|
||||||
//!
|
//!
|
||||||
//! # Why hardcode on Android?
|
//! # Why hardcode on Android?
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
//! `AndroidApp` context through Bevy's startup hooks and a
|
//! `AndroidApp` context through Bevy's startup hooks and a
|
||||||
//! per-call JNI bridge — meaningfully more code than the
|
//! per-call JNI bridge — meaningfully more code than the
|
||||||
//! sandbox-guaranteed `/data/data/<package>/files` path. The
|
//! sandbox-guaranteed `/data/data/<package>/files` path. The
|
||||||
//! package name `com.solitairequest.app` is fixed at compile
|
//! package name `com.ferrousapp.solitaire` is fixed at compile
|
||||||
//! time in `solitaire_app/Cargo.toml`'s
|
//! time in `solitaire_app/Cargo.toml`'s
|
||||||
//! `[package.metadata.android]` block, so a hardcoded path is
|
//! `[package.metadata.android]` block, so a hardcoded path is
|
||||||
//! safe until that ever changes (at which point this constant
|
//! safe until that ever changes (at which point this constant
|
||||||
@@ -40,14 +40,14 @@ use std::path::PathBuf;
|
|||||||
/// constant and the Cargo metadata together if the package id
|
/// constant and the Cargo metadata together if the package id
|
||||||
/// ever changes.
|
/// ever changes.
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
const ANDROID_APP_FILES_DIR: &str = "/data/data/com.solitairequest.app/files";
|
const ANDROID_APP_FILES_DIR: &str = "/data/data/com.ferrousapp.solitaire/files";
|
||||||
|
|
||||||
/// Returns the per-user data directory for the current target,
|
/// Returns the per-user data directory for the current target,
|
||||||
/// or `None` if the platform doesn't expose one (rare; usually
|
/// or `None` if the platform doesn't expose one (rare; usually
|
||||||
/// indicates a broken `$HOME` or `$XDG_*` configuration on a
|
/// indicates a broken `$HOME` or `$XDG_*` configuration on a
|
||||||
/// minimal Linux container).
|
/// minimal Linux container).
|
||||||
///
|
///
|
||||||
/// Callers append `solitaire_quest/<file>` themselves. See the
|
/// Callers append `ferrous_solitaire/<file>` themselves. See the
|
||||||
/// module-level doc comment for the per-platform behaviour and
|
/// module-level doc comment for the per-platform behaviour and
|
||||||
/// why Android uses a hardcoded path.
|
/// why Android uses a hardcoded path.
|
||||||
pub fn data_dir() -> Option<PathBuf> {
|
pub fn data_dir() -> Option<PathBuf> {
|
||||||
@@ -87,6 +87,6 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn data_dir_returns_sandbox_path_on_android() {
|
fn data_dir_returns_sandbox_path_on_android() {
|
||||||
let dir = data_dir().expect("android must report a data dir");
|
let dir = data_dir().expect("android must report a data dir");
|
||||||
assert_eq!(dir, PathBuf::from("/data/data/com.solitairequest.app/files"));
|
assert_eq!(dir, PathBuf::from("/data/data/com.ferrousapp.solitaire/files"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ use chrono::{Datelike, NaiveDate};
|
|||||||
pub use solitaire_sync::progress::level_for_xp;
|
pub use solitaire_sync::progress::level_for_xp;
|
||||||
pub use solitaire_sync::PlayerProgress;
|
pub use solitaire_sync::PlayerProgress;
|
||||||
|
|
||||||
const APP_DIR_NAME: &str = "solitaire_quest";
|
|
||||||
const FILE_NAME: &str = "progress.json";
|
const FILE_NAME: &str = "progress.json";
|
||||||
|
|
||||||
/// Deterministic seed derived from a date, identical for all players globally.
|
/// Deterministic seed derived from a date, identical for all players globally.
|
||||||
@@ -46,7 +45,7 @@ pub fn xp_for_win(time_seconds: u64, used_undo: bool) -> u64 {
|
|||||||
|
|
||||||
/// Platform-specific default path for `progress.json`.
|
/// Platform-specific default path for `progress.json`.
|
||||||
pub fn progress_file_path() -> Option<PathBuf> {
|
pub fn progress_file_path() -> Option<PathBuf> {
|
||||||
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(FILE_NAME))
|
crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(FILE_NAME))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load progress from an explicit path. Returns `default()` if missing/corrupt.
|
/// Load progress from an explicit path. Returns `default()` if missing/corrupt.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
//! Win-game replay recording + storage.
|
//! Win-game replay recording + storage.
|
||||||
//!
|
//!
|
||||||
//! When a player wins, the engine freezes the in-memory recording into a
|
//! When a player wins, the engine freezes the in-memory recording into a
|
||||||
//! [`Replay`] and persists it to `<data_dir>/solitaire_quest/latest_replay.json`
|
//! [`Replay`] and persists it to `<data_dir>/ferrous_solitaire/latest_replay.json`
|
||||||
//! via [`save_latest_replay_to`]. The Stats screen offers a "Watch replay"
|
//! via [`save_latest_replay_to`]. The Stats screen offers a "Watch replay"
|
||||||
//! action that loads it via [`load_latest_replay_from`] so the player can
|
//! action that loads it via [`load_latest_replay_from`] so the player can
|
||||||
//! revisit (or, in a future build, watch the engine re-execute) the path
|
//! revisit (or, in a future build, watch the engine re-execute) the path
|
||||||
@@ -29,7 +29,6 @@ use serde::{Deserialize, Serialize};
|
|||||||
use solitaire_core::game_state::{DrawMode, GameMode};
|
use solitaire_core::game_state::{DrawMode, GameMode};
|
||||||
use solitaire_core::pile::PileType;
|
use solitaire_core::pile::PileType;
|
||||||
|
|
||||||
const APP_DIR_NAME: &str = "solitaire_quest";
|
|
||||||
const LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json";
|
const LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json";
|
||||||
const REPLAY_HISTORY_FILE_NAME: &str = "replays.json";
|
const REPLAY_HISTORY_FILE_NAME: &str = "replays.json";
|
||||||
|
|
||||||
@@ -221,7 +220,7 @@ impl Replay {
|
|||||||
/// Rolling history of the player's most recent winning replays.
|
/// Rolling history of the player's most recent winning replays.
|
||||||
///
|
///
|
||||||
/// Stored as a single JSON file at
|
/// Stored as a single JSON file at
|
||||||
/// `<data_dir>/solitaire_quest/replays.json` (see
|
/// `<data_dir>/ferrous_solitaire/replays.json` (see
|
||||||
/// [`replay_history_path`]). Capped at [`REPLAY_HISTORY_CAP`] entries —
|
/// [`replay_history_path`]). Capped at [`REPLAY_HISTORY_CAP`] entries —
|
||||||
/// when [`append_replay_to_history`] pushes past the cap, the oldest
|
/// when [`append_replay_to_history`] pushes past the cap, the oldest
|
||||||
/// entry is dropped so the file never grows unbounded.
|
/// entry is dropped so the file never grows unbounded.
|
||||||
@@ -279,14 +278,14 @@ impl ReplayHistory {
|
|||||||
in migrate_legacy_latest_replay"
|
in migrate_legacy_latest_replay"
|
||||||
)]
|
)]
|
||||||
pub fn latest_replay_path() -> Option<PathBuf> {
|
pub fn latest_replay_path() -> Option<PathBuf> {
|
||||||
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(LATEST_REPLAY_FILE_NAME))
|
crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(LATEST_REPLAY_FILE_NAME))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the platform-specific path to `replays.json`, the rolling
|
/// Returns the platform-specific path to `replays.json`, the rolling
|
||||||
/// history file, or `None` if `crate::data_dir()` is unavailable (e.g.
|
/// history file, or `None` if `crate::data_dir()` is unavailable (e.g.
|
||||||
/// minimal Linux containers).
|
/// minimal Linux containers).
|
||||||
pub fn replay_history_path() -> Option<PathBuf> {
|
pub fn replay_history_path() -> Option<PathBuf> {
|
||||||
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(REPLAY_HISTORY_FILE_NAME))
|
crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(REPLAY_HISTORY_FILE_NAME))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save a [`Replay`] atomically to `path` using the standard `.tmp` →
|
/// Save a [`Replay`] atomically to `path` using the standard `.tmp` →
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ use std::path::{Path, PathBuf};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use solitaire_core::game_state::{DifficultyLevel, DrawMode};
|
use solitaire_core::game_state::{DifficultyLevel, DrawMode};
|
||||||
|
|
||||||
const APP_DIR_NAME: &str = "solitaire_quest";
|
|
||||||
const SETTINGS_FILE_NAME: &str = "settings.json";
|
const SETTINGS_FILE_NAME: &str = "settings.json";
|
||||||
|
|
||||||
/// Animation playback speed for card transitions.
|
/// Animation playback speed for card transitions.
|
||||||
@@ -239,6 +238,12 @@ pub struct Settings {
|
|||||||
/// field existed deserialize cleanly to `None` via `#[serde(default)]`.
|
/// field existed deserialize cleanly to `None` via `#[serde(default)]`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub leaderboard_display_name: Option<String>,
|
pub leaderboard_display_name: Option<String>,
|
||||||
|
/// `true` once the player has successfully opted in to the leaderboard on
|
||||||
|
/// the server. Used to decide whether a display-name change should also
|
||||||
|
/// push an update via `opt_in_leaderboard`. Older `settings.json` files
|
||||||
|
/// deserialize cleanly to `false` via `#[serde(default)]`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub leaderboard_opted_in: bool,
|
||||||
/// When `true`, the player may drag the top card of a foundation pile back
|
/// When `true`, the player may drag the top card of a foundation pile back
|
||||||
/// onto a compatible tableau column. Enabled by default (standard Klondike
|
/// onto a compatible tableau column. Enabled by default (standard Klondike
|
||||||
/// rules). Older `settings.json` files without this key deserialize to
|
/// rules). Older `settings.json` files without this key deserialize to
|
||||||
@@ -275,7 +280,7 @@ fn default_music_volume() -> f32 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn default_theme_id() -> String {
|
fn default_theme_id() -> String {
|
||||||
"dark".to_string()
|
"classic".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Default tooltip-hover dwell delay in seconds. Mirrors
|
/// Default tooltip-hover dwell delay in seconds. Mirrors
|
||||||
@@ -388,6 +393,7 @@ impl Default for Settings {
|
|||||||
replay_move_interval_secs: default_replay_move_interval_secs(),
|
replay_move_interval_secs: default_replay_move_interval_secs(),
|
||||||
last_difficulty: None,
|
last_difficulty: None,
|
||||||
leaderboard_display_name: None,
|
leaderboard_display_name: None,
|
||||||
|
leaderboard_opted_in: false,
|
||||||
take_from_foundation: true,
|
take_from_foundation: true,
|
||||||
analytics_enabled: false,
|
analytics_enabled: false,
|
||||||
matomo_url: None,
|
matomo_url: None,
|
||||||
@@ -402,11 +408,10 @@ impl Settings {
|
|||||||
/// their respective ranges after deserialization or hand-editing of
|
/// their respective ranges after deserialization or hand-editing of
|
||||||
/// `settings.json`.
|
/// `settings.json`.
|
||||||
pub fn sanitized(self) -> Self {
|
pub fn sanitized(self) -> Self {
|
||||||
// Migrate stale theme IDs: "default" was removed when the theme was
|
// Migrate stale theme IDs: "default" was the original name before it
|
||||||
// renamed to "dark"; "classic" was briefly the default before "dark"
|
// was renamed to "dark".
|
||||||
// was restored as the shipped default.
|
|
||||||
let selected_theme_id = match self.selected_theme_id.as_str() {
|
let selected_theme_id = match self.selected_theme_id.as_str() {
|
||||||
"default" | "classic" => "dark".to_string(),
|
"default" => "dark".to_string(),
|
||||||
_ => self.selected_theme_id,
|
_ => self.selected_theme_id,
|
||||||
};
|
};
|
||||||
Self {
|
Self {
|
||||||
@@ -480,7 +485,7 @@ impl Settings {
|
|||||||
/// Returns the platform-specific path to `settings.json`, or `None` if
|
/// Returns the platform-specific path to `settings.json`, or `None` if
|
||||||
/// the platform's data directory is unavailable.
|
/// the platform's data directory is unavailable.
|
||||||
pub fn settings_file_path() -> Option<PathBuf> {
|
pub fn settings_file_path() -> Option<PathBuf> {
|
||||||
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(SETTINGS_FILE_NAME))
|
crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(SETTINGS_FILE_NAME))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load settings from an explicit path. Returns `Settings::default()` if the
|
/// Load settings from an explicit path. Returns `Settings::default()` if the
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ use solitaire_core::game_state::{GameState, GAME_STATE_SCHEMA_VERSION};
|
|||||||
|
|
||||||
use crate::stats::StatsSnapshot;
|
use crate::stats::StatsSnapshot;
|
||||||
|
|
||||||
const APP_DIR_NAME: &str = "solitaire_quest";
|
|
||||||
const STATS_FILE_NAME: &str = "stats.json";
|
const STATS_FILE_NAME: &str = "stats.json";
|
||||||
const GAME_STATE_FILE_NAME: &str = "game_state.json";
|
const GAME_STATE_FILE_NAME: &str = "game_state.json";
|
||||||
const TIME_ATTACK_SESSION_FILE_NAME: &str = "time_attack_session.json";
|
const TIME_ATTACK_SESSION_FILE_NAME: &str = "time_attack_session.json";
|
||||||
@@ -21,7 +20,7 @@ const TIME_ATTACK_SESSION_FILE_NAME: &str = "time_attack_session.json";
|
|||||||
/// Returns the platform-specific path to `stats.json`, or `None` if
|
/// Returns the platform-specific path to `stats.json`, or `None` if
|
||||||
/// `crate::data_dir()` is unavailable (e.g. minimal Linux containers).
|
/// `crate::data_dir()` is unavailable (e.g. minimal Linux containers).
|
||||||
pub fn stats_file_path() -> Option<PathBuf> {
|
pub fn stats_file_path() -> Option<PathBuf> {
|
||||||
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(STATS_FILE_NAME))
|
crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(STATS_FILE_NAME))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load stats from an explicit path. Returns `StatsSnapshot::default()` if
|
/// Load stats from an explicit path. Returns `StatsSnapshot::default()` if
|
||||||
@@ -71,7 +70,7 @@ pub fn save_stats(stats: &StatsSnapshot) -> io::Result<()> {
|
|||||||
/// Returns the platform-specific path to `game_state.json`, or `None` if
|
/// Returns the platform-specific path to `game_state.json`, or `None` if
|
||||||
/// `crate::data_dir()` is unavailable.
|
/// `crate::data_dir()` is unavailable.
|
||||||
pub fn game_state_file_path() -> Option<PathBuf> {
|
pub fn game_state_file_path() -> Option<PathBuf> {
|
||||||
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(GAME_STATE_FILE_NAME))
|
crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(GAME_STATE_FILE_NAME))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load an in-progress `GameState` from `path`. Returns `None` if the file is
|
/// Load an in-progress `GameState` from `path`. Returns `None` if the file is
|
||||||
@@ -123,14 +122,14 @@ pub fn delete_game_state_at(path: &Path) -> io::Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove any leftover `*.json.tmp` files in the app data directory.
|
/// Remove any leftover `*.tmp` files in the app data directory.
|
||||||
///
|
///
|
||||||
/// These can be left behind if the process crashes between the write and rename
|
/// These can be left behind if the process crashes between the write and rename
|
||||||
/// in an atomic save. Safe to call on startup; missing or unreadable entries
|
/// in an atomic save. Safe to call on startup; missing or unreadable entries
|
||||||
/// are silently skipped.
|
/// are silently skipped.
|
||||||
pub fn cleanup_orphaned_tmp_files() -> io::Result<()> {
|
pub fn cleanup_orphaned_tmp_files() -> io::Result<()> {
|
||||||
let dir = match crate::data_dir() {
|
let dir = match crate::data_dir() {
|
||||||
Some(d) => d.join(APP_DIR_NAME),
|
Some(d) => d.join(crate::APP_DIR_NAME),
|
||||||
None => return Ok(()),
|
None => return Ok(()),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -181,7 +180,7 @@ pub struct TimeAttackSession {
|
|||||||
/// Returns the platform-specific path to `time_attack_session.json`, or
|
/// Returns the platform-specific path to `time_attack_session.json`, or
|
||||||
/// `None` if `crate::data_dir()` is unavailable.
|
/// `None` if `crate::data_dir()` is unavailable.
|
||||||
pub fn time_attack_session_path() -> Option<PathBuf> {
|
pub fn time_attack_session_path() -> Option<PathBuf> {
|
||||||
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(TIME_ATTACK_SESSION_FILE_NAME))
|
crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(TIME_ATTACK_SESSION_FILE_NAME))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save a Time Attack session atomically. Mirrors `save_game_state_to`'s
|
/// Save a Time Attack session atomically. Mirrors `save_game_state_to`'s
|
||||||
@@ -267,7 +266,7 @@ pub fn time_attack_session_with_now(remaining_secs: f32, wins: u32) -> TimeAttac
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Inner helper: delete `*.json.tmp` entries inside `dir`.
|
/// Inner helper: delete `*.tmp` entries inside `dir`.
|
||||||
///
|
///
|
||||||
/// Per-file errors (already deleted, permission denied) are silently ignored.
|
/// Per-file errors (already deleted, permission denied) are silently ignored.
|
||||||
fn cleanup_tmp_files_in(dir: &Path) {
|
fn cleanup_tmp_files_in(dir: &Path) {
|
||||||
@@ -277,7 +276,7 @@ fn cleanup_tmp_files_in(dir: &Path) {
|
|||||||
if path
|
if path
|
||||||
.file_name()
|
.file_name()
|
||||||
.and_then(|n| n.to_str())
|
.and_then(|n| n.to_str())
|
||||||
.is_some_and(|n| n.ends_with(".json.tmp"))
|
.is_some_and(|n| n.ends_with(".tmp"))
|
||||||
{
|
{
|
||||||
let _ = fs::remove_file(&path);
|
let _ = fs::remove_file(&path);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -309,6 +309,9 @@ impl SyncProvider for SolitaireServerClient {
|
|||||||
async fn opt_in_leaderboard(&self, display_name: &str) -> Result<(), SyncError> {
|
async fn opt_in_leaderboard(&self, display_name: &str) -> Result<(), SyncError> {
|
||||||
let token = self.access_token()?;
|
let token = self.access_token()?;
|
||||||
let url = format!("{}/api/leaderboard/opt-in", self.base_url);
|
let url = format!("{}/api/leaderboard/opt-in", self.base_url);
|
||||||
|
// Enforce the server's 32-char column limit at the client boundary so
|
||||||
|
// the server never receives an over-length name regardless of caller.
|
||||||
|
let display_name: String = display_name.chars().take(32).collect();
|
||||||
|
|
||||||
let resp = self
|
let resp = self
|
||||||
.client
|
.client
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
|
|||||||
use crate::stats_plugin::{StatsResource, StatsUpdate};
|
use crate::stats_plugin::{StatsResource, StatsUpdate};
|
||||||
use crate::ui_modal::{
|
use crate::ui_modal::{
|
||||||
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
||||||
ScrimDismissible,
|
ModalScrim, ScrimDismissible,
|
||||||
};
|
};
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
ACCENT_PRIMARY, BORDER_SUBTLE, STATE_SUCCESS, TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY,
|
ACCENT_PRIMARY, BORDER_SUBTLE, STATE_SUCCESS, TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY,
|
||||||
@@ -162,10 +162,7 @@ fn evaluate_on_win(
|
|||||||
mut achievements: ResMut<AchievementsResource>,
|
mut achievements: ResMut<AchievementsResource>,
|
||||||
mut progress: ResMut<ProgressResource>,
|
mut progress: ResMut<ProgressResource>,
|
||||||
) {
|
) {
|
||||||
let Some(ev) = wins.read().last() else {
|
for ev in wins.read() {
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let ctx = AchievementContext {
|
let ctx = AchievementContext {
|
||||||
games_played: stats.0.games_played,
|
games_played: stats.0.games_played,
|
||||||
games_won: stats.0.games_won,
|
games_won: stats.0.games_won,
|
||||||
@@ -184,7 +181,7 @@ fn evaluate_on_win(
|
|||||||
|
|
||||||
let hits = check_achievements(&ctx);
|
let hits = check_achievements(&ctx);
|
||||||
if hits.is_empty() {
|
if hits.is_empty() {
|
||||||
return;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
@@ -249,6 +246,7 @@ fn evaluate_on_win(
|
|||||||
&& let Err(e) = save_progress_to(target, &progress.0) {
|
&& let Err(e) = save_progress_to(target, &progress.0) {
|
||||||
warn!("failed to save progress after reward: {e}");
|
warn!("failed to save progress after reward: {e}");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cinephile unlock observer.
|
/// Cinephile unlock observer.
|
||||||
@@ -391,6 +389,7 @@ fn toggle_achievements_screen(
|
|||||||
achievements: Res<AchievementsResource>,
|
achievements: Res<AchievementsResource>,
|
||||||
font_res: Option<Res<FontResource>>,
|
font_res: Option<Res<FontResource>>,
|
||||||
screens: Query<Entity, With<AchievementsScreen>>,
|
screens: Query<Entity, With<AchievementsScreen>>,
|
||||||
|
other_modal_scrims: Query<(), (With<ModalScrim>, Without<AchievementsScreen>)>,
|
||||||
) {
|
) {
|
||||||
let button_clicked = requests.read().count() > 0;
|
let button_clicked = requests.read().count() > 0;
|
||||||
if !keys.just_pressed(KeyCode::KeyA) && !button_clicked {
|
if !keys.just_pressed(KeyCode::KeyA) && !button_clicked {
|
||||||
@@ -398,7 +397,7 @@ fn toggle_achievements_screen(
|
|||||||
}
|
}
|
||||||
if let Ok(entity) = screens.single() {
|
if let Ok(entity) = screens.single() {
|
||||||
commands.entity(entity).despawn();
|
commands.entity(entity).despawn();
|
||||||
} else {
|
} else if other_modal_scrims.is_empty() {
|
||||||
spawn_achievements_screen(&mut commands, &achievements.0, font_res.as_deref());
|
spawn_achievements_screen(&mut commands, &achievements.0, font_res.as_deref());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use solitaire_core::game_state::GameMode;
|
|||||||
use solitaire_data::{matomo_client::MatomoClient, settings::SyncBackend, Settings};
|
use solitaire_data::{matomo_client::MatomoClient, settings::SyncBackend, Settings};
|
||||||
|
|
||||||
use crate::events::{AchievementUnlockedEvent, ForfeitEvent, GameWonEvent, NewGameRequestEvent};
|
use crate::events::{AchievementUnlockedEvent, ForfeitEvent, GameWonEvent, NewGameRequestEvent};
|
||||||
use crate::resources::GameStateResource;
|
use crate::resources::{GameStateResource, TokioRuntimeResource};
|
||||||
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -45,6 +45,7 @@ pub struct AnalyticsPlugin;
|
|||||||
impl Plugin for AnalyticsPlugin {
|
impl Plugin for AnalyticsPlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.init_resource::<AnalyticsResource>()
|
app.init_resource::<AnalyticsResource>()
|
||||||
|
.init_resource::<TokioRuntimeResource>()
|
||||||
.add_systems(Startup, init_analytics)
|
.add_systems(Startup, init_analytics)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
@@ -80,28 +81,28 @@ fn react_to_settings_change(
|
|||||||
fn on_game_won(
|
fn on_game_won(
|
||||||
mut wins: MessageReader<GameWonEvent>,
|
mut wins: MessageReader<GameWonEvent>,
|
||||||
analytics: Res<AnalyticsResource>,
|
analytics: Res<AnalyticsResource>,
|
||||||
settings: Res<SettingsResource>,
|
rt: Res<TokioRuntimeResource>,
|
||||||
) {
|
) {
|
||||||
let Some(client) = analytics.client.clone() else {
|
let Some(client) = analytics.client.clone() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
for ev in wins.read() {
|
for ev in wins.read() {
|
||||||
client.event("Game", "Won", None, Some(ev.score as f64));
|
client.event("Game", "Won", None, Some(ev.score as f64));
|
||||||
fire_flush(client.clone(), &settings.0);
|
fire_flush(client.clone(), rt.0.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn on_forfeit(
|
fn on_forfeit(
|
||||||
mut forfeits: MessageReader<ForfeitEvent>,
|
mut forfeits: MessageReader<ForfeitEvent>,
|
||||||
analytics: Res<AnalyticsResource>,
|
analytics: Res<AnalyticsResource>,
|
||||||
settings: Res<SettingsResource>,
|
rt: Res<TokioRuntimeResource>,
|
||||||
) {
|
) {
|
||||||
let Some(client) = analytics.client.clone() else {
|
let Some(client) = analytics.client.clone() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
for _ev in forfeits.read() {
|
for _ev in forfeits.read() {
|
||||||
client.event("Game", "Forfeit", None, None);
|
client.event("Game", "Forfeit", None, None);
|
||||||
fire_flush(client.clone(), &settings.0);
|
fire_flush(client.clone(), rt.0.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,14 +138,14 @@ fn on_achievement_unlocked(
|
|||||||
fn tick_flush_timer(
|
fn tick_flush_timer(
|
||||||
time: Res<Time>,
|
time: Res<Time>,
|
||||||
mut analytics: ResMut<AnalyticsResource>,
|
mut analytics: ResMut<AnalyticsResource>,
|
||||||
settings: Res<SettingsResource>,
|
rt: Res<TokioRuntimeResource>,
|
||||||
) {
|
) {
|
||||||
analytics.flush_timer.tick(time.delta());
|
analytics.flush_timer.tick(time.delta());
|
||||||
if !analytics.flush_timer.just_finished() {
|
if !analytics.flush_timer.just_finished() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let Some(client) = analytics.client.clone() {
|
if let Some(client) = analytics.client.clone() {
|
||||||
fire_flush(client, &settings.0);
|
fire_flush(client, rt.0.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,15 +165,10 @@ fn client_for(settings: &Settings) -> Option<Arc<MatomoClient>> {
|
|||||||
Some(Arc::new(MatomoClient::new(url, settings.matomo_site_id, uid)))
|
Some(Arc::new(MatomoClient::new(url, settings.matomo_site_id, uid)))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fire_flush(client: Arc<MatomoClient>, _settings: &Settings) {
|
fn fire_flush(client: Arc<MatomoClient>, rt: Arc<tokio::runtime::Runtime>) {
|
||||||
AsyncComputeTaskPool::get()
|
AsyncComputeTaskPool::get()
|
||||||
.spawn(async move {
|
.spawn(async move {
|
||||||
if let Ok(rt) = tokio::runtime::Builder::new_current_thread()
|
|
||||||
.enable_all()
|
|
||||||
.build()
|
|
||||||
{
|
|
||||||
rt.block_on(client.flush());
|
rt.block_on(client.flush());
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.detach();
|
.detach();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,17 @@ const TIME_ATTACK_TOAST_SECS: f32 = 5.0;
|
|||||||
const CHALLENGE_TOAST_SECS: f32 = 3.0;
|
const CHALLENGE_TOAST_SECS: f32 = 3.0;
|
||||||
const VOLUME_TOAST_SECS: f32 = 1.4;
|
const VOLUME_TOAST_SECS: f32 = 1.4;
|
||||||
|
|
||||||
|
/// Z added to a card's render depth while its `CardAnim` is in-flight.
|
||||||
|
///
|
||||||
|
/// Foundation and tableau cards share x,y during the slide (destination equals
|
||||||
|
/// a slot that already holds a card). Without this lift the incoming card's
|
||||||
|
/// bottom-right corner overlaps the stationary card's top-left, which the
|
||||||
|
/// player perceives as a single card with mismatched rank/suit indices.
|
||||||
|
///
|
||||||
|
/// 50.0 sits comfortably above the highest pile depth (~1.04) and well below
|
||||||
|
/// `DRAG_Z` (500), so a dragged card always renders above an animated one.
|
||||||
|
const CARD_ANIM_Z_LIFT: f32 = 50.0;
|
||||||
|
|
||||||
/// Per-card stagger interval for the win cascade at Normal speed (seconds).
|
/// Per-card stagger interval for the win cascade at Normal speed (seconds).
|
||||||
///
|
///
|
||||||
/// Sourced from `ui_theme::MOTION_CASCADE_STAGGER_SECS` so all motion timing
|
/// Sourced from `ui_theme::MOTION_CASCADE_STAGGER_SECS` so all motion timing
|
||||||
@@ -247,6 +258,11 @@ fn advance_card_anims(
|
|||||||
anim.delay = (anim.delay - dt).max(0.0);
|
anim.delay = (anim.delay - dt).max(0.0);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if anim.duration <= 0.0 {
|
||||||
|
transform.translation = anim.target;
|
||||||
|
commands.entity(entity).remove::<CardAnim>();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
anim.elapsed += dt;
|
anim.elapsed += dt;
|
||||||
let t = (anim.elapsed / anim.duration).min(1.0);
|
let t = (anim.elapsed / anim.duration).min(1.0);
|
||||||
// Curved interpolation using `MotionCurve::SmoothSnap` (cubic ease-out
|
// Curved interpolation using `MotionCurve::SmoothSnap` (cubic ease-out
|
||||||
@@ -254,7 +270,11 @@ fn advance_card_anims(
|
|||||||
// shared `CardAnim` struct stays a simple linear-tween container — the
|
// shared `CardAnim` struct stays a simple linear-tween container — the
|
||||||
// upgrade is one extra `sample_curve` call per advancing animation.
|
// upgrade is one extra `sample_curve` call per advancing animation.
|
||||||
let s = sample_curve(MotionCurve::SmoothSnap, t);
|
let s = sample_curve(MotionCurve::SmoothSnap, t);
|
||||||
transform.translation = anim.start.lerp(anim.target, s);
|
let mut pos = anim.start.lerp(anim.target, s);
|
||||||
|
// Elevate z during transit so the moving card always renders in front
|
||||||
|
// of any card already resting at the destination position.
|
||||||
|
pos.z = anim.target.z + CARD_ANIM_Z_LIFT;
|
||||||
|
transform.translation = pos;
|
||||||
if t >= 1.0 {
|
if t >= 1.0 {
|
||||||
transform.translation = anim.target;
|
transform.translation = anim.target;
|
||||||
commands.entity(entity).remove::<CardAnim>();
|
commands.entity(entity).remove::<CardAnim>();
|
||||||
@@ -454,8 +474,8 @@ fn handle_settings_toast(
|
|||||||
for ev in events.read() {
|
for ev in events.read() {
|
||||||
let sfx = ev.0.sfx_volume;
|
let sfx = ev.0.sfx_volume;
|
||||||
let music = ev.0.music_volume;
|
let music = ev.0.music_volume;
|
||||||
let sfx_changed = last_sfx.is_none_or(|prev| (prev - sfx).abs() > f32::EPSILON);
|
let sfx_changed = last_sfx.is_none_or(|prev| (prev - sfx).abs() > 0.001);
|
||||||
let music_changed = last_music.is_none_or(|prev| (prev - music).abs() > f32::EPSILON);
|
let music_changed = last_music.is_none_or(|prev| (prev - music).abs() > 0.001);
|
||||||
*last_sfx = Some(sfx);
|
*last_sfx = Some(sfx);
|
||||||
*last_music = Some(music);
|
*last_music = Some(music);
|
||||||
if sfx_changed {
|
if sfx_changed {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ static USER_THEME_DIR_OVERRIDE: OnceLock<PathBuf> = OnceLock::new();
|
|||||||
/// Sub-folder under `dirs::data_dir()` where the project keeps every
|
/// Sub-folder under `dirs::data_dir()` where the project keeps every
|
||||||
/// per-user file. Matches the existing convention used by
|
/// per-user file. Matches the existing convention used by
|
||||||
/// `solitaire_data` for `settings.json`, `stats.json`, etc.
|
/// `solitaire_data` for `settings.json`, `stats.json`, etc.
|
||||||
const APP_DIR_NAME: &str = "solitaire_quest";
|
const APP_DIR_NAME: &str = "ferrous_solitaire";
|
||||||
|
|
||||||
/// Sub-folder under [`APP_DIR_NAME`] dedicated to user themes.
|
/// Sub-folder under [`APP_DIR_NAME`] dedicated to user themes.
|
||||||
const THEME_DIR_NAME: &str = "themes";
|
const THEME_DIR_NAME: &str = "themes";
|
||||||
@@ -97,19 +97,19 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn user_theme_dir_for_appends_solitaire_quest_themes() {
|
fn user_theme_dir_for_appends_ferrous_solitaire_themes() {
|
||||||
let dir = user_theme_dir_for(PathBuf::from("/tmp/data"));
|
let dir = user_theme_dir_for(PathBuf::from("/tmp/data"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
dir,
|
dir,
|
||||||
PathBuf::from("/tmp/data/solitaire_quest/themes"),
|
PathBuf::from("/tmp/data/ferrous_solitaire/themes"),
|
||||||
"user dir must nest under solitaire_quest/themes"
|
"user dir must nest under ferrous_solitaire/themes"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn user_theme_dir_for_handles_empty_root() {
|
fn user_theme_dir_for_handles_empty_root() {
|
||||||
let dir = user_theme_dir_for(PathBuf::new());
|
let dir = user_theme_dir_for(PathBuf::new());
|
||||||
assert_eq!(dir, PathBuf::from("solitaire_quest/themes"));
|
assert_eq!(dir, PathBuf::from("ferrous_solitaire/themes"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use bevy::prelude::*;
|
|||||||
use crate::audio_plugin::{AudioState, SoundLibrary};
|
use crate::audio_plugin::{AudioState, SoundLibrary};
|
||||||
use crate::events::{MoveRequestEvent, StateChangedEvent};
|
use crate::events::{MoveRequestEvent, StateChangedEvent};
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
|
use crate::pause_plugin::PausedResource;
|
||||||
use crate::resources::GameStateResource;
|
use crate::resources::GameStateResource;
|
||||||
|
|
||||||
/// Volume amplitude used for the auto-complete activation chime.
|
/// Volume amplitude used for the auto-complete activation chime.
|
||||||
@@ -72,9 +73,14 @@ fn detect_auto_complete(
|
|||||||
if game.0.is_auto_completable && !state.active {
|
if game.0.is_auto_completable && !state.active {
|
||||||
state.active = true;
|
state.active = true;
|
||||||
state.cooldown = 0.0; // fire first move immediately
|
state.cooldown = 0.0; // fire first move immediately
|
||||||
} else if !game.0.is_auto_completable {
|
|
||||||
state.active = false;
|
|
||||||
}
|
}
|
||||||
|
// Intentionally no `else if !is_auto_completable` branch here.
|
||||||
|
// Deactivating on every frame where `is_auto_completable` is false
|
||||||
|
// would hard-stop the sequence mid-flight whenever `next_auto_complete_move`
|
||||||
|
// transiently returns `None` (e.g. while the previous move is still
|
||||||
|
// in-flight). The `is_won` check above already handles the definitive
|
||||||
|
// end-of-game case; `drive_auto_complete` simply retries next tick
|
||||||
|
// when no move is available yet.
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Plays a distinct chime the moment auto-complete first activates.
|
/// Plays a distinct chime the moment auto-complete first activates.
|
||||||
@@ -106,11 +112,15 @@ fn drive_auto_complete(
|
|||||||
mut state: ResMut<AutoCompleteState>,
|
mut state: ResMut<AutoCompleteState>,
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
time: Res<Time>,
|
time: Res<Time>,
|
||||||
|
paused: Option<Res<PausedResource>>,
|
||||||
mut moves: MessageWriter<MoveRequestEvent>,
|
mut moves: MessageWriter<MoveRequestEvent>,
|
||||||
) {
|
) {
|
||||||
if !state.active {
|
if !state.active {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if paused.is_some_and(|p| p.0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
state.cooldown -= time.delta_secs();
|
state.cooldown -= time.delta_secs();
|
||||||
if state.cooldown > 0.0 {
|
if state.cooldown > 0.0 {
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ use bevy::asset::RenderAssetUsages;
|
|||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
|
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
|
||||||
|
|
||||||
|
use crate::resources::TokioRuntimeResource;
|
||||||
|
|
||||||
/// Stores the loaded avatar [`Handle<Image>`], or `None` when no avatar
|
/// Stores the loaded avatar [`Handle<Image>`], or `None` when no avatar
|
||||||
/// has been fetched yet (new account, no internet, or fetch in progress).
|
/// has been fetched yet (new account, no internet, or fetch in progress).
|
||||||
#[derive(Resource, Default)]
|
#[derive(Resource, Default)]
|
||||||
@@ -46,6 +48,7 @@ pub struct AvatarPlugin;
|
|||||||
impl Plugin for AvatarPlugin {
|
impl Plugin for AvatarPlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.add_message::<AvatarFetchEvent>()
|
app.add_message::<AvatarFetchEvent>()
|
||||||
|
.init_resource::<TokioRuntimeResource>()
|
||||||
.init_resource::<AvatarResource>()
|
.init_resource::<AvatarResource>()
|
||||||
.init_resource::<PendingAvatarTask>()
|
.init_resource::<PendingAvatarTask>()
|
||||||
.add_systems(Update, (handle_avatar_fetch, poll_avatar_task));
|
.add_systems(Update, (handle_avatar_fetch, poll_avatar_task));
|
||||||
@@ -54,17 +57,15 @@ impl Plugin for AvatarPlugin {
|
|||||||
|
|
||||||
fn handle_avatar_fetch(
|
fn handle_avatar_fetch(
|
||||||
mut events: MessageReader<AvatarFetchEvent>,
|
mut events: MessageReader<AvatarFetchEvent>,
|
||||||
|
rt: Res<TokioRuntimeResource>,
|
||||||
mut pending: ResMut<PendingAvatarTask>,
|
mut pending: ResMut<PendingAvatarTask>,
|
||||||
) {
|
) {
|
||||||
for ev in events.read() {
|
for ev in events.read() {
|
||||||
// Cancel any in-flight task and restart with the new URL.
|
// Cancel any in-flight task and restart with the new URL.
|
||||||
let url = ev.url.clone();
|
let url = ev.url.clone();
|
||||||
|
let rt = rt.0.clone();
|
||||||
pending.0 = Some(AsyncComputeTaskPool::get().spawn(async move {
|
pending.0 = Some(AsyncComputeTaskPool::get().spawn(async move {
|
||||||
tokio::runtime::Builder::new_current_thread()
|
rt.block_on(async move {
|
||||||
.enable_all()
|
|
||||||
.build()
|
|
||||||
.ok()?
|
|
||||||
.block_on(async move {
|
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
let bytes = client
|
let bytes = client
|
||||||
.get(&url)
|
.get(&url)
|
||||||
|
|||||||
@@ -142,6 +142,13 @@ impl Plugin for CardAnimationPlugin {
|
|||||||
update_frame_time_diagnostics,
|
update_frame_time_diagnostics,
|
||||||
// Advance active animations.
|
// Advance active animations.
|
||||||
advance_card_animations,
|
advance_card_animations,
|
||||||
|
// Flush deferred commands so `CardAnimation` removals from
|
||||||
|
// `advance_card_animations` are visible before the chain
|
||||||
|
// system runs. Without this, the chain sees the component
|
||||||
|
// still present in the same frame it was removed (deferred
|
||||||
|
// commands aren't applied until the next ApplyDeferred
|
||||||
|
// point), causing a 1-frame gap between every chain step.
|
||||||
|
ApplyDeferred,
|
||||||
// After each animation finishes, pop the next chain segment.
|
// After each animation finishes, pop the next chain segment.
|
||||||
advance_animation_chains,
|
advance_animation_chains,
|
||||||
// Interaction visuals (run after animation for final positions).
|
// Interaction visuals (run after animation for final positions).
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ use std::collections::{HashMap, HashSet};
|
|||||||
use bevy::color::Color;
|
use bevy::color::Color;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::window::WindowResized;
|
use bevy::window::WindowResized;
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
use bevy::sprite::Anchor;
|
||||||
use solitaire_core::card::{Card, Rank, Suit};
|
use solitaire_core::card::{Card, Rank, Suit};
|
||||||
use solitaire_core::game_state::{DrawMode, GameState};
|
use solitaire_core::game_state::{DrawMode, GameState};
|
||||||
use solitaire_core::pile::PileType;
|
use solitaire_core::pile::PileType;
|
||||||
@@ -39,7 +41,9 @@ use crate::ui_theme::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
/// Fraction of card height used as vertical offset between face-up tableau cards.
|
/// Fraction of card height used as vertical offset between face-up tableau cards.
|
||||||
pub const TABLEAU_FAN_FRAC: f32 = 0.25;
|
/// Must match `layout::TABLEAU_FAN_FRAC` so the initial layout and the first
|
||||||
|
/// dynamic update from `update_tableau_fan_frac` produce identical spacing.
|
||||||
|
pub const TABLEAU_FAN_FRAC: f32 = 0.18;
|
||||||
|
|
||||||
/// Per-card vertical step for face-down tableau cards, as a fraction of
|
/// Per-card vertical step for face-down tableau cards, as a fraction of
|
||||||
/// card height. Smaller than [`TABLEAU_FAN_FRAC`] because face-down cards
|
/// card height. Smaller than [`TABLEAU_FAN_FRAC`] because face-down cards
|
||||||
@@ -49,22 +53,32 @@ pub const TABLEAU_FAN_FRAC: f32 = 0.25;
|
|||||||
/// renderer creates a visible offset between the card face and where
|
/// renderer creates a visible offset between the card face and where
|
||||||
/// clicks land.
|
/// clicks land.
|
||||||
///
|
///
|
||||||
/// Matches `layout::TABLEAU_FACEDOWN_FAN_FRAC` (0.20). Both constants must
|
/// Matches `layout::TABLEAU_FACEDOWN_FAN_FRAC` (0.14). Both constants must
|
||||||
/// stay in sync; the layout constant drives the adaptive LayoutResource value
|
/// stay in sync; the layout constant drives the adaptive LayoutResource value
|
||||||
/// used at runtime, while this one is the minimum floor used by
|
/// used at runtime, while this one is the minimum floor used by
|
||||||
/// `update_tableau_fan_frac` when computing proportional updates.
|
/// `update_tableau_fan_frac` when computing proportional updates.
|
||||||
pub const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.20;
|
pub const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.14;
|
||||||
|
|
||||||
/// Fraction of card height used as a tiny offset between stacked cards in
|
/// Fraction of card height used as a tiny offset between stacked cards in
|
||||||
/// non-tableau piles, so stacking is visible. Public so other plugins
|
/// non-tableau piles, so stacking is visible. Public so other plugins
|
||||||
/// (e.g. input_plugin's drag-rejection tween) can compute the resting
|
/// (e.g. input_plugin's drag-rejection tween) can compute the resting
|
||||||
/// `Transform.translation.z` for a card at a given stack index without
|
/// `Transform.translation.z` for a card at a given stack index without
|
||||||
/// drifting from the value used by [`card_positions`].
|
/// drifting from the value used by [`card_positions`].
|
||||||
pub const STACK_FAN_FRAC: f32 = 0.003;
|
// Must exceed the highest child local-z of any card entity (0.02 for the
|
||||||
|
// Android corner label) so every card's sprite covers all children of the
|
||||||
|
// card below it. Raising from 0.003 → 0.025 fixes corner labels on
|
||||||
|
// foundation piles bleeding through when a 2 sits on an Ace.
|
||||||
|
pub const STACK_FAN_FRAC: f32 = 0.025;
|
||||||
|
|
||||||
/// Font size as a fraction of card width.
|
/// Font size as a fraction of card width.
|
||||||
const FONT_SIZE_FRAC: f32 = 0.28;
|
const FONT_SIZE_FRAC: f32 = 0.28;
|
||||||
|
|
||||||
|
/// Font-size fraction for the large-print readability overlay on Android.
|
||||||
|
/// Spawned on top of PNG face cards to make the rank+suit legible at phone
|
||||||
|
/// scale, where the baked-in PNG corner text is only ~10 px physical.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
const FONT_SIZE_FRAC_MOBILE: f32 = 0.35;
|
||||||
|
|
||||||
/// Card-face background — Terminal `#1a1a1a` (BG_ELEVATED).
|
/// Card-face background — Terminal `#1a1a1a` (BG_ELEVATED).
|
||||||
pub const CARD_FACE_COLOUR: Color = Color::srgb(0.102, 0.102, 0.102);
|
pub const CARD_FACE_COLOUR: Color = Color::srgb(0.102, 0.102, 0.102);
|
||||||
/// Suit colour for hearts + diamonds — saturated red `#e35353`.
|
/// Suit colour for hearts + diamonds — saturated red `#e35353`.
|
||||||
@@ -163,6 +177,25 @@ pub struct CardEntity {
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct CardLabel;
|
pub struct CardLabel;
|
||||||
|
|
||||||
|
/// Marker for the large-print rank+suit corner overlay on Android.
|
||||||
|
///
|
||||||
|
/// Spawned on top of PNG face cards (face-up only) at font size
|
||||||
|
/// [`FONT_SIZE_FRAC_MOBILE`] so the rank and suit character are
|
||||||
|
/// readable at phone scale. Only exists when `CardImageSet` is present
|
||||||
|
/// (the fallback solid-colour path uses a plain `CardLabel` instead).
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
#[derive(Component, Debug, Clone)]
|
||||||
|
struct AndroidCornerLabel(pub String);
|
||||||
|
|
||||||
|
/// Solid-colour background sprite behind [`AndroidCornerLabel`].
|
||||||
|
///
|
||||||
|
/// Covers the card art's own small corner rank/suit text so only the
|
||||||
|
/// large overlay is visible. Sized at [`FONT_SIZE_FRAC_MOBILE`]-derived
|
||||||
|
/// dimensions and coloured [`CARD_FACE_COLOUR`] to match the card face.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
#[derive(Component, Debug, Clone, Copy)]
|
||||||
|
struct AndroidCornerBg;
|
||||||
|
|
||||||
/// Marker component indicating the card is currently highlighted as a hint.
|
/// Marker component indicating the card is currently highlighted as a hint.
|
||||||
/// `remaining` counts down in real seconds; the highlight is removed when it
|
/// `remaining` counts down in real seconds; the highlight is removed when it
|
||||||
/// reaches zero and the card sprite colour is restored to its normal value.
|
/// reaches zero and the card sprite colour is restored to its normal value.
|
||||||
@@ -339,8 +372,8 @@ fn add_card_shadow_child(parent: &mut ChildSpawnerCommands, card_size: Vec2) {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawns a `CardBackFrame` child behind a face-down card entity so the dark
|
/// Spawns a `CardBackFrame` child behind a card entity to give every card a
|
||||||
/// back PNG has a visible perimeter against the dark felt.
|
/// thin perimeter against the dark felt, regardless of face state.
|
||||||
fn add_card_back_frame_child(parent: &mut ChildSpawnerCommands, card_size: Vec2) {
|
fn add_card_back_frame_child(parent: &mut ChildSpawnerCommands, card_size: Vec2) {
|
||||||
parent.spawn((
|
parent.spawn((
|
||||||
CardBackFrame,
|
CardBackFrame,
|
||||||
@@ -424,14 +457,62 @@ impl Plugin for CardPlugin {
|
|||||||
clear_right_click_highlights_on_state_change.after(GameMutation),
|
clear_right_click_highlights_on_state_change.after(GameMutation),
|
||||||
clear_right_click_highlights_on_pause,
|
clear_right_click_highlights_on_pause,
|
||||||
update_stock_empty_indicator.after(GameMutation),
|
update_stock_empty_indicator.after(GameMutation),
|
||||||
update_stock_count_badge.after(GameMutation),
|
update_stock_count_badge
|
||||||
|
.after(GameMutation)
|
||||||
|
.run_if(resource_changed::<crate::GameStateResource>),
|
||||||
collect_resize_events.after(LayoutSystem::UpdateOnResize),
|
collect_resize_events.after(LayoutSystem::UpdateOnResize),
|
||||||
snap_cards_on_window_resize.after(collect_resize_events),
|
snap_cards_on_window_resize.after(collect_resize_events),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
app.add_systems(Update, resize_android_corner_labels);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the relative asset path for a card face PNG.
|
||||||
|
///
|
||||||
|
/// The path format is `cards/faces/classic/{RANK}{SUIT}.png`, e.g. `QS.png`
|
||||||
|
/// for the Queen of Spades. Both `load_card_images` and the unit tests use
|
||||||
|
/// this function so the filename formula is tested in isolation from the
|
||||||
|
/// asset-loading machinery.
|
||||||
|
///
|
||||||
|
/// Note: this function verifies only the **code-side mapping**. If the PNG
|
||||||
|
/// file at the returned path contains wrong artwork (e.g. `QS.png` has a
|
||||||
|
/// diamond watermark baked in), that is an **asset content bug** and must be
|
||||||
|
/// fixed by replacing the file — no code change can correct it.
|
||||||
|
fn card_face_asset_path(rank: Rank, suit: Suit) -> String {
|
||||||
|
const SUIT_CHARS: [&str; 4] = ["C", "D", "H", "S"];
|
||||||
|
const RANK_STRS: [&str; 13] = [
|
||||||
|
"A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K",
|
||||||
|
];
|
||||||
|
let suit_idx = match suit {
|
||||||
|
Suit::Clubs => 0,
|
||||||
|
Suit::Diamonds => 1,
|
||||||
|
Suit::Hearts => 2,
|
||||||
|
Suit::Spades => 3,
|
||||||
|
};
|
||||||
|
let rank_idx = match rank {
|
||||||
|
Rank::Ace => 0,
|
||||||
|
Rank::Two => 1,
|
||||||
|
Rank::Three => 2,
|
||||||
|
Rank::Four => 3,
|
||||||
|
Rank::Five => 4,
|
||||||
|
Rank::Six => 5,
|
||||||
|
Rank::Seven => 6,
|
||||||
|
Rank::Eight => 7,
|
||||||
|
Rank::Nine => 8,
|
||||||
|
Rank::Ten => 9,
|
||||||
|
Rank::Jack => 10,
|
||||||
|
Rank::Queen => 11,
|
||||||
|
Rank::King => 12,
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
"cards/faces/classic/{}{}.png",
|
||||||
|
RANK_STRS[rank_idx], SUIT_CHARS[suit_idx]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Loads card face and back PNGs at startup via [`AssetServer`] and inserts
|
/// Loads card face and back PNGs at startup via [`AssetServer`] and inserts
|
||||||
/// [`CardImageSet`].
|
/// [`CardImageSet`].
|
||||||
///
|
///
|
||||||
@@ -446,21 +527,19 @@ fn load_card_images(asset_server: Option<Res<AssetServer>>, mut commands: Comman
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Suit index: Clubs=0, Diamonds=1, Hearts=2, Spades=3
|
const SUITS: [Suit; 4] = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
|
||||||
const SUIT_CHARS: [&str; 4] = ["C", "D", "H", "S"];
|
const RANKS: [Rank; 13] = [
|
||||||
// Rank index: Ace=0 … King=12
|
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five, Rank::Six, Rank::Seven,
|
||||||
const RANK_STRS: [&str; 13] = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"];
|
Rank::Eight, Rank::Nine, Rank::Ten, Rank::Jack, Rank::Queen, Rank::King,
|
||||||
|
];
|
||||||
|
|
||||||
let faces: [[Handle<Image>; 13]; 4] = std::array::from_fn(|suit| {
|
let faces: [[Handle<Image>; 13]; 4] = std::array::from_fn(|si| {
|
||||||
std::array::from_fn(|rank| {
|
std::array::from_fn(|ri| {
|
||||||
asset_server.load(format!(
|
asset_server.load(card_face_asset_path(RANKS[ri], SUITS[si]))
|
||||||
"cards/faces/{}{}.png",
|
|
||||||
RANK_STRS[rank], SUIT_CHARS[suit]
|
|
||||||
))
|
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
let backs = std::array::from_fn(|i| {
|
let backs = std::array::from_fn(|i| {
|
||||||
asset_server.load(format!("cards/backs/back_{i}.png"))
|
asset_server.load(format!("cards/backs/classic/back_{i}.png"))
|
||||||
});
|
});
|
||||||
commands.insert_resource(CardImageSet {
|
commands.insert_resource(CardImageSet {
|
||||||
faces,
|
faces,
|
||||||
@@ -554,6 +633,7 @@ fn resync_cards_on_settings_change(
|
|||||||
/// Render the initial deal. Runs in `PostStartup`, so all `Startup` systems
|
/// Render the initial deal. Runs in `PostStartup`, so all `Startup` systems
|
||||||
/// (including `TablePlugin::setup_table` which inserts `LayoutResource`)
|
/// (including `TablePlugin::setup_table` which inserts `LayoutResource`)
|
||||||
/// have already completed.
|
/// have already completed.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn sync_cards_startup(
|
fn sync_cards_startup(
|
||||||
commands: Commands,
|
commands: Commands,
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
@@ -562,6 +642,7 @@ fn sync_cards_startup(
|
|||||||
settings: Option<Res<SettingsResource>>,
|
settings: Option<Res<SettingsResource>>,
|
||||||
entities: Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>,
|
entities: Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>,
|
||||||
card_images: Option<Res<CardImageSet>>,
|
card_images: Option<Res<CardImageSet>>,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
) {
|
) {
|
||||||
if let Some(layout) = layout {
|
if let Some(layout) = layout {
|
||||||
let slide_secs = slide_dur.map_or(0.15, |d| d.slide_secs);
|
let slide_secs = slide_dur.map_or(0.15, |d| d.slide_secs);
|
||||||
@@ -569,7 +650,8 @@ fn sync_cards_startup(
|
|||||||
let back_colour = card_back_colour(selected_back);
|
let back_colour = card_back_colour(selected_back);
|
||||||
let color_blind = settings.as_ref().is_some_and(|s| s.0.color_blind_mode);
|
let color_blind = settings.as_ref().is_some_and(|s| s.0.color_blind_mode);
|
||||||
let high_contrast = settings.as_ref().is_some_and(|s| s.0.high_contrast_mode);
|
let high_contrast = settings.as_ref().is_some_and(|s| s.0.high_contrast_mode);
|
||||||
sync_cards(commands, &game.0, &layout.0, slide_secs, back_colour, color_blind, high_contrast, &entities, card_images.as_deref(), selected_back);
|
let font_handle = font_res.as_ref().map(|r| &r.0);
|
||||||
|
sync_cards(commands, &game.0, &layout.0, slide_secs, back_colour, color_blind, high_contrast, &entities, card_images.as_deref(), selected_back, font_handle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -583,6 +665,7 @@ fn sync_cards_on_change(
|
|||||||
settings: Option<Res<SettingsResource>>,
|
settings: Option<Res<SettingsResource>>,
|
||||||
entities: Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>,
|
entities: Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>,
|
||||||
card_images: Option<Res<CardImageSet>>,
|
card_images: Option<Res<CardImageSet>>,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
) {
|
) {
|
||||||
if events.read().next().is_none() {
|
if events.read().next().is_none() {
|
||||||
return;
|
return;
|
||||||
@@ -593,7 +676,8 @@ fn sync_cards_on_change(
|
|||||||
let back_colour = card_back_colour(selected_back);
|
let back_colour = card_back_colour(selected_back);
|
||||||
let color_blind = settings.as_ref().is_some_and(|s| s.0.color_blind_mode);
|
let color_blind = settings.as_ref().is_some_and(|s| s.0.color_blind_mode);
|
||||||
let high_contrast = settings.as_ref().is_some_and(|s| s.0.high_contrast_mode);
|
let high_contrast = settings.as_ref().is_some_and(|s| s.0.high_contrast_mode);
|
||||||
sync_cards(commands, &game.0, &layout.0, slide_secs, back_colour, color_blind, high_contrast, &entities, card_images.as_deref(), selected_back);
|
let font_handle = font_res.as_ref().map(|r| &r.0);
|
||||||
|
sync_cards(commands, &game.0, &layout.0, slide_secs, back_colour, color_blind, high_contrast, &entities, card_images.as_deref(), selected_back, font_handle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -609,18 +693,40 @@ fn sync_cards(
|
|||||||
entities: &Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>,
|
entities: &Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>,
|
||||||
card_images: Option<&CardImageSet>,
|
card_images: Option<&CardImageSet>,
|
||||||
selected_back: usize,
|
selected_back: usize,
|
||||||
|
font_handle: Option<&Handle<Font>>,
|
||||||
) {
|
) {
|
||||||
let positions = card_positions(game, layout);
|
let positions = card_positions(game, layout);
|
||||||
|
|
||||||
// Map card_id -> (Entity, current_translation, has_card_animation) for
|
// The waste buffer card exists only to keep its entity alive while the new
|
||||||
// in-place updates. The `has_card_animation` flag lets `update_card_entity`
|
// top card's slide animation plays — it must never be visible to the player.
|
||||||
// skip the snap/slide path on cards that are already being driven by a
|
// Without this, the buffer sits at waste_base uncovered during the animation
|
||||||
// curve-based `CardAnimation` tween (e.g. the drag-rejection return tween
|
// and its rank/suit peek behind the incoming card.
|
||||||
// — see `input_plugin::end_drag`). Otherwise the StateChangedEvent that
|
let waste_buffer_id: Option<u32> = {
|
||||||
// accompanies a rejection would race the tween and the card would jump.
|
let visible = match game.draw_mode {
|
||||||
let mut existing: HashMap<u32, (Entity, Vec3, bool)> = HashMap::new();
|
DrawMode::DrawOne => 1_usize,
|
||||||
|
DrawMode::DrawThree => 3_usize,
|
||||||
|
};
|
||||||
|
game.piles
|
||||||
|
.get(&PileType::Waste)
|
||||||
|
.filter(|w| w.cards.len() > visible)
|
||||||
|
.and_then(|w| w.cards.get(w.cards.len().saturating_sub(visible + 1)))
|
||||||
|
.map(|c| c.id)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Map card_id -> (Entity, current_translation, anim_end) for in-place
|
||||||
|
// updates. `anim_end` is `Some(end_xy)` when a curve-based `CardAnimation`
|
||||||
|
// is currently driving the card (e.g. a drag-rejection return tween).
|
||||||
|
//
|
||||||
|
// In the position loop below we compare `anim_end` against the new game-
|
||||||
|
// state target position to decide whether to honour or cancel the tween:
|
||||||
|
// • end ≈ target → animation is still heading to the right place; let
|
||||||
|
// it finish (skip the snap/slide path).
|
||||||
|
// • end ≠ target → the game state has changed (e.g. a new game started
|
||||||
|
// while the win-cascade was mid-flight); cancel the
|
||||||
|
// stale `CardAnimation` and apply the new position.
|
||||||
|
let mut existing: HashMap<u32, (Entity, Vec3, Option<Vec2>)> = HashMap::new();
|
||||||
for (entity, marker, transform, anim) in entities.iter() {
|
for (entity, marker, transform, anim) in entities.iter() {
|
||||||
existing.insert(marker.card_id, (entity, transform.translation, anim.is_some()));
|
existing.insert(marker.card_id, (entity, transform.translation, anim.map(|a| a.end)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let live_ids: HashSet<u32> = positions.iter().map(|(c, _, _)| c.id).collect();
|
let live_ids: HashSet<u32> = positions.iter().map(|(c, _, _)| c.id).collect();
|
||||||
@@ -632,17 +738,38 @@ fn sync_cards(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// For each card in the current state: spawn or update its entity.
|
// For each card in the current state: spawn or update its entity, then
|
||||||
|
// apply visibility. The waste buffer card is hidden so it cannot peek
|
||||||
|
// behind the incoming top card during the draw slide animation.
|
||||||
for (card, position, z) in positions {
|
for (card, position, z) in positions {
|
||||||
match existing.get(&card.id) {
|
let entity = match existing.get(&card.id) {
|
||||||
Some(&(entity, cur, has_anim)) => {
|
Some(&(entity, cur, anim_end)) => {
|
||||||
|
// If a CardAnimation is in flight, check whether its destination
|
||||||
|
// still matches the game-state target. If the game moved the card
|
||||||
|
// elsewhere (e.g. new game started during a win-cascade scatter),
|
||||||
|
// cancel the stale tween so the card snaps/slides to its new home.
|
||||||
|
let has_anim = match anim_end {
|
||||||
|
Some(end_xy) if (end_xy - position).length() > 2.0 => {
|
||||||
|
commands.entity(entity).remove::<CardAnimation>();
|
||||||
|
false
|
||||||
|
}
|
||||||
|
Some(_) => true,
|
||||||
|
None => false,
|
||||||
|
};
|
||||||
update_card_entity(
|
update_card_entity(
|
||||||
&mut commands, entity, card, position, z, layout,
|
&mut commands, entity, card, position, z, layout,
|
||||||
slide_secs, back_colour, color_blind, high_contrast, cur, has_anim, card_images, selected_back,
|
slide_secs, back_colour, color_blind, high_contrast, cur, has_anim, card_images, selected_back, font_handle,
|
||||||
)
|
);
|
||||||
}
|
entity
|
||||||
None => spawn_card_entity(&mut commands, card, position, z, layout, back_colour, color_blind, high_contrast, card_images, selected_back),
|
|
||||||
}
|
}
|
||||||
|
None => spawn_card_entity(&mut commands, card, position, z, layout, back_colour, color_blind, high_contrast, card_images, selected_back, font_handle),
|
||||||
|
};
|
||||||
|
let visibility = if waste_buffer_id == Some(card.id) {
|
||||||
|
Visibility::Hidden
|
||||||
|
} else {
|
||||||
|
Visibility::Inherited
|
||||||
|
};
|
||||||
|
commands.entity(entity).insert(visibility);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -665,6 +792,19 @@ fn card_positions<'a>(game: &'a GameState, layout: &Layout) -> Vec<(&'a Card, Ve
|
|||||||
PileType::Tableau(6),
|
PileType::Tableau(6),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Compute the Draw-Three waste fan step proportional to the column spacing
|
||||||
|
// (waste_x − stock_x = card_width + h_gap) rather than a fixed fraction of
|
||||||
|
// card_width. On desktop (H_GAP_DIVISOR=4) col_step = 1.25×cw and
|
||||||
|
// 0.224 × 1.25 = 0.28 — identical to the previous constant. On Android
|
||||||
|
// (H_GAP_DIVISOR=32) col_step ≈ 1.031×cw so fan_step ≈ 0.231×cw, keeping
|
||||||
|
// the top fanned card's centre within the waste column's own horizontal
|
||||||
|
// footprint instead of spilling into the adjacent gap.
|
||||||
|
let waste_fan_step = {
|
||||||
|
let s = layout.pile_positions.get(&PileType::Stock).copied().unwrap_or_default();
|
||||||
|
let w = layout.pile_positions.get(&PileType::Waste).copied().unwrap_or_default();
|
||||||
|
(w.x - s.x).abs() * 0.224
|
||||||
|
};
|
||||||
|
|
||||||
for pile_type in piles {
|
for pile_type in piles {
|
||||||
let Some(base) = layout.pile_positions.get(&pile_type) else {
|
let Some(base) = layout.pile_positions.get(&pile_type) else {
|
||||||
continue;
|
continue;
|
||||||
@@ -706,7 +846,7 @@ fn card_positions<'a>(game: &'a GameState, layout: &Layout) -> Vec<(&'a Card, Ve
|
|||||||
// normally — no card is hidden, so the shift is 0.
|
// normally — no card is hidden, so the shift is 0.
|
||||||
let visible = 3_usize;
|
let visible = 3_usize;
|
||||||
let hidden = rendered_len.saturating_sub(visible);
|
let hidden = rendered_len.saturating_sub(visible);
|
||||||
slot.saturating_sub(hidden) as f32 * layout.card_size.x * 0.28
|
slot.saturating_sub(hidden) as f32 * waste_fan_step
|
||||||
} else {
|
} else {
|
||||||
0.0
|
0.0
|
||||||
};
|
};
|
||||||
@@ -738,7 +878,8 @@ fn spawn_card_entity(
|
|||||||
high_contrast: bool,
|
high_contrast: bool,
|
||||||
card_images: Option<&CardImageSet>,
|
card_images: Option<&CardImageSet>,
|
||||||
selected_back: usize,
|
selected_back: usize,
|
||||||
) {
|
font_handle: Option<&Handle<Font>>,
|
||||||
|
) -> Entity {
|
||||||
let sprite = card_sprite(card, layout.card_size, back_colour, card_images, selected_back);
|
let sprite = card_sprite(card, layout.card_size, back_colour, card_images, selected_back);
|
||||||
|
|
||||||
let mut entity = commands.spawn((
|
let mut entity = commands.spawn((
|
||||||
@@ -747,6 +888,7 @@ fn spawn_card_entity(
|
|||||||
Transform::from_xyz(pos.x, pos.y, z),
|
Transform::from_xyz(pos.x, pos.y, z),
|
||||||
Visibility::default(),
|
Visibility::default(),
|
||||||
));
|
));
|
||||||
|
let entity_id = entity.id();
|
||||||
// Every card gets a subtle drop-shadow child so the play surface reads
|
// Every card gets a subtle drop-shadow child so the play surface reads
|
||||||
// as physical instead of flat. Spawned in idle state; the drag-tracking
|
// as physical instead of flat. Spawned in idle state; the drag-tracking
|
||||||
// system retunes its offset / alpha when this card joins the dragged
|
// system retunes its offset / alpha when this card joins the dragged
|
||||||
@@ -754,15 +896,15 @@ fn spawn_card_entity(
|
|||||||
entity.with_children(|b| {
|
entity.with_children(|b| {
|
||||||
add_card_shadow_child(b, layout.card_size);
|
add_card_shadow_child(b, layout.card_size);
|
||||||
});
|
});
|
||||||
// Face-down cards get a thin contrasting border frame so the dark back
|
// Every card gets a thin border frame so it reads as a distinct
|
||||||
// PNG reads as a distinct rectangle against the dark felt.
|
// rectangle against the dark felt, regardless of face state.
|
||||||
if !card.face_up {
|
|
||||||
entity.with_children(|b| {
|
entity.with_children(|b| {
|
||||||
add_card_back_frame_child(b, layout.card_size);
|
add_card_back_frame_child(b, layout.card_size);
|
||||||
});
|
});
|
||||||
}
|
|
||||||
// When PNG faces are loaded the rank/suit are baked into the image.
|
// When PNG faces are loaded the rank/suit are baked into the image.
|
||||||
// Only spawn the Text2d overlay in the solid-colour fallback (tests).
|
// Only spawn the Text2d overlay in the solid-colour fallback (tests).
|
||||||
|
// On Android we additionally spawn a large-print corner label even in
|
||||||
|
// image mode so the rank/suit are legible at phone scale.
|
||||||
if card_images.is_none() {
|
if card_images.is_none() {
|
||||||
entity.with_children(|b| {
|
entity.with_children(|b| {
|
||||||
b.spawn((
|
b.spawn((
|
||||||
@@ -778,6 +920,16 @@ fn spawn_card_entity(
|
|||||||
));
|
));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
if card_images.is_some() {
|
||||||
|
entity.with_children(|b| {
|
||||||
|
add_android_corner_label(b, card, layout.card_size, color_blind, high_contrast, font_handle);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Suppress unused-variable warning when not building for Android.
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
let _ = font_handle;
|
||||||
|
entity_id
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
@@ -796,6 +948,7 @@ fn update_card_entity(
|
|||||||
has_card_animation: bool,
|
has_card_animation: bool,
|
||||||
card_images: Option<&CardImageSet>,
|
card_images: Option<&CardImageSet>,
|
||||||
selected_back: usize,
|
selected_back: usize,
|
||||||
|
font_handle: Option<&Handle<Font>>,
|
||||||
) {
|
) {
|
||||||
let target = Vec3::new(pos.x, pos.y, z);
|
let target = Vec3::new(pos.x, pos.y, z);
|
||||||
|
|
||||||
@@ -831,16 +984,15 @@ fn update_card_entity(
|
|||||||
|
|
||||||
// Despawn any stale children and re-add the per-card drop shadow plus,
|
// Despawn any stale children and re-add the per-card drop shadow plus,
|
||||||
// in solid-colour fallback mode, the label overlay. In image mode the
|
// in solid-colour fallback mode, the label overlay. In image mode the
|
||||||
// rank/suit are baked into the PNG, so no `Text2d` overlay is needed.
|
// rank/suit are baked into the PNG; on Android we also add a large-print
|
||||||
|
// corner overlay so they are legible at phone scale.
|
||||||
commands.entity(entity).despawn_related::<Children>();
|
commands.entity(entity).despawn_related::<Children>();
|
||||||
commands.entity(entity).with_children(|b| {
|
commands.entity(entity).with_children(|b| {
|
||||||
add_card_shadow_child(b, layout.card_size);
|
add_card_shadow_child(b, layout.card_size);
|
||||||
});
|
});
|
||||||
if !card.face_up {
|
|
||||||
commands.entity(entity).with_children(|b| {
|
commands.entity(entity).with_children(|b| {
|
||||||
add_card_back_frame_child(b, layout.card_size);
|
add_card_back_frame_child(b, layout.card_size);
|
||||||
});
|
});
|
||||||
}
|
|
||||||
if card_images.is_none() {
|
if card_images.is_none() {
|
||||||
commands.entity(entity).with_children(|b| {
|
commands.entity(entity).with_children(|b| {
|
||||||
b.spawn((
|
b.spawn((
|
||||||
@@ -856,6 +1008,15 @@ fn update_card_entity(
|
|||||||
));
|
));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
if card_images.is_some() {
|
||||||
|
commands.entity(entity).with_children(|b| {
|
||||||
|
add_android_corner_label(b, card, layout.card_size, color_blind, high_contrast, font_handle);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Suppress unused-variable warning when not building for Android.
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
let _ = font_handle;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn label_for(card: &Card) -> String {
|
fn label_for(card: &Card) -> String {
|
||||||
@@ -928,6 +1089,134 @@ fn label_visibility(card: &Card) -> Visibility {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rank+suit string for the Android readability overlay.
|
||||||
|
/// Uses Unicode suit glyphs (♠♥♦♣ — U+2660–U+2666, covered by FiraMono).
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
fn mobile_label_for(card: &Card) -> String {
|
||||||
|
let rank = match card.rank {
|
||||||
|
Rank::Ace => "A",
|
||||||
|
Rank::Two => "2",
|
||||||
|
Rank::Three => "3",
|
||||||
|
Rank::Four => "4",
|
||||||
|
Rank::Five => "5",
|
||||||
|
Rank::Six => "6",
|
||||||
|
Rank::Seven => "7",
|
||||||
|
Rank::Eight => "8",
|
||||||
|
Rank::Nine => "9",
|
||||||
|
Rank::Ten => "10",
|
||||||
|
Rank::Jack => "J",
|
||||||
|
Rank::Queen => "Q",
|
||||||
|
Rank::King => "K",
|
||||||
|
};
|
||||||
|
let suit = match card.suit {
|
||||||
|
Suit::Clubs => "♣",
|
||||||
|
Suit::Diamonds => "♦",
|
||||||
|
Suit::Hearts => "♥",
|
||||||
|
Suit::Spades => "♠",
|
||||||
|
};
|
||||||
|
format!("{rank}{suit}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns the [`AndroidCornerLabel`] + [`AndroidCornerBg`] children on
|
||||||
|
/// face-up cards. The background sprite covers the card art's own small
|
||||||
|
/// corner text so only the large overlay is visible.
|
||||||
|
/// Spawns the [`AndroidCornerLabel`] + [`AndroidCornerBg`] children on
|
||||||
|
/// face-up cards using FiraMono (passed via `font_handle`) so that the
|
||||||
|
/// suit Unicode glyphs U+2660–U+2666 render correctly. Without an explicit
|
||||||
|
/// font handle Bevy falls back to its built-in face which does not include
|
||||||
|
/// those glyphs, causing a coloured missing-glyph rectangle to appear in
|
||||||
|
/// the text colour — the root cause of the "red square on face-down cards"
|
||||||
|
/// visual bug (the box bleeds through near the card edge at z=0.02).
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
fn add_android_corner_label(
|
||||||
|
parent: &mut ChildSpawnerCommands,
|
||||||
|
card: &Card,
|
||||||
|
card_size: Vec2,
|
||||||
|
color_blind: bool,
|
||||||
|
high_contrast: bool,
|
||||||
|
font_handle: Option<&Handle<Font>>,
|
||||||
|
) {
|
||||||
|
if !card.face_up {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let font_size = card_size.x * FONT_SIZE_FRAC_MOBILE;
|
||||||
|
let inset = 3.0_f32;
|
||||||
|
// Background covers ~3 monospace chars wide × 1 line tall.
|
||||||
|
// FiraMono char width ≈ 0.6 × font_size; 2.0× gives room for "10♠"
|
||||||
|
// (3 chars = 1.8× font_size) plus a small margin.
|
||||||
|
let bg_w = font_size * 2.0;
|
||||||
|
let bg_h = font_size * 1.25;
|
||||||
|
|
||||||
|
// Background covers the PNG's baked-in small corner text (top-left).
|
||||||
|
// Classic PNG cards have a white face, so the background must be white too.
|
||||||
|
// (CARD_FACE_COLOUR is the Terminal theme's dark face colour — wrong here.)
|
||||||
|
parent.spawn((
|
||||||
|
AndroidCornerBg,
|
||||||
|
Sprite {
|
||||||
|
color: Color::WHITE,
|
||||||
|
custom_size: Some(Vec2::new(bg_w, bg_h)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
Transform::from_xyz(
|
||||||
|
-card_size.x / 2.0 + inset + bg_w / 2.0,
|
||||||
|
card_size.y / 2.0 - inset - bg_h / 2.0,
|
||||||
|
0.015,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
// Cover the matching rotated baked-in text at the bottom-right corner.
|
||||||
|
parent.spawn((
|
||||||
|
AndroidCornerBg,
|
||||||
|
Sprite {
|
||||||
|
color: Color::WHITE,
|
||||||
|
custom_size: Some(Vec2::new(bg_w, bg_h)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
Transform::from_xyz(
|
||||||
|
card_size.x / 2.0 - inset - bg_w / 2.0,
|
||||||
|
-card_size.y / 2.0 + inset + bg_h / 2.0,
|
||||||
|
0.015,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
|
||||||
|
// Large rank+suit text drawn on top of the background. FiraMono must be
|
||||||
|
// wired here explicitly — the suit glyphs (U+2660–U+2666) are not in
|
||||||
|
// Bevy's built-in font and render as a coloured rectangle without it.
|
||||||
|
//
|
||||||
|
// Classic PNG cards have a white face: red suits stay the same saturated
|
||||||
|
// red, but black suits must use a dark colour (CARD_FACE_COLOUR ≈ #1a1a1a)
|
||||||
|
// rather than the near-white BLACK_SUIT_COLOUR designed for the dark
|
||||||
|
// Terminal theme background.
|
||||||
|
let text_col = if card.suit.is_red() {
|
||||||
|
if color_blind {
|
||||||
|
RED_SUIT_COLOUR_CBM
|
||||||
|
} else if high_contrast {
|
||||||
|
RED_SUIT_COLOUR_HC
|
||||||
|
} else {
|
||||||
|
RED_SUIT_COLOUR
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
CARD_FACE_COLOUR
|
||||||
|
};
|
||||||
|
let label_text = mobile_label_for(card);
|
||||||
|
parent.spawn((
|
||||||
|
AndroidCornerLabel(label_text.clone()),
|
||||||
|
CardLabel,
|
||||||
|
Text2d::new(label_text),
|
||||||
|
TextFont {
|
||||||
|
font: font_handle.cloned().unwrap_or_default(),
|
||||||
|
font_size,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
TextColor(text_col),
|
||||||
|
Anchor::TOP_LEFT,
|
||||||
|
Transform::from_xyz(
|
||||||
|
-card_size.x / 2.0 + inset,
|
||||||
|
card_size.y / 2.0 - inset,
|
||||||
|
0.02,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Task #34 — Card-flip animation systems
|
// Task #34 — Card-flip animation systems
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1394,6 +1683,7 @@ fn apply_stock_empty_indicator<F: bevy::ecs::query::QueryFilter>(
|
|||||||
pile_markers: &mut Query<(Entity, &PileMarker, &mut Sprite), F>,
|
pile_markers: &mut Query<(Entity, &PileMarker, &mut Sprite), F>,
|
||||||
label_children: &Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
|
label_children: &Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
|
||||||
layout: &Layout,
|
layout: &Layout,
|
||||||
|
font: Handle<Font>,
|
||||||
) {
|
) {
|
||||||
let stock_empty = game
|
let stock_empty = game
|
||||||
.piles
|
.piles
|
||||||
@@ -1419,7 +1709,7 @@ fn apply_stock_empty_indicator<F: bevy::ecs::query::QueryFilter>(
|
|||||||
b.spawn((
|
b.spawn((
|
||||||
StockEmptyLabel,
|
StockEmptyLabel,
|
||||||
Text2d::new("↺"),
|
Text2d::new("↺"),
|
||||||
TextFont { font_size, ..default() },
|
TextFont { font: font.clone(), font_size, ..default() },
|
||||||
TextColor(TEXT_PRIMARY.with_alpha(0.7)),
|
TextColor(TEXT_PRIMARY.with_alpha(0.7)),
|
||||||
Transform::from_xyz(0.0, 0.0, 0.1),
|
Transform::from_xyz(0.0, 0.0, 0.1),
|
||||||
));
|
));
|
||||||
@@ -1445,16 +1735,19 @@ fn update_stock_empty_indicator_startup(
|
|||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
layout: Option<Res<LayoutResource>>,
|
layout: Option<Res<LayoutResource>>,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
|
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
|
||||||
label_children: Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
|
label_children: Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
|
||||||
) {
|
) {
|
||||||
let Some(layout) = layout else { return };
|
let Some(layout) = layout else { return };
|
||||||
|
let font = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default();
|
||||||
apply_stock_empty_indicator(
|
apply_stock_empty_indicator(
|
||||||
&mut commands,
|
&mut commands,
|
||||||
&game.0,
|
&game.0,
|
||||||
&mut pile_markers,
|
&mut pile_markers,
|
||||||
&label_children,
|
&label_children,
|
||||||
&layout.0,
|
&layout.0,
|
||||||
|
font,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1465,6 +1758,7 @@ fn update_stock_empty_indicator(
|
|||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
layout: Option<Res<LayoutResource>>,
|
layout: Option<Res<LayoutResource>>,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
|
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
|
||||||
label_children: Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
|
label_children: Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
|
||||||
) {
|
) {
|
||||||
@@ -1472,12 +1766,14 @@ fn update_stock_empty_indicator(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let Some(layout) = layout else { return };
|
let Some(layout) = layout else { return };
|
||||||
|
let font = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default();
|
||||||
apply_stock_empty_indicator(
|
apply_stock_empty_indicator(
|
||||||
&mut commands,
|
&mut commands,
|
||||||
&game.0,
|
&game.0,
|
||||||
&mut pile_markers,
|
&mut pile_markers,
|
||||||
&label_children,
|
&label_children,
|
||||||
&layout.0,
|
&layout.0,
|
||||||
|
font,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1492,10 +1788,11 @@ fn update_stock_empty_indicator(
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Inset (in pixels) from the top-right corner of the stock pile sprite to
|
/// Inset (in pixels) from the top-right corner of the stock pile sprite to
|
||||||
/// the centre of the count badge. A small inward offset keeps the chip from
|
/// the centre of the count badge. Must satisfy `|x| >= STOCK_BADGE_SIZE.x / 2`
|
||||||
/// drifting half-off the card while still reading as "attached" to the
|
/// so the badge right edge stays inside the stock pile and never overlaps the
|
||||||
/// corner.
|
/// adjacent waste pile — critical on Android where `H_GAP_DIVISOR = 32` gives
|
||||||
const STOCK_BADGE_INSET: Vec2 = Vec2::new(-12.0, -8.0);
|
/// an inter-pile gap of only ~4 px.
|
||||||
|
const STOCK_BADGE_INSET: Vec2 = Vec2::new(-20.0, -8.0);
|
||||||
|
|
||||||
/// Width / height of the badge background sprite, in world pixels. Sized so
|
/// Width / height of the badge background sprite, in world pixels. Sized so
|
||||||
/// a 2-digit count (max "24") fits comfortably with `TYPE_BODY` (14 pt) text.
|
/// a 2-digit count (max "24") fits comfortably with `TYPE_BODY` (14 pt) text.
|
||||||
@@ -1687,6 +1984,7 @@ fn snap_cards_on_window_resize(
|
|||||||
game: Option<Res<GameStateResource>>,
|
game: Option<Res<GameStateResource>>,
|
||||||
layout: Option<Res<LayoutResource>>,
|
layout: Option<Res<LayoutResource>>,
|
||||||
card_images: Option<Res<CardImageSet>>,
|
card_images: Option<Res<CardImageSet>>,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
entities: Query<
|
entities: Query<
|
||||||
(Entity, &CardEntity, &mut Sprite, &mut Transform),
|
(Entity, &CardEntity, &mut Sprite, &mut Transform),
|
||||||
(Without<CardLabel>, Without<CardShadow>, Without<CardBackFrame>),
|
(Without<CardLabel>, Without<CardShadow>, Without<CardBackFrame>),
|
||||||
@@ -1735,12 +2033,14 @@ fn snap_cards_on_window_resize(
|
|||||||
frame_query,
|
frame_query,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let font = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default();
|
||||||
apply_stock_empty_indicator(
|
apply_stock_empty_indicator(
|
||||||
&mut commands,
|
&mut commands,
|
||||||
&game.0,
|
&game.0,
|
||||||
&mut pile_markers,
|
&mut pile_markers,
|
||||||
&label_children,
|
&label_children,
|
||||||
&layout.0,
|
&layout.0,
|
||||||
|
font,
|
||||||
);
|
);
|
||||||
|
|
||||||
throttle.last_applied_secs = now;
|
throttle.last_applied_secs = now;
|
||||||
@@ -1836,6 +2136,44 @@ fn resize_cards_in_place(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Updates font size and top-left anchor transform of every
|
||||||
|
/// [`AndroidCornerLabel`] entity when `LayoutResource` changes (orientation
|
||||||
|
/// change or any window resize). The full despawn/respawn path in
|
||||||
|
/// `update_card_entity` already handles game-state changes; this system
|
||||||
|
/// covers the resize-only path where children are mutated in place.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
fn resize_android_corner_labels(
|
||||||
|
layout: Res<LayoutResource>,
|
||||||
|
card_images: Option<Res<CardImageSet>>,
|
||||||
|
mut text_query: Query<(&AndroidCornerLabel, &mut Text2d, &mut TextFont, &mut Transform)>,
|
||||||
|
mut bg_query: Query<
|
||||||
|
(&mut Sprite, &mut Transform),
|
||||||
|
(With<AndroidCornerBg>, Without<AndroidCornerLabel>),
|
||||||
|
>,
|
||||||
|
) {
|
||||||
|
if !layout.is_changed() || card_images.is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let font_size = layout.0.card_size.x * FONT_SIZE_FRAC_MOBILE;
|
||||||
|
let inset = 3.0_f32;
|
||||||
|
let bg_w = font_size * 2.0;
|
||||||
|
let bg_h = font_size * 1.25;
|
||||||
|
let text_x = -layout.0.card_size.x / 2.0 + inset;
|
||||||
|
let text_y = layout.0.card_size.y / 2.0 - inset;
|
||||||
|
|
||||||
|
for (label, mut text2d, mut font, mut transform) in text_query.iter_mut() {
|
||||||
|
text2d.0 = label.0.clone();
|
||||||
|
font.font_size = font_size;
|
||||||
|
transform.translation.x = text_x;
|
||||||
|
transform.translation.y = text_y;
|
||||||
|
}
|
||||||
|
for (mut sprite, mut transform) in bg_query.iter_mut() {
|
||||||
|
sprite.custom_size = Some(Vec2::new(bg_w, bg_h));
|
||||||
|
transform.translation.x = text_x + bg_w / 2.0;
|
||||||
|
transform.translation.y = text_y - bg_h / 2.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Adjusts `LayoutResource.tableau_fan_frac` to match the current maximum
|
/// Adjusts `LayoutResource.tableau_fan_frac` to match the current maximum
|
||||||
/// face-up column depth. Runs after every `StateChangedEvent` so the fan
|
/// face-up column depth. Runs after every `StateChangedEvent` so the fan
|
||||||
/// expands as the player reveals cards while staying within the window.
|
/// expands as the player reveals cards while staying within the window.
|
||||||
@@ -2103,6 +2441,35 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The waste buffer card (slot below top) must be at the *same* XY as the
|
||||||
|
/// top card so that hiding it (`Visibility::Hidden`) leaves no visible gap.
|
||||||
|
#[test]
|
||||||
|
fn waste_draw_one_buffer_card_at_same_xy_as_top() {
|
||||||
|
use solitaire_core::game_state::DrawMode;
|
||||||
|
let mut g = GameState::new(42, DrawMode::DrawOne);
|
||||||
|
// Draw 3 times so the waste pile has 3 cards and the buffer exists.
|
||||||
|
for _ in 0..3 {
|
||||||
|
let _ = g.draw();
|
||||||
|
}
|
||||||
|
let waste_ids: std::collections::HashSet<u32> =
|
||||||
|
g.piles[&PileType::Waste].cards.iter().map(|c| c.id).collect();
|
||||||
|
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
|
let positions = card_positions(&g, &layout);
|
||||||
|
let waste_rendered: Vec<_> = positions
|
||||||
|
.iter()
|
||||||
|
.filter(|(card, _, _)| waste_ids.contains(&card.id))
|
||||||
|
.collect();
|
||||||
|
// Buffer (slot 0) + top (slot 1) = 2 rendered waste cards.
|
||||||
|
assert_eq!(waste_rendered.len(), 2, "Draw-One with 3 waste cards must render exactly 2");
|
||||||
|
// Both must share the same XY so that hiding the buffer leaves no gap.
|
||||||
|
let (_, pos0, _) = waste_rendered[0];
|
||||||
|
let (_, pos1, _) = waste_rendered[1];
|
||||||
|
assert!(
|
||||||
|
(pos0.x - pos1.x).abs() < 1e-3 && (pos0.y - pos1.y).abs() < 1e-3,
|
||||||
|
"buffer and top card must be at the same XY; got buffer={pos0:?} top={pos1:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn card_positions_tableau_cards_are_fanned_downward() {
|
fn card_positions_tableau_cards_are_fanned_downward() {
|
||||||
let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne);
|
let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne);
|
||||||
@@ -3007,4 +3374,230 @@ mod tests {
|
|||||||
assert!((highlight.blue - success.blue).abs() < 1e-6);
|
assert!((highlight.blue - success.blue).abs() < 1e-6);
|
||||||
assert!((highlight.alpha - 0.6).abs() < 1e-6);
|
assert!((highlight.alpha - 0.6).abs() < 1e-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Bug #1 — CardImageSet key lookup (code-side mapping)
|
||||||
|
//
|
||||||
|
// These tests verify that every (Rank, Suit) pair produces the expected
|
||||||
|
// filename via `card_face_asset_path`. They can only detect *code-side*
|
||||||
|
// mapping bugs (e.g. a suit index mismatch). They do NOT inspect pixel
|
||||||
|
// data — if `QS.png` contains a diamond watermark that is an *asset
|
||||||
|
// content* bug that requires replacing the PNG file.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn card_face_asset_path_queen_of_spades_is_qs_png() {
|
||||||
|
assert_eq!(
|
||||||
|
card_face_asset_path(Rank::Queen, Suit::Spades),
|
||||||
|
"cards/faces/classic/QS.png",
|
||||||
|
"Queen of Spades must resolve to QS.png, not QD.png"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn card_face_asset_path_queen_of_diamonds_is_qd_png() {
|
||||||
|
assert_eq!(
|
||||||
|
card_face_asset_path(Rank::Queen, Suit::Diamonds),
|
||||||
|
"cards/faces/classic/QD.png"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn card_face_asset_path_ace_of_clubs_is_ac_png() {
|
||||||
|
assert_eq!(card_face_asset_path(Rank::Ace, Suit::Clubs), "cards/faces/classic/AC.png");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn card_face_asset_path_ten_of_hearts_is_10h_png() {
|
||||||
|
assert_eq!(card_face_asset_path(Rank::Ten, Suit::Hearts), "cards/faces/classic/10H.png");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn card_face_asset_path_king_of_spades_is_ks_png() {
|
||||||
|
assert_eq!(card_face_asset_path(Rank::King, Suit::Spades), "cards/faces/classic/KS.png");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn card_face_asset_path_all_52_keys_are_unique() {
|
||||||
|
use std::collections::HashSet;
|
||||||
|
let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
|
||||||
|
let ranks = [
|
||||||
|
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five, Rank::Six,
|
||||||
|
Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten, Rank::Jack, Rank::Queen, Rank::King,
|
||||||
|
];
|
||||||
|
let paths: HashSet<String> = suits
|
||||||
|
.iter()
|
||||||
|
.flat_map(|&s| ranks.iter().map(move |&r| card_face_asset_path(r, s)))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(paths.len(), 52, "all 52 card face paths must be distinct");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn card_face_asset_path_suits_produce_correct_suffix() {
|
||||||
|
// Each suit must map to its own letter, not a neighbour's.
|
||||||
|
assert!(card_face_asset_path(Rank::Ace, Suit::Clubs).ends_with("AC.png"));
|
||||||
|
assert!(card_face_asset_path(Rank::Ace, Suit::Diamonds).ends_with("AD.png"));
|
||||||
|
assert!(card_face_asset_path(Rank::Ace, Suit::Hearts).ends_with("AH.png"));
|
||||||
|
assert!(card_face_asset_path(Rank::Ace, Suit::Spades).ends_with("AS.png"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Bug #3 — Suit → color mapping for the Android corner overlay
|
||||||
|
//
|
||||||
|
// Black suits (♠♣) must use BLACK_SUIT_COLOUR (near-white) so they
|
||||||
|
// contrast against the dark card face. They must NOT share the red or
|
||||||
|
// lime colours assigned to red suits.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn text_colour_black_suits_are_near_white_not_red() {
|
||||||
|
for suit in [Suit::Clubs, Suit::Spades] {
|
||||||
|
let card = Card { id: 0, suit, rank: Rank::Ace, face_up: true };
|
||||||
|
let colour = text_colour(&card, false, false);
|
||||||
|
assert_eq!(
|
||||||
|
colour, BLACK_SUIT_COLOUR,
|
||||||
|
"{suit:?} must map to BLACK_SUIT_COLOUR (near-white)"
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
colour, RED_SUIT_COLOUR,
|
||||||
|
"{suit:?} must not use the red suit colour"
|
||||||
|
);
|
||||||
|
// Confirm it's visually light (all channels > 0.85).
|
||||||
|
let srgba = colour.to_srgba();
|
||||||
|
assert!(
|
||||||
|
srgba.red > 0.85 && srgba.green > 0.85 && srgba.blue > 0.85,
|
||||||
|
"{suit:?} colour must be near-white for dark card background contrast, got {srgba:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Bug #4 — Waste pile z-ordering
|
||||||
|
//
|
||||||
|
// Every rendered waste card must have a strictly greater z than the one
|
||||||
|
// below it so Bevy's CPU-side sprite sort renders them back-to-front.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn waste_pile_cards_have_strictly_increasing_z() {
|
||||||
|
use solitaire_core::game_state::DrawMode;
|
||||||
|
let mut g = GameState::new(42, DrawMode::DrawThree);
|
||||||
|
for _ in 0..5 {
|
||||||
|
let _ = g.draw();
|
||||||
|
}
|
||||||
|
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
|
let positions = card_positions(&g, &layout);
|
||||||
|
|
||||||
|
let waste_ids: std::collections::HashSet<u32> = g.piles[&PileType::Waste]
|
||||||
|
.cards
|
||||||
|
.iter()
|
||||||
|
.map(|c| c.id)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut waste_zs: Vec<f32> = positions
|
||||||
|
.iter()
|
||||||
|
.filter(|(c, _, _)| waste_ids.contains(&c.id))
|
||||||
|
.map(|(_, _, z)| *z)
|
||||||
|
.collect();
|
||||||
|
waste_zs.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||||
|
waste_zs.dedup();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
waste_zs.len() >= 2,
|
||||||
|
"expected multiple rendered waste cards, got {}",
|
||||||
|
waste_zs.len()
|
||||||
|
);
|
||||||
|
// All z values must be strictly ordered (no duplicates).
|
||||||
|
for w in waste_zs.windows(2) {
|
||||||
|
assert!(
|
||||||
|
w[1] > w[0],
|
||||||
|
"waste z values must be strictly increasing, got {} ≤ {}",
|
||||||
|
w[1],
|
||||||
|
w[0]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Regression: on tight layouts (e.g. Android H_GAP_DIVISOR=32) the
|
||||||
|
/// Draw-Three waste fan must be proportional to column spacing so that no
|
||||||
|
/// fanned card ever bleeds left into the stock column.
|
||||||
|
///
|
||||||
|
/// The invariant holds structurally (x_offset ≥ 0), but this test pins
|
||||||
|
/// the formula so a future change that accidentally introduces negative
|
||||||
|
/// offsets or flips the fan direction is caught immediately.
|
||||||
|
#[test]
|
||||||
|
fn waste_cards_do_not_overlap_stock_column_on_portrait() {
|
||||||
|
use solitaire_core::game_state::DrawMode;
|
||||||
|
let mut g = GameState::new(42, DrawMode::DrawThree);
|
||||||
|
for _ in 0..5 {
|
||||||
|
let _ = g.draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Android-portrait window. In host tests H_GAP_DIVISOR uses the
|
||||||
|
// desktop value (4), but the no-overlap invariant must hold on any
|
||||||
|
// screen size and gap ratio.
|
||||||
|
let window = Vec2::new(900.0, 2000.0);
|
||||||
|
let layout = crate::layout::compute_layout(window, 32.0, 110.0, true);
|
||||||
|
|
||||||
|
let stock_x = layout.pile_positions[&PileType::Stock].x;
|
||||||
|
let stock_right_edge = stock_x + layout.card_size.x / 2.0;
|
||||||
|
|
||||||
|
let waste_ids: std::collections::HashSet<u32> = g.piles[&PileType::Waste]
|
||||||
|
.cards
|
||||||
|
.iter()
|
||||||
|
.map(|c| c.id)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let positions = card_positions(&g, &layout);
|
||||||
|
for (card, pos, _) in positions.iter().filter(|(c, _, _)| waste_ids.contains(&c.id)) {
|
||||||
|
let left_edge = pos.x - layout.card_size.x / 2.0;
|
||||||
|
assert!(
|
||||||
|
left_edge >= stock_right_edge - 1e-3,
|
||||||
|
"waste card {} left edge {:.2} overlaps stock right edge {:.2} on portrait window",
|
||||||
|
card.id,
|
||||||
|
left_edge,
|
||||||
|
stock_right_edge,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn waste_pile_draw_one_cards_have_distinct_z() {
|
||||||
|
use solitaire_core::game_state::DrawMode;
|
||||||
|
let mut g = GameState::new(42, DrawMode::DrawOne);
|
||||||
|
for _ in 0..3 {
|
||||||
|
let _ = g.draw();
|
||||||
|
}
|
||||||
|
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
|
let positions = card_positions(&g, &layout);
|
||||||
|
|
||||||
|
let waste_ids: std::collections::HashSet<u32> = g.piles[&PileType::Waste]
|
||||||
|
.cards
|
||||||
|
.iter()
|
||||||
|
.map(|c| c.id)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut waste_zs: Vec<f32> = positions
|
||||||
|
.iter()
|
||||||
|
.filter(|(c, _, _)| waste_ids.contains(&c.id))
|
||||||
|
.map(|(_, _, z)| *z)
|
||||||
|
.collect();
|
||||||
|
waste_zs.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||||
|
waste_zs.dedup();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
waste_zs.len() >= 2,
|
||||||
|
"Draw-One must render at least 2 waste cards (visible + buffer)"
|
||||||
|
);
|
||||||
|
// Deduplicated length must equal pre-dedup length → all z distinct.
|
||||||
|
let raw_count = positions
|
||||||
|
.iter()
|
||||||
|
.filter(|(c, _, _)| waste_ids.contains(&c.id))
|
||||||
|
.count();
|
||||||
|
assert_eq!(
|
||||||
|
waste_zs.len(),
|
||||||
|
raw_count,
|
||||||
|
"all rendered waste card z values must be distinct"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ use solitaire_core::game_state::{DrawMode, GameState};
|
|||||||
use solitaire_core::pile::PileType;
|
use solitaire_core::pile::PileType;
|
||||||
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
||||||
|
|
||||||
use crate::card_plugin::{RightClickHighlight, TABLEAU_FAN_FRAC};
|
use crate::card_plugin::RightClickHighlight;
|
||||||
use crate::layout::{Layout, LayoutResource};
|
use crate::layout::{Layout, LayoutResource};
|
||||||
use crate::resources::{DragState, GameStateResource};
|
use crate::resources::{DragState, GameStateResource};
|
||||||
use crate::table_plugin::{PileMarker, PILE_MARKER_DEFAULT_COLOUR};
|
use crate::table_plugin::{PileMarker, PILE_MARKER_DEFAULT_COLOUR};
|
||||||
@@ -80,7 +80,7 @@ impl Plugin for CursorPlugin {
|
|||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
update_cursor_icon,
|
update_cursor_icon,
|
||||||
update_drop_highlights,
|
update_drop_highlights.run_if(resource_changed::<crate::resources::DragState>),
|
||||||
update_drop_target_overlays,
|
update_drop_target_overlays,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -382,24 +382,24 @@ fn update_drop_target_overlays(
|
|||||||
/// for everything else it is card-sized. Replicated here rather than
|
/// for everything else it is card-sized. Replicated here rather than
|
||||||
/// imported because `pile_drop_rect` is private to `input_plugin` and
|
/// imported because `pile_drop_rect` is private to `input_plugin` and
|
||||||
/// this overlay is the only other consumer.
|
/// this overlay is the only other consumer.
|
||||||
fn drop_overlay_rect(pile: &PileType, layout: &Layout, game: &GameState) -> (Vec2, Vec2) {
|
fn drop_overlay_rect(pile: &PileType, layout: &Layout, game: &GameState) -> Option<(Vec2, Vec2)> {
|
||||||
let centre = layout.pile_positions[pile];
|
let centre = layout.pile_positions.get(pile).copied()?;
|
||||||
if matches!(pile, PileType::Tableau(_)) {
|
if matches!(pile, PileType::Tableau(_)) {
|
||||||
let card_count = game.piles.get(pile).map_or(0, |p| p.cards.len());
|
let card_count = game.piles.get(pile).map_or(0, |p| p.cards.len());
|
||||||
if card_count > 1 {
|
if card_count > 1 {
|
||||||
let fan = -layout.card_size.y * TABLEAU_FAN_FRAC;
|
let fan = -layout.card_size.y * layout.tableau_fan_frac;
|
||||||
let bottom_card_centre_y = centre.y + fan * (card_count - 1) as f32;
|
let bottom_card_centre_y = centre.y + fan * (card_count - 1) as f32;
|
||||||
let top_edge = centre.y + layout.card_size.y / 2.0;
|
let top_edge = centre.y + layout.card_size.y / 2.0;
|
||||||
let bottom_edge = bottom_card_centre_y - layout.card_size.y / 2.0;
|
let bottom_edge = bottom_card_centre_y - layout.card_size.y / 2.0;
|
||||||
let span_height = top_edge - bottom_edge;
|
let span_height = top_edge - bottom_edge;
|
||||||
let new_centre_y = (top_edge + bottom_edge) / 2.0;
|
let new_centre_y = (top_edge + bottom_edge) / 2.0;
|
||||||
return (
|
return Some((
|
||||||
Vec2::new(centre.x, new_centre_y),
|
Vec2::new(centre.x, new_centre_y),
|
||||||
Vec2::new(layout.card_size.x, span_height),
|
Vec2::new(layout.card_size.x, span_height),
|
||||||
);
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(centre, layout.card_size)
|
Some((centre, layout.card_size))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawns one overlay parent (fill) plus four edge sprites (outline) at
|
/// Spawns one overlay parent (fill) plus four edge sprites (outline) at
|
||||||
@@ -410,7 +410,10 @@ fn spawn_drop_target_overlay(
|
|||||||
layout: &Layout,
|
layout: &Layout,
|
||||||
game: &GameState,
|
game: &GameState,
|
||||||
) {
|
) {
|
||||||
let (centre, size) = drop_overlay_rect(pile, layout, game);
|
let Some((centre, size)) = drop_overlay_rect(pile, layout, game) else {
|
||||||
|
warn!("drop_overlay_rect: pile {pile:?} not in layout, skipping overlay");
|
||||||
|
return;
|
||||||
|
};
|
||||||
let edge = DROP_TARGET_OUTLINE_PX;
|
let edge = DROP_TARGET_OUTLINE_PX;
|
||||||
|
|
||||||
commands
|
commands
|
||||||
@@ -478,7 +481,7 @@ fn tableau_or_stack_pos(
|
|||||||
if is_tableau {
|
if is_tableau {
|
||||||
Vec2::new(
|
Vec2::new(
|
||||||
base.x,
|
base.x,
|
||||||
base.y - layout.card_size.y * TABLEAU_FAN_FRAC * (index as f32),
|
base.y - layout.card_size.y * layout.tableau_fan_frac * (index as f32),
|
||||||
)
|
)
|
||||||
} else if matches!(pile, PileType::Waste) && game.draw_mode == DrawMode::DrawThree {
|
} else if matches!(pile, PileType::Waste) && game.draw_mode == DrawMode::DrawThree {
|
||||||
let pile_len = game.piles.get(pile).map_or(0, |p| p.cards.len());
|
let pile_len = game.piles.get(pile).map_or(0, |p| p.cards.len());
|
||||||
|
|||||||
@@ -210,10 +210,15 @@ impl Plugin for FeedbackAnimPlugin {
|
|||||||
start_shake_anim.after(GameMutation),
|
start_shake_anim.after(GameMutation),
|
||||||
tick_shake_anim,
|
tick_shake_anim,
|
||||||
start_settle_anim.after(GameMutation),
|
start_settle_anim.after(GameMutation),
|
||||||
|
// tick_foundation_flourish writes the full Transform.scale
|
||||||
|
// (Vec3); tick_settle_anim writes only scale.y on top of
|
||||||
|
// it. Ordering ensures the settle's y-only write always
|
||||||
|
// applies last so it wins on the ~0.15 s overlap when both
|
||||||
|
// components are present on the same King entity.
|
||||||
|
tick_foundation_flourish.before(tick_settle_anim),
|
||||||
tick_settle_anim,
|
tick_settle_anim,
|
||||||
start_deal_anim.after(GameMutation),
|
start_deal_anim.after(GameMutation),
|
||||||
start_foundation_flourish.after(GameMutation),
|
start_foundation_flourish.after(GameMutation),
|
||||||
tick_foundation_flourish,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -228,10 +233,15 @@ impl Plugin for FeedbackAnimPlugin {
|
|||||||
fn start_shake_anim(
|
fn start_shake_anim(
|
||||||
mut events: MessageReader<MoveRejectedEvent>,
|
mut events: MessageReader<MoveRejectedEvent>,
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
card_entities: Query<(Entity, &CardEntity, &Transform)>,
|
card_entities: Query<(Entity, &CardEntity, &Transform)>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
) {
|
) {
|
||||||
|
let reduce_motion = settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode);
|
||||||
for ev in events.read() {
|
for ev in events.read() {
|
||||||
|
if reduce_motion {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let dest_pile = &ev.to;
|
let dest_pile = &ev.to;
|
||||||
// Collect the card ids that belong to the destination pile.
|
// Collect the card ids that belong to the destination pile.
|
||||||
let Some(pile) = game.0.piles.get(dest_pile) else { continue };
|
let Some(pile) = game.0.piles.get(dest_pile) else { continue };
|
||||||
@@ -489,11 +499,16 @@ pub fn foundation_flourish_scale(elapsed_secs: f32, duration_secs: f32) -> f32 {
|
|||||||
fn start_foundation_flourish(
|
fn start_foundation_flourish(
|
||||||
mut events: MessageReader<FoundationCompletedEvent>,
|
mut events: MessageReader<FoundationCompletedEvent>,
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
card_entities: Query<(Entity, &CardEntity)>,
|
card_entities: Query<(Entity, &CardEntity)>,
|
||||||
mut pile_markers: Query<(Entity, &PileMarker, &Sprite, Option<&FoundationMarkerFlourish>)>,
|
mut pile_markers: Query<(Entity, &PileMarker, &Sprite, Option<&FoundationMarkerFlourish>)>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
) {
|
) {
|
||||||
|
let reduce_motion = settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode);
|
||||||
for ev in events.read() {
|
for ev in events.read() {
|
||||||
|
if reduce_motion {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let pile_type = PileType::Foundation(ev.slot);
|
let pile_type = PileType::Foundation(ev.slot);
|
||||||
// Top card of the completed foundation is the King.
|
// Top card of the completed foundation is the King.
|
||||||
let Some(king_id) = game
|
let Some(king_id) = game
|
||||||
@@ -785,7 +800,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn deal_stagger_jitter_varies_across_card_ids() {
|
fn deal_stagger_jitter_varies_across_card_ids() {
|
||||||
// 52 cards should produce more than a couple distinct jitter factors;
|
// 52 cards should produce more than a couple distinct jitter factors;
|
||||||
// a constant function would return one value for all ids.
|
// a constant function would return one function for all ids.
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
let unique: HashSet<u64> = (0u32..52)
|
let unique: HashSet<u64> = (0u32..52)
|
||||||
.map(|id| (deal_stagger_jitter(id) * 1e6) as i64 as u64)
|
.map(|id| (deal_stagger_jitter(id) * 1e6) as i64 as u64)
|
||||||
@@ -796,4 +811,96 @@ mod tests {
|
|||||||
unique.len()
|
unique.len()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Reduce-motion gates — ShakeAnim, FoundationFlourish
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// `start_shake_anim` must not insert `ShakeAnim` when `reduce_motion_mode`
|
||||||
|
/// is on, even when the event targets a pile that has card entities present.
|
||||||
|
#[test]
|
||||||
|
fn shake_anim_skipped_under_reduce_motion() {
|
||||||
|
use bevy::ecs::message::Messages;
|
||||||
|
use solitaire_core::game_state::{DrawMode, GameState};
|
||||||
|
use solitaire_data::Settings;
|
||||||
|
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins)
|
||||||
|
.add_plugins(FeedbackAnimPlugin);
|
||||||
|
app.insert_resource(GameStateResource(GameState::new(1, DrawMode::DrawOne)));
|
||||||
|
app.insert_resource(SettingsResource(Settings {
|
||||||
|
reduce_motion_mode: true,
|
||||||
|
..Settings::default()
|
||||||
|
}));
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
// Pick a card from Tableau(0) so the event refers to a real pile.
|
||||||
|
let dest_pile = PileType::Tableau(0);
|
||||||
|
let card_id = app
|
||||||
|
.world()
|
||||||
|
.resource::<GameStateResource>()
|
||||||
|
.0
|
||||||
|
.piles
|
||||||
|
.get(&dest_pile)
|
||||||
|
.and_then(|p| p.cards.last())
|
||||||
|
.map(|c| c.id)
|
||||||
|
.expect("Tableau(0) should have at least one card in a fresh game");
|
||||||
|
|
||||||
|
// Spawn a minimal CardEntity matching that id so the system would
|
||||||
|
// find it and insert ShakeAnim if the gate were absent.
|
||||||
|
app.world_mut().spawn((
|
||||||
|
CardEntity { card_id },
|
||||||
|
Transform::default(),
|
||||||
|
));
|
||||||
|
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<Messages<MoveRejectedEvent>>()
|
||||||
|
.write(MoveRejectedEvent {
|
||||||
|
from: PileType::Stock,
|
||||||
|
to: dest_pile,
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let shake_count = app
|
||||||
|
.world_mut()
|
||||||
|
.query::<&ShakeAnim>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count();
|
||||||
|
assert_eq!(shake_count, 0, "ShakeAnim must not be inserted under reduce-motion");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `start_foundation_flourish` must not insert `FoundationFlourish` when
|
||||||
|
/// `reduce_motion_mode` is on.
|
||||||
|
#[test]
|
||||||
|
fn foundation_flourish_skipped_under_reduce_motion() {
|
||||||
|
use bevy::ecs::message::Messages;
|
||||||
|
use solitaire_core::game_state::{DrawMode, GameState};
|
||||||
|
use solitaire_data::Settings;
|
||||||
|
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins)
|
||||||
|
.add_plugins(FeedbackAnimPlugin);
|
||||||
|
app.insert_resource(GameStateResource(GameState::new(1, DrawMode::DrawOne)));
|
||||||
|
app.insert_resource(SettingsResource(Settings {
|
||||||
|
reduce_motion_mode: true,
|
||||||
|
..Settings::default()
|
||||||
|
}));
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<Messages<FoundationCompletedEvent>>()
|
||||||
|
.write(FoundationCompletedEvent {
|
||||||
|
slot: 0,
|
||||||
|
suit: solitaire_core::card::Suit::Spades,
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let flourish_count = app
|
||||||
|
.world_mut()
|
||||||
|
.query::<&FoundationFlourish>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count();
|
||||||
|
assert_eq!(flourish_count, 0, "FoundationFlourish must not be inserted under reduce-motion");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,8 +31,15 @@ fn load_font(fonts: Option<ResMut<Assets<Font>>>, mut commands: Commands) {
|
|||||||
// Assets<Font>). FontPlugin in that context is a no-op — consumers
|
// Assets<Font>). FontPlugin in that context is a no-op — consumers
|
||||||
// already query `Option<Res<FontResource>>` and degrade cleanly.
|
// already query `Option<Res<FontResource>>` and degrade cleanly.
|
||||||
let Some(mut fonts) = fonts else { return };
|
let Some(mut fonts) = fonts else { return };
|
||||||
let font = Font::try_from_bytes(BUNDLED_FONT_BYTES.to_vec())
|
let font = match Font::try_from_bytes(BUNDLED_FONT_BYTES.to_vec()) {
|
||||||
.expect("bundled FiraMono failed to parse — binary is corrupt");
|
Ok(f) => f,
|
||||||
|
Err(e) => {
|
||||||
|
// A corrupt embedded font is unusual but should not crash the
|
||||||
|
// process — UI will render without glyphs rather than panicking.
|
||||||
|
warn!("bundled FiraMono failed to parse ({e}); UI text may be invisible");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
let handle = fonts.add(font);
|
let handle = fonts.add(font);
|
||||||
commands.insert_resource(FontResource(handle));
|
commands.insert_resource(FontResource(handle));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -202,6 +202,8 @@ impl Plugin for GamePlugin {
|
|||||||
.add_message::<FoundationCompletedEvent>()
|
.add_message::<FoundationCompletedEvent>()
|
||||||
.add_message::<InfoToastEvent>()
|
.add_message::<InfoToastEvent>()
|
||||||
.add_message::<AppLifecycle>()
|
.add_message::<AppLifecycle>()
|
||||||
|
// add_message is idempotent; SettingsPlugin also registers this.
|
||||||
|
.add_message::<crate::settings_plugin::SettingsChangedEvent>()
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
poll_pending_new_game_seed.before(GameMutation),
|
poll_pending_new_game_seed.before(GameMutation),
|
||||||
@@ -228,6 +230,7 @@ impl Plugin for GamePlugin {
|
|||||||
// GameMutation flow.
|
// GameMutation flow.
|
||||||
.add_systems(Update, spawn_restore_prompt_if_pending)
|
.add_systems(Update, spawn_restore_prompt_if_pending)
|
||||||
.add_systems(Update, handle_restore_prompt.before(GameMutation))
|
.add_systems(Update, handle_restore_prompt.before(GameMutation))
|
||||||
|
.add_systems(Update, sync_settings_to_game.before(GameMutation))
|
||||||
.init_resource::<AutoSaveTimer>()
|
.init_resource::<AutoSaveTimer>()
|
||||||
.add_systems(Update, tick_elapsed_time)
|
.add_systems(Update, tick_elapsed_time)
|
||||||
.add_systems(Update, auto_save_game_state)
|
.add_systems(Update, auto_save_game_state)
|
||||||
@@ -235,6 +238,23 @@ impl Plugin for GamePlugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Forwards `take_from_foundation` from [`SettingsResource`] to the live
|
||||||
|
/// [`GameStateResource`] every time [`SettingsChangedEvent`] fires.
|
||||||
|
///
|
||||||
|
/// This covers two cases that the new-game path misses:
|
||||||
|
/// 1. The initial settings load at startup: saves on disk default to `false`
|
||||||
|
/// but `Settings` defaults to `true`; the event fires once when the
|
||||||
|
/// settings file is first read.
|
||||||
|
/// 2. A user toggling the setting mid-session in the Settings panel.
|
||||||
|
fn sync_settings_to_game(
|
||||||
|
mut events: MessageReader<crate::settings_plugin::SettingsChangedEvent>,
|
||||||
|
mut game: ResMut<GameStateResource>,
|
||||||
|
) {
|
||||||
|
for ev in events.read() {
|
||||||
|
game.0.take_from_foundation = ev.0.take_from_foundation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Pure, testable helper. Updates `elapsed_seconds` and drains the
|
/// Pure, testable helper. Updates `elapsed_seconds` and drains the
|
||||||
/// fractional accumulator into whole-second ticks. No-op when `is_won`.
|
/// fractional accumulator into whole-second ticks. No-op when `is_won`.
|
||||||
pub fn advance_elapsed(
|
pub fn advance_elapsed(
|
||||||
@@ -380,11 +400,11 @@ fn poll_pending_new_game_seed(
|
|||||||
|
|
||||||
/// Pure helper extracted for testability — `new_game_with_solver_*`
|
/// Pure helper extracted for testability — `new_game_with_solver_*`
|
||||||
/// engine tests in the same file exercise this path.
|
/// engine tests in the same file exercise this path.
|
||||||
pub(crate) fn choose_winnable_seed(initial_seed: u64, draw_mode: &DrawMode) -> u64 {
|
pub(crate) fn choose_winnable_seed(initial_seed: u64, draw_mode: DrawMode) -> u64 {
|
||||||
let cfg = SolverConfig::default();
|
let cfg = SolverConfig::default();
|
||||||
let mut seed = initial_seed;
|
let mut seed = initial_seed;
|
||||||
for _ in 0..SOLVER_DEAL_RETRY_CAP {
|
for _ in 0..SOLVER_DEAL_RETRY_CAP {
|
||||||
match try_solve(seed, draw_mode.clone(), &cfg) {
|
match try_solve(seed, draw_mode, &cfg) {
|
||||||
SolverResult::Winnable | SolverResult::Inconclusive => return seed,
|
SolverResult::Winnable | SolverResult::Inconclusive => return seed,
|
||||||
SolverResult::Unwinnable => {
|
SolverResult::Unwinnable => {
|
||||||
seed = seed.wrapping_add(1);
|
seed = seed.wrapping_add(1);
|
||||||
@@ -451,7 +471,7 @@ fn handle_new_game(
|
|||||||
// where SettingsPlugin is not installed.
|
// where SettingsPlugin is not installed.
|
||||||
let draw_mode = settings
|
let draw_mode = settings
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map_or_else(|| game.0.draw_mode.clone(), |s| s.0.draw_mode.clone());
|
.map_or_else(|| game.0.draw_mode, |s| s.0.draw_mode);
|
||||||
let mode = ev.mode.unwrap_or(game.0.mode);
|
let mode = ev.mode.unwrap_or(game.0.mode);
|
||||||
|
|
||||||
// Solver-backed retry: when the player has opted in to
|
// Solver-backed retry: when the player has opted in to
|
||||||
@@ -473,9 +493,8 @@ fn handle_new_game(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|s| s.0.winnable_deals_only);
|
.is_some_and(|s| s.0.winnable_deals_only);
|
||||||
if winnable_only && mode == GameMode::Classic && ev.seed.is_none() {
|
if winnable_only && mode == GameMode::Classic && ev.seed.is_none() {
|
||||||
let dm = draw_mode.clone();
|
|
||||||
let task = AsyncComputeTaskPool::get()
|
let task = AsyncComputeTaskPool::get()
|
||||||
.spawn(async move { choose_winnable_seed(initial_seed, &dm) });
|
.spawn(async move { choose_winnable_seed(initial_seed, draw_mode) });
|
||||||
pending_seed.inner = Some(PendingSeedTask {
|
pending_seed.inner = Some(PendingSeedTask {
|
||||||
handle: task,
|
handle: task,
|
||||||
mode: ev.mode,
|
mode: ev.mode,
|
||||||
@@ -615,6 +634,7 @@ fn handle_restore_prompt(
|
|||||||
new_game_buttons: Query<&Interaction, (With<RestoreNewGameButton>, Changed<Interaction>)>,
|
new_game_buttons: Query<&Interaction, (With<RestoreNewGameButton>, Changed<Interaction>)>,
|
||||||
mut pending: ResMut<PendingRestoredGame>,
|
mut pending: ResMut<PendingRestoredGame>,
|
||||||
mut game: ResMut<GameStateResource>,
|
mut game: ResMut<GameStateResource>,
|
||||||
|
settings: Option<Res<crate::settings_plugin::SettingsResource>>,
|
||||||
mut changed: MessageWriter<StateChangedEvent>,
|
mut changed: MessageWriter<StateChangedEvent>,
|
||||||
mut new_game: MessageWriter<NewGameRequestEvent>,
|
mut new_game: MessageWriter<NewGameRequestEvent>,
|
||||||
mut launch_home_shown: Option<ResMut<crate::home_plugin::LaunchHomeShown>>,
|
mut launch_home_shown: Option<ResMut<crate::home_plugin::LaunchHomeShown>>,
|
||||||
@@ -640,6 +660,10 @@ fn handle_restore_prompt(
|
|||||||
let resolved = if key_continue || click_continue {
|
let resolved = if key_continue || click_continue {
|
||||||
if let Some(restored) = pending.0.take() {
|
if let Some(restored) = pending.0.take() {
|
||||||
game.0 = restored;
|
game.0 = restored;
|
||||||
|
// Patch setting that serialized with the old core default of `false`.
|
||||||
|
if let Some(s) = settings.as_ref() {
|
||||||
|
game.0.take_from_foundation = s.0.take_from_foundation;
|
||||||
|
}
|
||||||
changed.write(StateChangedEvent);
|
changed.write(StateChangedEvent);
|
||||||
}
|
}
|
||||||
for entity in &screens {
|
for entity in &screens {
|
||||||
@@ -970,7 +994,7 @@ pub fn record_replay_on_win(
|
|||||||
let win_move_index = recording.moves.len().checked_sub(1);
|
let win_move_index = recording.moves.len().checked_sub(1);
|
||||||
let replay = Replay::new(
|
let replay = Replay::new(
|
||||||
game.0.seed,
|
game.0.seed,
|
||||||
game.0.draw_mode.clone(),
|
game.0.draw_mode,
|
||||||
game.0.mode,
|
game.0.mode,
|
||||||
ev.time_seconds,
|
ev.time_seconds,
|
||||||
ev.score,
|
ev.score,
|
||||||
@@ -1079,9 +1103,7 @@ fn check_no_moves(
|
|||||||
) {
|
) {
|
||||||
// Reset the debounce flag on every state change so if something changes
|
// Reset the debounce flag on every state change so if something changes
|
||||||
// we re-evaluate on the next state change.
|
// we re-evaluate on the next state change.
|
||||||
let had_event = events.read().next().is_some();
|
let had_event = events.read().count() > 0;
|
||||||
// Drain remaining events to avoid leaking.
|
|
||||||
events.clear();
|
|
||||||
|
|
||||||
if !had_event {
|
if !had_event {
|
||||||
return;
|
return;
|
||||||
@@ -1302,7 +1324,7 @@ mod tests {
|
|||||||
|
|
||||||
/// Build a minimal headless `App` with just `GamePlugin` installed.
|
/// Build a minimal headless `App` with just `GamePlugin` installed.
|
||||||
/// Disables persistence and overrides the seed so tests are deterministic
|
/// Disables persistence and overrides the seed so tests are deterministic
|
||||||
/// and don't touch `~/.local/share/solitaire_quest/game_state.json`.
|
/// and don't touch `~/.local/share/ferrous_solitaire/game_state.json`.
|
||||||
fn test_app(seed: u64) -> App {
|
fn test_app(seed: u64) -> App {
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
app.add_plugins(MinimalPlugins).add_plugins(GamePlugin);
|
app.add_plugins(MinimalPlugins).add_plugins(GamePlugin);
|
||||||
@@ -1316,7 +1338,7 @@ mod tests {
|
|||||||
// can't leak into per-test world state and trip the
|
// can't leak into per-test world state and trip the
|
||||||
// `pending.0.is_some()` guard in `auto_save_game_state` /
|
// `pending.0.is_some()` guard in `auto_save_game_state` /
|
||||||
// `save_game_state_on_exit`. Without this clear, an
|
// `save_game_state_on_exit`. Without this clear, an
|
||||||
// unrelated `~/.local/share/solitaire_quest/game_state.json`
|
// unrelated `~/.local/share/ferrous_solitaire/game_state.json`
|
||||||
// would silently disable the auto-save path under test.
|
// would silently disable the auto-save path under test.
|
||||||
app.insert_resource(PendingRestoredGame(None));
|
app.insert_resource(PendingRestoredGame(None));
|
||||||
// Override the system-time seed with a known value.
|
// Override the system-time seed with a known value.
|
||||||
@@ -2649,7 +2671,7 @@ mod tests {
|
|||||||
// resolves as Inconclusive — the engine treats Inconclusive
|
// resolves as Inconclusive — the engine treats Inconclusive
|
||||||
// as winnable (see `choose_winnable_seed` doc), so the
|
// as winnable (see `choose_winnable_seed` doc), so the
|
||||||
// helper must return 395 when started at 394.
|
// helper must return 395 when started at 394.
|
||||||
let chosen = choose_winnable_seed(394, &DrawMode::DrawOne);
|
let chosen = choose_winnable_seed(394, DrawMode::DrawOne);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
chosen, 395,
|
chosen, 395,
|
||||||
"seed 394 is Unwinnable; the next seed (395, Inconclusive) must be accepted"
|
"seed 394 is Unwinnable; the next seed (395, Inconclusive) must be accepted"
|
||||||
|
|||||||
@@ -9,14 +9,15 @@ use bevy::prelude::*;
|
|||||||
|
|
||||||
use crate::events::HelpRequestEvent;
|
use crate::events::HelpRequestEvent;
|
||||||
use crate::font_plugin::FontResource;
|
use crate::font_plugin::FontResource;
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
use crate::hud_plugin::ANDROID_HINT_LABEL;
|
||||||
use crate::ui_modal::{
|
use crate::ui_modal::{
|
||||||
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
||||||
ScrimDismissible,
|
ModalScrim, ScrimDismissible,
|
||||||
};
|
|
||||||
use crate::ui_theme::{
|
|
||||||
BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, SPACE_2, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
|
|
||||||
TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL,
|
|
||||||
};
|
};
|
||||||
|
use crate::ui_theme::{SPACE_2, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL};
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
use crate::ui_theme::{BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, TYPE_CAPTION, VAL_SPACE_1};
|
||||||
|
|
||||||
/// Marker on the help overlay root node.
|
/// Marker on the help overlay root node.
|
||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
@@ -66,6 +67,7 @@ fn toggle_help_screen(
|
|||||||
keys: Res<ButtonInput<KeyCode>>,
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
mut requests: MessageReader<HelpRequestEvent>,
|
mut requests: MessageReader<HelpRequestEvent>,
|
||||||
screens: Query<Entity, With<HelpScreen>>,
|
screens: Query<Entity, With<HelpScreen>>,
|
||||||
|
other_modal_scrims: Query<(), (With<ModalScrim>, Without<HelpScreen>)>,
|
||||||
font_res: Option<Res<FontResource>>,
|
font_res: Option<Res<FontResource>>,
|
||||||
) {
|
) {
|
||||||
// Either F1 or a click on the HUD "Help" button (which fires
|
// Either F1 or a click on the HUD "Help" button (which fires
|
||||||
@@ -76,7 +78,7 @@ fn toggle_help_screen(
|
|||||||
}
|
}
|
||||||
if let Ok(entity) = screens.single() {
|
if let Ok(entity) = screens.single() {
|
||||||
commands.entity(entity).despawn();
|
commands.entity(entity).despawn();
|
||||||
} else {
|
} else if other_modal_scrims.is_empty() {
|
||||||
spawn_help_screen(&mut commands, font_res.as_deref());
|
spawn_help_screen(&mut commands, font_res.as_deref());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,6 +125,7 @@ fn scroll_help_panel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Each entry in the controls reference table.
|
/// Each entry in the controls reference table.
|
||||||
|
#[allow(dead_code)]
|
||||||
struct ControlRow {
|
struct ControlRow {
|
||||||
keys: &'static str,
|
keys: &'static str,
|
||||||
description: &'static str,
|
description: &'static str,
|
||||||
@@ -158,7 +161,7 @@ const CONTROL_SECTIONS: &[ControlSection] = &[
|
|||||||
ControlRow { keys: "←", description: "Undo last move" },
|
ControlRow { keys: "←", description: "Undo last move" },
|
||||||
ControlRow { keys: "||", description: "Pause / resume" },
|
ControlRow { keys: "||", description: "Pause / resume" },
|
||||||
ControlRow { keys: "?", description: "This help screen" },
|
ControlRow { keys: "?", description: "This help screen" },
|
||||||
ControlRow { keys: "→", description: "Show a hint" },
|
ControlRow { keys: ANDROID_HINT_LABEL, description: "Show a hint" },
|
||||||
ControlRow { keys: "≡", description: "Open menu (Stats, Settings, Profile...)" },
|
ControlRow { keys: "≡", description: "Open menu (Stats, Settings, Profile...)" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -243,6 +246,7 @@ fn spawn_help_screen(commands: &mut Commands, font_res: Option<&FontResource>) {
|
|||||||
..default()
|
..default()
|
||||||
};
|
};
|
||||||
let font_row = font_section.clone();
|
let font_row = font_section.clone();
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
let font_kbd = TextFont {
|
let font_kbd = TextFont {
|
||||||
font: font_handle,
|
font: font_handle,
|
||||||
font_size: TYPE_CAPTION,
|
font_size: TYPE_CAPTION,
|
||||||
@@ -345,6 +349,29 @@ fn spawn_help_screen(commands: &mut Commands, font_res: Option<&FontResource>) {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// Regression test for M-17: Android help screen showed "→" (right-arrow)
|
||||||
|
/// for the Hint button when the actual HUD button label is "!".
|
||||||
|
/// Verifies that the HUD Buttons section contains exactly one row whose
|
||||||
|
/// `keys` matches `ANDROID_HINT_LABEL`.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
#[test]
|
||||||
|
fn android_hint_row_matches_hud_label() {
|
||||||
|
use crate::hud_plugin::ANDROID_HINT_LABEL;
|
||||||
|
let hud_section = CONTROL_SECTIONS
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.title == "HUD buttons")
|
||||||
|
.expect("HUD buttons section must exist");
|
||||||
|
let hint_row = hud_section
|
||||||
|
.rows
|
||||||
|
.iter()
|
||||||
|
.find(|r| r.description == "Show a hint")
|
||||||
|
.expect("hint row must exist");
|
||||||
|
assert_eq!(
|
||||||
|
hint_row.keys, ANDROID_HINT_LABEL,
|
||||||
|
"help hint row must match the HUD button label"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn headless_app() -> App {
|
fn headless_app() -> App {
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
app.add_plugins(MinimalPlugins).add_plugins(HelpPlugin);
|
app.add_plugins(MinimalPlugins).add_plugins(HelpPlugin);
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ use crate::stats_plugin::StatsResource;
|
|||||||
use crate::ui_focus::{Disabled, FocusGroup, Focusable};
|
use crate::ui_focus::{Disabled, FocusGroup, Focusable};
|
||||||
use crate::ui_modal::{
|
use crate::ui_modal::{
|
||||||
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
||||||
|
ModalButton,
|
||||||
ScrimDismissible,
|
ScrimDismissible,
|
||||||
};
|
};
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
@@ -174,6 +175,7 @@ impl HomeMode {
|
|||||||
|
|
||||||
/// The keyboard accelerator that dispatches the same launch event,
|
/// The keyboard accelerator that dispatches the same launch event,
|
||||||
/// shown in a small chip on the card.
|
/// shown in a small chip on the card.
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
fn hotkey(self) -> &'static str {
|
fn hotkey(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
HomeMode::Classic => "N",
|
HomeMode::Classic => "N",
|
||||||
@@ -372,6 +374,7 @@ fn toggle_home_screen(
|
|||||||
daily: Option<Res<DailyChallengeResource>>,
|
daily: Option<Res<DailyChallengeResource>>,
|
||||||
font_res: Option<Res<FontResource>>,
|
font_res: Option<Res<FontResource>>,
|
||||||
screens: Query<Entity, With<HomeScreen>>,
|
screens: Query<Entity, With<HomeScreen>>,
|
||||||
|
other_modal_scrims: Query<(), (With<crate::ui_modal::ModalScrim>, Without<HomeScreen>)>,
|
||||||
diff_expanded: Res<DifficultyExpanded>,
|
diff_expanded: Res<DifficultyExpanded>,
|
||||||
) {
|
) {
|
||||||
if !keys.just_pressed(KeyCode::KeyM) {
|
if !keys.just_pressed(KeyCode::KeyM) {
|
||||||
@@ -379,7 +382,7 @@ fn toggle_home_screen(
|
|||||||
}
|
}
|
||||||
if let Ok(entity) = screens.single() {
|
if let Ok(entity) = screens.single() {
|
||||||
commands.entity(entity).despawn();
|
commands.entity(entity).despawn();
|
||||||
} else {
|
} else if other_modal_scrims.is_empty() {
|
||||||
spawn_home_screen(
|
spawn_home_screen(
|
||||||
&mut commands,
|
&mut commands,
|
||||||
build_home_context(
|
build_home_context(
|
||||||
@@ -427,7 +430,7 @@ fn build_home_context<'a>(
|
|||||||
challenge_best: stats.map_or(0, |s| s.0.challenge_best_score),
|
challenge_best: stats.map_or(0, |s| s.0.challenge_best_score),
|
||||||
daily_today,
|
daily_today,
|
||||||
draw_mode: settings
|
draw_mode: settings
|
||||||
.map(|s| s.0.draw_mode.clone())
|
.map(|s| s.0.draw_mode)
|
||||||
.unwrap_or(DrawMode::DrawOne),
|
.unwrap_or(DrawMode::DrawOne),
|
||||||
font_res,
|
font_res,
|
||||||
difficulty_expanded,
|
difficulty_expanded,
|
||||||
@@ -588,6 +591,7 @@ fn handle_home_draw_mode_buttons(
|
|||||||
one_buttons: Query<&Interaction, (With<HomeDrawOneButton>, Changed<Interaction>)>,
|
one_buttons: Query<&Interaction, (With<HomeDrawOneButton>, Changed<Interaction>)>,
|
||||||
three_buttons: Query<&Interaction, (With<HomeDrawThreeButton>, Changed<Interaction>)>,
|
three_buttons: Query<&Interaction, (With<HomeDrawThreeButton>, Changed<Interaction>)>,
|
||||||
screens: Query<Entity, With<HomeScreen>>,
|
screens: Query<Entity, With<HomeScreen>>,
|
||||||
|
other_modal_scrims: Query<(), (With<crate::ui_modal::ModalScrim>, Without<HomeScreen>)>,
|
||||||
mut settings: Option<ResMut<SettingsResource>>,
|
mut settings: Option<ResMut<SettingsResource>>,
|
||||||
storage_path: Option<Res<SettingsStoragePath>>,
|
storage_path: Option<Res<SettingsStoragePath>>,
|
||||||
mut changed: MessageWriter<SettingsChangedEvent>,
|
mut changed: MessageWriter<SettingsChangedEvent>,
|
||||||
@@ -600,6 +604,12 @@ fn handle_home_draw_mode_buttons(
|
|||||||
if screens.is_empty() {
|
if screens.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Don't respawn while another modal sits on top — the despawn queues
|
||||||
|
// immediately but executes at end of frame, so a respawn in the same
|
||||||
|
// frame would create a second concurrent ModalScrim.
|
||||||
|
if !other_modal_scrims.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let want_one = one_buttons.iter().any(|i| *i == Interaction::Pressed);
|
let want_one = one_buttons.iter().any(|i| *i == Interaction::Pressed);
|
||||||
let want_three = three_buttons.iter().any(|i| *i == Interaction::Pressed);
|
let want_three = three_buttons.iter().any(|i| *i == Interaction::Pressed);
|
||||||
if !want_one && !want_three {
|
if !want_one && !want_three {
|
||||||
@@ -657,6 +667,7 @@ fn handle_home_difficulty_toggle(
|
|||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
toggles: Query<&Interaction, (With<HomeDifficultyToggle>, Changed<Interaction>)>,
|
toggles: Query<&Interaction, (With<HomeDifficultyToggle>, Changed<Interaction>)>,
|
||||||
screens: Query<Entity, With<HomeScreen>>,
|
screens: Query<Entity, With<HomeScreen>>,
|
||||||
|
other_modal_scrims: Query<(), (With<crate::ui_modal::ModalScrim>, Without<HomeScreen>)>,
|
||||||
mut diff_expanded: ResMut<DifficultyExpanded>,
|
mut diff_expanded: ResMut<DifficultyExpanded>,
|
||||||
progress: Option<Res<ProgressResource>>,
|
progress: Option<Res<ProgressResource>>,
|
||||||
stats: Option<Res<StatsResource>>,
|
stats: Option<Res<StatsResource>>,
|
||||||
@@ -667,6 +678,9 @@ fn handle_home_difficulty_toggle(
|
|||||||
if screens.is_empty() {
|
if screens.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if !other_modal_scrims.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if !toggles.iter().any(|i| *i == Interaction::Pressed) {
|
if !toggles.iter().any(|i| *i == Interaction::Pressed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1102,7 +1116,7 @@ fn spawn_difficulty_section(parent: &mut ChildSpawnerCommands, ctx: &HomeContext
|
|||||||
let font_label = TextFont { font: font_handle.clone(), font_size: TYPE_BODY, ..default() };
|
let font_label = TextFont { font: font_handle.clone(), font_size: TYPE_BODY, ..default() };
|
||||||
let font_chip = TextFont { font: font_handle, font_size: TYPE_CAPTION, ..default() };
|
let font_chip = TextFont { font: font_handle, font_size: TYPE_CAPTION, ..default() };
|
||||||
|
|
||||||
let chevron = if ctx.difficulty_expanded { "▼" } else { "▶" };
|
let chevron = if ctx.difficulty_expanded { "v" } else { ">" };
|
||||||
|
|
||||||
// Header row — click to toggle expand/collapse.
|
// Header row — click to toggle expand/collapse.
|
||||||
parent
|
parent
|
||||||
@@ -1336,6 +1350,7 @@ fn spawn_mode_card(
|
|||||||
// bevy::ui — the click handler queries on `&Interaction`
|
// bevy::ui — the click handler queries on `&Interaction`
|
||||||
// which Button drives.
|
// which Button drives.
|
||||||
Button,
|
Button,
|
||||||
|
ModalButton(ButtonVariant::Secondary),
|
||||||
Node {
|
Node {
|
||||||
flex_direction: FlexDirection::Column,
|
flex_direction: FlexDirection::Column,
|
||||||
row_gap: VAL_SPACE_2,
|
row_gap: VAL_SPACE_2,
|
||||||
|
|||||||
@@ -13,12 +13,14 @@ use solitaire_core::game_state::{DrawMode, GameMode};
|
|||||||
use solitaire_core::pile::PileType;
|
use solitaire_core::pile::PileType;
|
||||||
|
|
||||||
use crate::auto_complete_plugin::AutoCompleteState;
|
use crate::auto_complete_plugin::AutoCompleteState;
|
||||||
|
use crate::avatar_plugin::AvatarResource;
|
||||||
|
use solitaire_data::SyncBackend;
|
||||||
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
||||||
use crate::daily_challenge_plugin::DailyChallengeResource;
|
use crate::daily_challenge_plugin::DailyChallengeResource;
|
||||||
use crate::progress_plugin::ProgressResource;
|
use crate::progress_plugin::ProgressResource;
|
||||||
use crate::settings_plugin::SettingsResource;
|
use crate::settings_plugin::SettingsResource;
|
||||||
use crate::layout::HUD_BAND_HEIGHT;
|
use crate::layout::HUD_BAND_HEIGHT;
|
||||||
use crate::safe_area::{SafeAreaAnchoredTop, SafeAreaInsets};
|
use crate::safe_area::{SafeAreaAnchoredBottom, SafeAreaAnchoredTop};
|
||||||
use crate::ui_theme::SPACE_2;
|
use crate::ui_theme::SPACE_2;
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
scaled_duration, ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI,
|
scaled_duration, ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI,
|
||||||
@@ -43,7 +45,7 @@ use crate::layout::LayoutSystem;
|
|||||||
use crate::pause_plugin::PausedResource;
|
use crate::pause_plugin::PausedResource;
|
||||||
use crate::resources::GameStateResource;
|
use crate::resources::GameStateResource;
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
use crate::resources::DragState;
|
use crate::resources::{DragState, GameInputConsumedResource};
|
||||||
use crate::selection_plugin::SelectionState;
|
use crate::selection_plugin::SelectionState;
|
||||||
use crate::time_attack_plugin::TimeAttackResource;
|
use crate::time_attack_plugin::TimeAttackResource;
|
||||||
use crate::ui_focus::{FocusGroup, Focusable};
|
use crate::ui_focus::{FocusGroup, Focusable};
|
||||||
@@ -138,6 +140,19 @@ pub struct HudColumn;
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct HudActionBar;
|
pub struct HudActionBar;
|
||||||
|
|
||||||
|
/// Marker on the text node inside each action-bar button (Android only).
|
||||||
|
/// Used by `resize_action_bar_labels` to update font size on window resize.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct ActionButtonLabel;
|
||||||
|
|
||||||
|
/// Marker on the circular profile-picture button anchored to the
|
||||||
|
/// top-right of the HUD band. Pressing it opens the Profile overlay.
|
||||||
|
/// Shows the server avatar image when loaded; falls back to the player's
|
||||||
|
/// initial on a filled disc when no image is available.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct HudAvatar;
|
||||||
|
|
||||||
/// Controls whether the in-game HUD (band, score column, action buttons) is
|
/// Controls whether the in-game HUD (band, score column, action buttons) is
|
||||||
/// visible. Toggled on Android by tapping empty board space; always `Visible`
|
/// visible. Toggled on Android by tapping empty board space; always `Visible`
|
||||||
/// on desktop. Resets to `Visible` whenever a modal opens.
|
/// on desktop. Resets to `Visible` whenever a modal opens.
|
||||||
@@ -152,10 +167,13 @@ pub enum HudVisibility {
|
|||||||
#[derive(Resource, Debug, Default)]
|
#[derive(Resource, Debug, Default)]
|
||||||
struct HudTapTracker {
|
struct HudTapTracker {
|
||||||
start_pos: Option<bevy::math::Vec2>,
|
start_pos: Option<bevy::math::Vec2>,
|
||||||
|
/// Set `true` when the finger-down hit an action button so the
|
||||||
|
/// finger-up never toggles bar visibility.
|
||||||
|
started_on_button: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
const HUD_TAP_SLOP_PX: f32 = 15.0;
|
const HUD_TAP_SLOP_PX: f32 = 25.0;
|
||||||
|
|
||||||
/// Drives the score-readout pulse: scales the [`HudScore`] text from
|
/// Drives the score-readout pulse: scales the [`HudScore`] text from
|
||||||
/// 1.0 → 1.1 → 1.0 over [`MOTION_SCORE_PULSE_SECS`] (scaled by
|
/// 1.0 → 1.1 → 1.0 over [`MOTION_SCORE_PULSE_SECS`] (scaled by
|
||||||
@@ -286,6 +304,11 @@ pub struct HelpButton;
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct HintButton;
|
pub struct HintButton;
|
||||||
|
|
||||||
|
/// Android HUD label for the Hint button — shared with the help screen's
|
||||||
|
/// controls reference so both always agree.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
pub(crate) const ANDROID_HINT_LABEL: &str = "!";
|
||||||
|
|
||||||
/// Marker on the "Modes" action button. Click toggles the [`ModesPopover`]
|
/// Marker on the "Modes" action button. Click toggles the [`ModesPopover`]
|
||||||
/// (a small dropdown panel) below the action bar. Each popover row starts
|
/// (a small dropdown panel) below the action bar. Each popover row starts
|
||||||
/// the corresponding game mode.
|
/// the corresponding game mode.
|
||||||
@@ -354,6 +377,9 @@ pub enum MenuOption {
|
|||||||
/// Mirrors `ui_theme::Z_HUD` and is duplicated here only so the hud module
|
/// Mirrors `ui_theme::Z_HUD` and is duplicated here only so the hud module
|
||||||
/// can use it as a `const` without a non-const expression in `ZIndex(...)`.
|
/// can use it as a `const` without a non-const expression in `ZIndex(...)`.
|
||||||
const Z_HUD: i32 = crate::ui_theme::Z_HUD;
|
const Z_HUD: i32 = crate::ui_theme::Z_HUD;
|
||||||
|
const Z_HUD_POPOVER_BACKDROP: i32 = crate::ui_theme::Z_HUD_POPOVER_BACKDROP;
|
||||||
|
const Z_HUD_POPOVER: i32 = crate::ui_theme::Z_HUD_POPOVER;
|
||||||
|
const Z_HUD_TOP: i32 = crate::ui_theme::Z_HUD_TOP;
|
||||||
|
|
||||||
/// Idle / hover / pressed colours shared by every action button. Aliased
|
/// Idle / hover / pressed colours shared by every action button. Aliased
|
||||||
/// to the theme tokens so the HUD picks up palette changes for free.
|
/// to the theme tokens so the HUD picks up palette changes for free.
|
||||||
@@ -395,16 +421,23 @@ impl Plugin for HudPlugin {
|
|||||||
// WindowResized is registered by table_plugin; re-register
|
// WindowResized is registered by table_plugin; re-register
|
||||||
// defensively so the HUD plugin works standalone in tests.
|
// defensively so the HUD plugin works standalone in tests.
|
||||||
.add_message::<WindowResized>()
|
.add_message::<WindowResized>()
|
||||||
.add_systems(Startup, (spawn_hud_band, spawn_hud, spawn_action_buttons))
|
.add_systems(Startup, (spawn_hud_band, spawn_hud, spawn_action_buttons, spawn_hud_avatar))
|
||||||
.add_systems(Update, update_hud.after(GameMutation))
|
.add_systems(Update, update_hud.after(GameMutation))
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
apply_hud_visibility.before(LayoutSystem::UpdateOnResize),
|
apply_hud_visibility.before(LayoutSystem::UpdateOnResize),
|
||||||
)
|
)
|
||||||
.add_systems(Update, restore_hud_on_modal)
|
.add_systems(Update, restore_hud_on_modal)
|
||||||
|
.add_systems(Update, (update_hud_avatar, handle_avatar_button))
|
||||||
.add_systems(Update, update_won_previously.after(GameMutation))
|
.add_systems(Update, update_won_previously.after(GameMutation))
|
||||||
.add_systems(Update, announce_auto_complete.after(GameMutation))
|
.add_systems(Update, announce_auto_complete.after(GameMutation))
|
||||||
.add_systems(Update, update_selection_hud)
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
update_selection_hud.run_if(
|
||||||
|
resource_exists_and_changed::<SelectionState>
|
||||||
|
.or(resource_exists_and_changed::<GameStateResource>),
|
||||||
|
),
|
||||||
|
)
|
||||||
.add_systems(Update, update_hud_typography)
|
.add_systems(Update, update_hud_typography)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
@@ -446,7 +479,12 @@ impl Plugin for HudPlugin {
|
|||||||
// Otherwise on a hover-state change (`Changed<Interaction>`),
|
// Otherwise on a hover-state change (`Changed<Interaction>`),
|
||||||
// `paint_action_buttons` would clobber the alpha back to 1.0
|
// `paint_action_buttons` would clobber the alpha back to 1.0
|
||||||
// mid-fade and produce a visible blip.
|
// mid-fade and produce a visible blip.
|
||||||
.add_systems(Last, (update_action_fade, apply_action_fade).chain());
|
;
|
||||||
|
// Desktop-only: cursor-proximity fade. On Android the bar
|
||||||
|
// visibility is toggled explicitly; cursor_position() returning
|
||||||
|
// Some(touch_pos) during a tap would otherwise fade the bar out.
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
app.add_systems(Last, (update_action_fade, apply_action_fade).chain());
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
{
|
{
|
||||||
app.init_resource::<HudTapTracker>()
|
app.init_resource::<HudTapTracker>()
|
||||||
@@ -457,36 +495,33 @@ impl Plugin for HudPlugin {
|
|||||||
.after(TouchDragSet::AfterStartDrag)
|
.after(TouchDragSet::AfterStartDrag)
|
||||||
.in_set(TouchDragSet::BeforeEndDrag),
|
.in_set(TouchDragSet::BeforeEndDrag),
|
||||||
);
|
);
|
||||||
|
app.add_systems(
|
||||||
|
Update,
|
||||||
|
resize_action_bar_labels
|
||||||
|
.run_if(resource_exists_and_changed::<crate::layout::LayoutResource>),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawns the translucent HUD band that anchors the action buttons
|
/// Spawns the invisible HUD band that reserves vertical space at the top of
|
||||||
/// and primary readouts visually. Sits behind every other HUD element
|
/// the screen so the card layout (computed by `layout::compute_layout` using
|
||||||
/// (one z-rung below `Z_HUD`) so it reads as the band's "container"
|
/// `HUD_BAND_HEIGHT`) aligns correctly below the score readouts.
|
||||||
/// without intercepting clicks from the buttons it sits under.
|
|
||||||
///
|
///
|
||||||
/// Width is full-window, height matches `layout::HUD_BAND_HEIGHT` (the
|
/// The entity carries no `BackgroundColor` — the green felt shows through.
|
||||||
/// same constant the card layout reserves at the top), so the band's
|
/// A slim grey background is handled by each content section individually
|
||||||
/// bottom edge lines up exactly with the top edge of the highest
|
/// (the bottom action bar has its own `BG_HUD_BAND` background).
|
||||||
/// playable card. The fill is `BG_HUD_BAND` — midnight purple at 0.70
|
fn spawn_hud_band(mut commands: Commands) {
|
||||||
/// alpha, so the green felt reads through subtly.
|
|
||||||
fn spawn_hud_band(insets: Option<Res<SafeAreaInsets>>, mut commands: Commands) {
|
|
||||||
const BASE_TOP: f32 = 0.0;
|
const BASE_TOP: f32 = 0.0;
|
||||||
let top_inset = insets.as_deref().copied().unwrap_or_default().top;
|
|
||||||
commands.spawn((
|
commands.spawn((
|
||||||
Node {
|
Node {
|
||||||
position_type: PositionType::Absolute,
|
position_type: PositionType::Absolute,
|
||||||
top: Val::Px(BASE_TOP + top_inset),
|
top: Val::Px(BASE_TOP),
|
||||||
left: Val::Px(0.0),
|
left: Val::Px(0.0),
|
||||||
width: Val::Percent(100.0),
|
width: Val::Percent(100.0),
|
||||||
height: Val::Px(HUD_BAND_HEIGHT),
|
height: Val::Px(HUD_BAND_HEIGHT),
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
BackgroundColor(BG_HUD_BAND),
|
|
||||||
// Sit one z-rung below the HUD content so the buttons and text
|
|
||||||
// paint on top, but above the card sprites (which are 2D-world
|
|
||||||
// entities and rendered behind UI regardless).
|
|
||||||
ZIndex(Z_HUD - 1),
|
ZIndex(Z_HUD - 1),
|
||||||
SafeAreaAnchoredTop { base_top: BASE_TOP },
|
SafeAreaAnchoredTop { base_top: BASE_TOP },
|
||||||
HudBand,
|
HudBand,
|
||||||
@@ -514,10 +549,8 @@ fn spawn_hud_band(insets: Option<Res<SafeAreaInsets>>, mut commands: Commands) {
|
|||||||
/// make Score the visual protagonist.
|
/// make Score the visual protagonist.
|
||||||
fn spawn_hud(
|
fn spawn_hud(
|
||||||
font_res: Option<Res<FontResource>>,
|
font_res: Option<Res<FontResource>>,
|
||||||
insets: Option<Res<SafeAreaInsets>>,
|
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
) {
|
) {
|
||||||
let top_inset = insets.as_deref().copied().unwrap_or_default().top;
|
|
||||||
let font_handle = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default();
|
let font_handle = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default();
|
||||||
let font_score = TextFont {
|
let font_score = TextFont {
|
||||||
font: font_handle.clone(),
|
font: font_handle.clone(),
|
||||||
@@ -557,7 +590,7 @@ fn spawn_hud(
|
|||||||
Node {
|
Node {
|
||||||
position_type: PositionType::Absolute,
|
position_type: PositionType::Absolute,
|
||||||
left: VAL_SPACE_3,
|
left: VAL_SPACE_3,
|
||||||
top: Val::Px(SPACE_2 + top_inset),
|
top: Val::Px(SPACE_2),
|
||||||
flex_direction: FlexDirection::Column,
|
flex_direction: FlexDirection::Column,
|
||||||
// Cap the column at 50% of viewport so on narrow
|
// Cap the column at 50% of viewport so on narrow
|
||||||
// (mobile) widths the inner tier rows have a bounded
|
// (mobile) widths the inner tier rows have a bounded
|
||||||
@@ -684,6 +717,133 @@ fn spawn_hud(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Spawns the circular avatar / initials button anchored to the top-right
|
||||||
|
/// of the HUD band. Initial content is seeded from whatever resources are
|
||||||
|
/// available at startup; `update_hud_avatar` replaces the children whenever
|
||||||
|
/// `AvatarResource` or `SettingsResource` later changes.
|
||||||
|
fn spawn_hud_avatar(
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
avatar: Option<Res<AvatarResource>>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
const SIZE: f32 = 32.0;
|
||||||
|
let id = commands
|
||||||
|
.spawn((
|
||||||
|
HudAvatar,
|
||||||
|
Button,
|
||||||
|
Tooltip::new("Your profile — tap to open."),
|
||||||
|
Node {
|
||||||
|
position_type: PositionType::Absolute,
|
||||||
|
top: Val::Px(SPACE_2),
|
||||||
|
right: VAL_SPACE_3,
|
||||||
|
width: Val::Px(SIZE),
|
||||||
|
height: Val::Px(SIZE),
|
||||||
|
border_radius: BorderRadius::all(Val::Px(SIZE / 2.0)),
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
justify_content: JustifyContent::Center,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(ACCENT_PRIMARY),
|
||||||
|
ZIndex(Z_HUD),
|
||||||
|
SafeAreaAnchoredTop { base_top: SPACE_2 },
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
spawn_avatar_child(
|
||||||
|
&mut commands,
|
||||||
|
id,
|
||||||
|
avatar.as_deref(),
|
||||||
|
settings.as_deref(),
|
||||||
|
font_res.as_deref(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-spawns the avatar circle content (image or initials) whenever either
|
||||||
|
/// [`AvatarResource`] or [`SettingsResource`] changes — covers both the
|
||||||
|
/// image arriving after download and the username changing after login.
|
||||||
|
fn update_hud_avatar(
|
||||||
|
avatar: Option<Res<AvatarResource>>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
q: Query<Entity, With<HudAvatar>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
let avatar_changed = avatar.as_ref().is_some_and(|r| r.is_changed());
|
||||||
|
let settings_changed = settings.as_ref().is_some_and(|r| r.is_changed());
|
||||||
|
if !avatar_changed && !settings_changed {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Ok(entity) = q.single() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
commands.entity(entity).despawn_related::<Children>();
|
||||||
|
spawn_avatar_child(
|
||||||
|
&mut commands,
|
||||||
|
entity,
|
||||||
|
avatar.as_deref(),
|
||||||
|
settings.as_deref(),
|
||||||
|
font_res.as_deref(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Populates the avatar container with either the downloaded image or an
|
||||||
|
/// initials fallback disc. Called from both the startup spawn and the
|
||||||
|
/// reactive update system so the rendering logic lives in one place.
|
||||||
|
fn spawn_avatar_child(
|
||||||
|
commands: &mut Commands,
|
||||||
|
parent: Entity,
|
||||||
|
avatar: Option<&AvatarResource>,
|
||||||
|
settings: Option<&SettingsResource>,
|
||||||
|
font_res: Option<&FontResource>,
|
||||||
|
) {
|
||||||
|
const SIZE: f32 = 32.0;
|
||||||
|
if let Some(handle) = avatar.and_then(|a| a.0.clone()) {
|
||||||
|
// Image fills the circle container; border_radius clips it to a disc.
|
||||||
|
commands.entity(parent).with_children(|b| {
|
||||||
|
b.spawn((
|
||||||
|
ImageNode::new(handle),
|
||||||
|
Node {
|
||||||
|
width: Val::Px(SIZE),
|
||||||
|
height: Val::Px(SIZE),
|
||||||
|
border_radius: BorderRadius::all(Val::Px(SIZE / 2.0)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
));
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
let initial = settings
|
||||||
|
.and_then(|s| match &s.0.sync_backend {
|
||||||
|
SyncBackend::SolitaireServer { username, .. } => username.chars().next(),
|
||||||
|
SyncBackend::Local => None,
|
||||||
|
})
|
||||||
|
.and_then(|c| c.to_uppercase().next())
|
||||||
|
.unwrap_or('?');
|
||||||
|
commands.entity(parent).with_children(|b| {
|
||||||
|
b.spawn((
|
||||||
|
Text::new(initial.to_string()),
|
||||||
|
TextFont {
|
||||||
|
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||||
|
font_size: 14.0,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
TextColor(TEXT_PRIMARY),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens the Profile overlay when the avatar button is pressed.
|
||||||
|
fn handle_avatar_button(
|
||||||
|
interaction_query: Query<&Interaction, (With<HudAvatar>, Changed<Interaction>)>,
|
||||||
|
mut toggle_profile: MessageWriter<ToggleProfileRequestEvent>,
|
||||||
|
) {
|
||||||
|
for interaction in &interaction_query {
|
||||||
|
if *interaction == Interaction::Pressed {
|
||||||
|
toggle_profile.write(ToggleProfileRequestEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Spawns the action button bar anchored to the top-right of the window.
|
/// Spawns the action button bar anchored to the top-right of the window.
|
||||||
/// Each child is a clickable button mirroring a keyboard accelerator —
|
/// Each child is a clickable button mirroring a keyboard accelerator —
|
||||||
/// per the UI-first principle (CLAUDE.md / ARCHITECTURE.md §1) the buttons
|
/// per the UI-first principle (CLAUDE.md / ARCHITECTURE.md §1) the buttons
|
||||||
@@ -694,26 +854,34 @@ fn spawn_hud(
|
|||||||
/// on its own visual edge.
|
/// on its own visual edge.
|
||||||
fn spawn_action_buttons(
|
fn spawn_action_buttons(
|
||||||
font_res: Option<Res<FontResource>>,
|
font_res: Option<Res<FontResource>>,
|
||||||
insets: Option<Res<SafeAreaInsets>>,
|
windows: Query<&Window>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
) {
|
) {
|
||||||
let top_inset = insets.as_deref().copied().unwrap_or_default().top;
|
// On Android the glyph labels must scale with the viewport so they remain
|
||||||
|
// legible on any screen density. Use the window width at startup; the
|
||||||
|
// resize_action_bar_labels system keeps this current on window changes.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
let action_font_size = {
|
||||||
|
let w = windows.iter().next().map_or(900.0, |win| win.width());
|
||||||
|
action_bar_font_size(w)
|
||||||
|
};
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
let action_font_size = TYPE_BODY;
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
let _windows = windows;
|
||||||
|
|
||||||
let font = TextFont {
|
let font = TextFont {
|
||||||
font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(),
|
font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(),
|
||||||
font_size: TYPE_BODY,
|
font_size: action_font_size,
|
||||||
..default()
|
..default()
|
||||||
};
|
};
|
||||||
|
|
||||||
// On Android, 7 text-labelled buttons at 48 dp each wrap to two rows on
|
// On Android, compact Unicode symbols fit all 7 buttons in one row.
|
||||||
// a 411 dp phone. Use compact Unicode symbols and tighter gaps so all 7
|
// On desktop, keep the descriptive text labels.
|
||||||
// fit in a single row (7×44 + 6×4 = 332 dp, well within a 90%-wide band
|
|
||||||
// of 370 dp). On desktop, keep the descriptive text labels.
|
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
let (max_width, col_gap, row_gap_val) =
|
let col_gap = Val::Px(4.0);
|
||||||
(Val::Percent(90.0), Val::Px(4.0), Val::Px(4.0));
|
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
let (max_width, col_gap, row_gap_val) =
|
let col_gap = VAL_SPACE_2;
|
||||||
(Val::Percent(65.0), VAL_SPACE_2, VAL_SPACE_2);
|
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
let labels = (
|
let labels = (
|
||||||
@@ -721,39 +889,48 @@ fn spawn_action_buttons(
|
|||||||
/* undo */ "\u{2190}", // ← leftwards arrow (Arrows block, confirmed FiraMono)
|
/* undo */ "\u{2190}", // ← leftwards arrow (Arrows block, confirmed FiraMono)
|
||||||
/* pause */ "||", // || ASCII double-pipe — ‖ (U+2016) absent from FiraMono
|
/* pause */ "||", // || ASCII double-pipe — ‖ (U+2016) absent from FiraMono
|
||||||
/* help */ "?",
|
/* help */ "?",
|
||||||
/* hint */ "\u{2192}", // → rightwards arrow (Arrows block, confirmed FiraMono)
|
/* hint */ ANDROID_HINT_LABEL,
|
||||||
/* modes */ "\u{2193}", // ↓ downwards arrow (Arrows block, confirmed FiraMono)
|
/* modes */ "M", // plain ASCII — U+21BB and U+21C4 both render as tofu on FiraMono
|
||||||
// replaces ▾ (U+25BE) which is absent from FiraMono
|
|
||||||
/* new */ "+",
|
/* new */ "+",
|
||||||
);
|
);
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
let labels = (
|
let labels = (
|
||||||
"Menu \u{25BE}",
|
"Menu \u{2193}",
|
||||||
"Undo",
|
"Undo",
|
||||||
"Pause",
|
"Pause",
|
||||||
"Help",
|
"Help",
|
||||||
"Hint",
|
"Hint",
|
||||||
"Modes \u{25BE}",
|
"Modes \u{2193}",
|
||||||
"New Game",
|
"New Game",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Bottom bar: full-width, centered, sits above the gesture-navigation zone.
|
||||||
|
// `SafeAreaAnchoredBottom` applies the correct logical-pixel inset once
|
||||||
|
// Android reports it (frames 1-3); initial value is 0.0.
|
||||||
commands
|
commands
|
||||||
.spawn((
|
.spawn((
|
||||||
Node {
|
Node {
|
||||||
position_type: PositionType::Absolute,
|
position_type: PositionType::Absolute,
|
||||||
right: VAL_SPACE_3,
|
bottom: Val::Px(0.0),
|
||||||
top: Val::Px(SPACE_2 + top_inset),
|
left: Val::Px(0.0),
|
||||||
|
width: Val::Percent(100.0),
|
||||||
flex_direction: FlexDirection::Row,
|
flex_direction: FlexDirection::Row,
|
||||||
max_width,
|
|
||||||
flex_wrap: FlexWrap::Wrap,
|
flex_wrap: FlexWrap::Wrap,
|
||||||
justify_content: JustifyContent::FlexEnd,
|
justify_content: JustifyContent::Center,
|
||||||
column_gap: col_gap,
|
column_gap: col_gap,
|
||||||
row_gap: row_gap_val,
|
row_gap: VAL_SPACE_2,
|
||||||
align_items: AlignItems::Center,
|
align_items: AlignItems::Center,
|
||||||
|
padding: UiRect {
|
||||||
|
left: VAL_SPACE_3,
|
||||||
|
right: VAL_SPACE_3,
|
||||||
|
top: VAL_SPACE_2,
|
||||||
|
bottom: VAL_SPACE_2,
|
||||||
|
},
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
|
BackgroundColor(BG_HUD_BAND),
|
||||||
ZIndex(Z_HUD),
|
ZIndex(Z_HUD),
|
||||||
SafeAreaAnchoredTop { base_top: SPACE_2 },
|
SafeAreaAnchoredBottom { base_bottom: 0.0 },
|
||||||
HudActionBar,
|
HudActionBar,
|
||||||
))
|
))
|
||||||
.with_children(|row| {
|
.with_children(|row| {
|
||||||
@@ -799,8 +976,7 @@ fn spawn_action_button<M: Component>(
|
|||||||
// visibly clutter the narrow-viewport action row. Force the hint
|
// visibly clutter the narrow-viewport action row. Force the hint
|
||||||
// off on Android; the chevrons on Menu/Modes remain because they
|
// off on Android; the chevrons on Menu/Modes remain because they
|
||||||
// indicate dropdown behaviour and still apply on touch.
|
// indicate dropdown behaviour and still apply on touch.
|
||||||
#[cfg(target_os = "android")]
|
let hotkey = if cfg!(target_os = "android") { None } else { hotkey };
|
||||||
let hotkey: Option<&'static str> = None;
|
|
||||||
|
|
||||||
let hotkey_font = TextFont {
|
let hotkey_font = TextFont {
|
||||||
font: font.font.clone(),
|
font: font.font.clone(),
|
||||||
@@ -813,7 +989,7 @@ fn spawn_action_button<M: Component>(
|
|||||||
// centred with room to breathe. On desktop, keep the comfortable 48 dp
|
// centred with room to breathe. On desktop, keep the comfortable 48 dp
|
||||||
// floor and 8 dp side padding.
|
// floor and 8 dp side padding.
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
let (pad, min_w, min_h) = (UiRect::axes(Val::Px(4.0), Val::Px(4.0)), Val::Px(44.0), Val::Px(44.0));
|
let (pad, min_w, min_h) = (UiRect::axes(Val::Px(4.0), Val::Px(4.0)), Val::Px(52.0), Val::Px(44.0));
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
let (pad, min_w, min_h) = (UiRect::axes(VAL_SPACE_2, VAL_SPACE_2), Val::Px(48.0), Val::Px(48.0));
|
let (pad, min_w, min_h) = (UiRect::axes(VAL_SPACE_2, VAL_SPACE_2), Val::Px(48.0), Val::Px(48.0));
|
||||||
|
|
||||||
@@ -841,6 +1017,9 @@ fn spawn_action_button<M: Component>(
|
|||||||
HighContrastBorder::with_default(BORDER_SUBTLE),
|
HighContrastBorder::with_default(BORDER_SUBTLE),
|
||||||
))
|
))
|
||||||
.with_children(|b| {
|
.with_children(|b| {
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
b.spawn((ActionButtonLabel, Text::new(label), font.clone(), TextColor(text_color)));
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
b.spawn((Text::new(label), font.clone(), TextColor(text_color)));
|
b.spawn((Text::new(label), font.clone(), TextColor(text_color)));
|
||||||
if let Some(key) = hotkey {
|
if let Some(key) = hotkey {
|
||||||
// Hotkey hint rendered as a dim caption next to the label —
|
// Hotkey hint rendered as a dim caption next to the label —
|
||||||
@@ -1013,6 +1192,14 @@ fn spawn_modes_popover(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Popover opens upward from just above the bottom action bar.
|
||||||
|
// Use a platform-aware offset that clears the bar height + safe-area
|
||||||
|
// gesture zone on Android, and the flat bar height on desktop.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
let popover_bottom = Val::Px(200.0);
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
let popover_bottom = Val::Px(80.0);
|
||||||
|
|
||||||
commands
|
commands
|
||||||
.spawn((
|
.spawn((
|
||||||
ModesPopover,
|
ModesPopover,
|
||||||
@@ -1020,7 +1207,7 @@ fn spawn_modes_popover(
|
|||||||
Node {
|
Node {
|
||||||
position_type: PositionType::Absolute,
|
position_type: PositionType::Absolute,
|
||||||
right: VAL_SPACE_3,
|
right: VAL_SPACE_3,
|
||||||
top: Val::Px(50.0),
|
bottom: popover_bottom,
|
||||||
flex_direction: FlexDirection::Column,
|
flex_direction: FlexDirection::Column,
|
||||||
row_gap: VAL_SPACE_1,
|
row_gap: VAL_SPACE_1,
|
||||||
padding: UiRect::all(VAL_SPACE_2),
|
padding: UiRect::all(VAL_SPACE_2),
|
||||||
@@ -1028,7 +1215,7 @@ fn spawn_modes_popover(
|
|||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
BackgroundColor(BG_ELEVATED),
|
BackgroundColor(BG_ELEVATED),
|
||||||
ZIndex(Z_HUD + 5),
|
ZIndex(Z_HUD_POPOVER),
|
||||||
))
|
))
|
||||||
.with_children(|panel| {
|
.with_children(|panel| {
|
||||||
for (option, label, tooltip) in rows {
|
for (option, label, tooltip) in rows {
|
||||||
@@ -1055,8 +1242,8 @@ fn spawn_modes_popover(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fullscreen transparent backdrop at Z_HUD+4 (below the popover at
|
// Fullscreen transparent backdrop at Z_HUD_POPOVER_BACKDROP (below the
|
||||||
// Z_HUD+5) so tapping outside the panel light-dismisses it.
|
// popover at Z_HUD_POPOVER) so tapping outside light-dismisses it.
|
||||||
commands.spawn((
|
commands.spawn((
|
||||||
ModesPopoverBackdrop,
|
ModesPopoverBackdrop,
|
||||||
Button,
|
Button,
|
||||||
@@ -1069,7 +1256,7 @@ fn spawn_modes_popover(
|
|||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
BackgroundColor(Color::NONE),
|
BackgroundColor(Color::NONE),
|
||||||
ZIndex(Z_HUD + 4),
|
ZIndex(Z_HUD_POPOVER_BACKDROP),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1205,6 +1392,12 @@ fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>)
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Same upward-opening placement as ModesPopover.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
let popover_bottom = Val::Px(200.0);
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
let popover_bottom = Val::Px(80.0);
|
||||||
|
|
||||||
commands
|
commands
|
||||||
.spawn((
|
.spawn((
|
||||||
MenuPopover,
|
MenuPopover,
|
||||||
@@ -1212,7 +1405,7 @@ fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>)
|
|||||||
Node {
|
Node {
|
||||||
position_type: PositionType::Absolute,
|
position_type: PositionType::Absolute,
|
||||||
right: VAL_SPACE_3,
|
right: VAL_SPACE_3,
|
||||||
top: Val::Px(50.0),
|
bottom: popover_bottom,
|
||||||
flex_direction: FlexDirection::Column,
|
flex_direction: FlexDirection::Column,
|
||||||
row_gap: VAL_SPACE_1,
|
row_gap: VAL_SPACE_1,
|
||||||
padding: UiRect::all(VAL_SPACE_2),
|
padding: UiRect::all(VAL_SPACE_2),
|
||||||
@@ -1220,7 +1413,7 @@ fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>)
|
|||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
BackgroundColor(BG_ELEVATED),
|
BackgroundColor(BG_ELEVATED),
|
||||||
ZIndex(Z_HUD + 5),
|
ZIndex(Z_HUD_POPOVER),
|
||||||
))
|
))
|
||||||
.with_children(|panel| {
|
.with_children(|panel| {
|
||||||
for (option, label, tooltip) in rows {
|
for (option, label, tooltip) in rows {
|
||||||
@@ -1261,7 +1454,7 @@ fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>)
|
|||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
BackgroundColor(Color::NONE),
|
BackgroundColor(Color::NONE),
|
||||||
ZIndex(Z_HUD + 4),
|
ZIndex(Z_HUD_POPOVER_BACKDROP),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1424,9 +1617,9 @@ impl Default for HudActionFade {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cursor-y threshold (in window pixels, 0 = top) below which the bar
|
/// How many pixels from the bottom edge the cursor must be to reveal the bar.
|
||||||
/// stays visible. Set slightly above `HUD_BAND_HEIGHT` so the bar fades
|
/// Set slightly taller than `HUD_BAND_HEIGHT` so the bar fades in as the
|
||||||
/// in as the cursor approaches, not only once it crosses into the band.
|
/// cursor approaches, not only when it crosses into the band itself.
|
||||||
const ACTION_FADE_REVEAL_PX: f32 = HUD_BAND_HEIGHT + 32.0;
|
const ACTION_FADE_REVEAL_PX: f32 = HUD_BAND_HEIGHT + 32.0;
|
||||||
|
|
||||||
/// Lerp rate for fading (per second). 6.0 ≈ 167 ms for a full
|
/// Lerp rate for fading (per second). 6.0 ≈ 167 ms for a full
|
||||||
@@ -1435,7 +1628,7 @@ const ACTION_FADE_REVEAL_PX: f32 = HUD_BAND_HEIGHT + 32.0;
|
|||||||
const ACTION_FADE_RATE_PER_SEC: f32 = 6.0;
|
const ACTION_FADE_RATE_PER_SEC: f32 = 6.0;
|
||||||
|
|
||||||
/// Updates the fade state from cursor position. Sets `target = 1.0` if
|
/// Updates the fade state from cursor position. Sets `target = 1.0` if
|
||||||
/// the cursor is in the reveal zone (top of window) or off-screen
|
/// the cursor is in the reveal zone (bottom of window) or off-screen
|
||||||
/// (player is using keyboard); `0.0` otherwise. Lerps `alpha` toward
|
/// (player is using keyboard); `0.0` otherwise. Lerps `alpha` toward
|
||||||
/// `target` at a fixed rate so the visual transition is smooth across
|
/// `target` at a fixed rate so the visual transition is smooth across
|
||||||
/// variable framerates.
|
/// variable framerates.
|
||||||
@@ -1447,8 +1640,9 @@ fn update_action_fade(
|
|||||||
let Ok(window) = windows.single() else {
|
let Ok(window) = windows.single() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
let height = window.resolution.height();
|
||||||
fade.target = match window.cursor_position() {
|
fade.target = match window.cursor_position() {
|
||||||
Some(pos) if pos.y <= ACTION_FADE_REVEAL_PX => 1.0,
|
Some(pos) if pos.y >= height - ACTION_FADE_REVEAL_PX => 1.0,
|
||||||
Some(_) => 0.0,
|
Some(_) => 0.0,
|
||||||
// Off-window cursor: assume keyboard navigation and keep the
|
// Off-window cursor: assume keyboard navigation and keep the
|
||||||
// bar visible so Tab cycling doesn't lead to invisible focus.
|
// bar visible so Tab cycling doesn't lead to invisible focus.
|
||||||
@@ -1589,6 +1783,11 @@ fn detect_score_change(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let reduce_motion = settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode);
|
||||||
|
if reduce_motion {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let speed = settings
|
let speed = settings
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|s| s.0.animation_speed)
|
.map(|s| s.0.animation_speed)
|
||||||
@@ -1634,7 +1833,7 @@ fn detect_score_change(
|
|||||||
top: Val::Px(0.0),
|
top: Val::Px(0.0),
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
ZIndex(Z_HUD + 10),
|
ZIndex(Z_HUD_TOP),
|
||||||
Text::new(format!("+{delta}")),
|
Text::new(format!("+{delta}")),
|
||||||
font,
|
font,
|
||||||
TextColor(ACCENT_PRIMARY),
|
TextColor(ACCENT_PRIMARY),
|
||||||
@@ -1762,6 +1961,9 @@ fn start_streak_flourish(
|
|||||||
let Some(latest) = events.read().last() else {
|
let Some(latest) = events.read().last() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
if settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let speed = settings
|
let speed = settings
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|s| s.0.animation_speed)
|
.map(|s| s.0.animation_speed)
|
||||||
@@ -2281,15 +2483,9 @@ fn update_hud_typography(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::type_complexity)]
|
|
||||||
fn apply_hud_visibility(
|
fn apply_hud_visibility(
|
||||||
hud_vis: Res<HudVisibility>,
|
hud_vis: Res<HudVisibility>,
|
||||||
mut nodes: Query<
|
mut action_bar: Query<&mut Visibility, With<HudActionBar>>,
|
||||||
&mut Visibility,
|
|
||||||
Or<(With<HudBand>, With<HudColumn>, With<HudActionBar>)>,
|
|
||||||
>,
|
|
||||||
window_entities: Query<(Entity, &Window)>,
|
|
||||||
mut resize_events: MessageWriter<WindowResized>,
|
|
||||||
) {
|
) {
|
||||||
if !hud_vis.is_changed() {
|
if !hud_vis.is_changed() {
|
||||||
return;
|
return;
|
||||||
@@ -2299,16 +2495,11 @@ fn apply_hud_visibility(
|
|||||||
} else {
|
} else {
|
||||||
Visibility::Hidden
|
Visibility::Hidden
|
||||||
};
|
};
|
||||||
for mut node_vis in &mut nodes {
|
for mut vis in &mut action_bar {
|
||||||
*node_vis = v;
|
*vis = v;
|
||||||
}
|
|
||||||
if let Some((entity, window)) = window_entities.iter().next() {
|
|
||||||
resize_events.write(WindowResized {
|
|
||||||
window: entity,
|
|
||||||
width: window.resolution.width(),
|
|
||||||
height: window.resolution.height(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
// The bottom action bar is a pure overlay — it does not claim any
|
||||||
|
// space in the card layout, so no WindowResized event is needed.
|
||||||
}
|
}
|
||||||
|
|
||||||
fn restore_hud_on_modal(
|
fn restore_hud_on_modal(
|
||||||
@@ -2320,6 +2511,32 @@ fn restore_hud_on_modal(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the action-bar glyph font size for a given logical window width.
|
||||||
|
/// Scales linearly so glyphs remain legible at any phone density.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
fn action_bar_font_size(window_width: f32) -> f32 {
|
||||||
|
// ~1/40 of the window width gives ~22 px on a 900 logical-px phone.
|
||||||
|
// Clamped so it never goes too tiny on narrow viewports or too large
|
||||||
|
// on landscape tablets.
|
||||||
|
(window_width / 40.0).clamp(16.0, 30.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resizes the glyph text inside every [`ActionButtonLabel`] to match the
|
||||||
|
/// current viewport width whenever [`LayoutResource`] changes (orientation
|
||||||
|
/// change or window resize).
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
fn resize_action_bar_labels(
|
||||||
|
layout: Res<crate::layout::LayoutResource>,
|
||||||
|
windows: Query<&Window>,
|
||||||
|
mut labels: Query<&mut TextFont, With<ActionButtonLabel>>,
|
||||||
|
) {
|
||||||
|
let w = windows.iter().next().map_or(layout.0.card_size.x * 7.25, |win| win.width());
|
||||||
|
let new_size = action_bar_font_size(w);
|
||||||
|
for mut font in &mut labels {
|
||||||
|
font.font_size = new_size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
fn toggle_hud_on_tap(
|
fn toggle_hud_on_tap(
|
||||||
mut touch_events: MessageReader<bevy::input::touch::TouchInput>,
|
mut touch_events: MessageReader<bevy::input::touch::TouchInput>,
|
||||||
@@ -2328,29 +2545,56 @@ fn toggle_hud_on_tap(
|
|||||||
paused: Option<Res<PausedResource>>,
|
paused: Option<Res<PausedResource>>,
|
||||||
mut tracker: ResMut<HudTapTracker>,
|
mut tracker: ResMut<HudTapTracker>,
|
||||||
mut hud_vis: ResMut<HudVisibility>,
|
mut hud_vis: ResMut<HudVisibility>,
|
||||||
|
buttons: Query<&Interaction, With<ActionButton>>,
|
||||||
|
mut game_consumed: ResMut<GameInputConsumedResource>,
|
||||||
) {
|
) {
|
||||||
use bevy::input::touch::TouchPhase;
|
use bevy::input::touch::TouchPhase;
|
||||||
if !scrims.is_empty() || paused.is_some_and(|p| p.0) {
|
if !scrims.is_empty() || paused.is_some_and(|p| p.0) {
|
||||||
|
// Drain buffered events so they don't replay in the frame after
|
||||||
|
// the scrim despawns, which would trigger a spurious visibility
|
||||||
|
// toggle as the resume/close button tap's Started+Ended pair
|
||||||
|
// replays in the now-scrim-free frame.
|
||||||
|
for _ in touch_events.read() {}
|
||||||
tracker.start_pos = None;
|
tracker.start_pos = None;
|
||||||
|
tracker.started_on_button = false;
|
||||||
|
game_consumed.0 = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for event in touch_events.read() {
|
for event in touch_events.read() {
|
||||||
match event.phase {
|
match event.phase {
|
||||||
TouchPhase::Started => {
|
TouchPhase::Started => {
|
||||||
tracker.start_pos = Some(event.position);
|
tracker.start_pos = Some(event.position);
|
||||||
|
// Record whether the finger-down landed on a button so
|
||||||
|
// the finger-up doesn't double-fire (toggle bar + press
|
||||||
|
// button at the same time).
|
||||||
|
tracker.started_on_button =
|
||||||
|
buttons.iter().any(|i| *i != Interaction::None);
|
||||||
}
|
}
|
||||||
TouchPhase::Ended if drag.is_idle() => {
|
TouchPhase::Ended if drag.is_idle() => {
|
||||||
|
// Also treat taps where game logic consumed the touch (e.g.
|
||||||
|
// drawing from stock) as "on button" so they don't toggle
|
||||||
|
// the HUD. The flag is set on TouchPhase::Started by the
|
||||||
|
// input system that consumed the tap and must be cleared here
|
||||||
|
// regardless of whether we toggle.
|
||||||
|
let on_button = tracker.started_on_button || game_consumed.0;
|
||||||
|
game_consumed.0 = false;
|
||||||
if let Some(start) = tracker.start_pos.take() {
|
if let Some(start) = tracker.start_pos.take() {
|
||||||
if (event.position - start).length() < HUD_TAP_SLOP_PX {
|
if !on_button && (event.position - start).length() < HUD_TAP_SLOP_PX {
|
||||||
*hud_vis = match *hud_vis {
|
*hud_vis = match *hud_vis {
|
||||||
HudVisibility::Visible => HudVisibility::Hidden,
|
HudVisibility::Visible => HudVisibility::Hidden,
|
||||||
HudVisibility::Hidden => HudVisibility::Visible,
|
HudVisibility::Hidden => HudVisibility::Visible,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
tracker.started_on_button = false;
|
||||||
}
|
}
|
||||||
TouchPhase::Canceled | TouchPhase::Moved => {
|
// Moved: don't clear start_pos — Android fires Moved for normal
|
||||||
|
// tap jitter, and the distance check at Ended already rejects
|
||||||
|
// real drags. Clearing here would silently swallow tap toggles.
|
||||||
|
TouchPhase::Canceled => {
|
||||||
tracker.start_pos = None;
|
tracker.start_pos = None;
|
||||||
|
tracker.started_on_button = false;
|
||||||
|
game_consumed.0 = false;
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
@@ -2838,6 +3082,35 @@ mod tests {
|
|||||||
assert!((streak_flourish_scale(0.5, 0.0) - 1.0).abs() < 1e-5);
|
assert!((streak_flourish_scale(0.5, 0.0) - 1.0).abs() < 1e-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Reduce-motion gates — ScorePulse, ScoreFloater, StreakFlourish
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Under `Settings::reduce_motion_mode`, a score bump must NOT spawn
|
||||||
|
/// a `ScorePulse` on the readout or a `ScoreFloater` on the stage.
|
||||||
|
#[test]
|
||||||
|
fn score_change_skips_pulse_and_floater_under_reduce_motion() {
|
||||||
|
use solitaire_data::Settings;
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.insert_resource(SettingsResource(Settings {
|
||||||
|
reduce_motion_mode: true,
|
||||||
|
..Settings::default()
|
||||||
|
}));
|
||||||
|
// +100 would normally create both a ScorePulse and a ScoreFloater.
|
||||||
|
app.world_mut().resource_mut::<GameStateResource>().0.score = 100;
|
||||||
|
app.update();
|
||||||
|
assert_eq!(
|
||||||
|
count_with::<ScorePulse>(&mut app),
|
||||||
|
0,
|
||||||
|
"ScorePulse must not spawn under reduce-motion"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
count_with::<ScoreFloater>(&mut app),
|
||||||
|
0,
|
||||||
|
"ScoreFloater must not spawn under reduce-motion"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Phase 2: keyboard focus ring — HUD action bar
|
// Phase 2: keyboard focus ring — HUD action bar
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|||||||
@@ -47,7 +47,8 @@ use crate::game_plugin::{ConfirmNewGameScreen, GameMutation, RestorePromptScreen
|
|||||||
use crate::pause_plugin::PausedResource;
|
use crate::pause_plugin::PausedResource;
|
||||||
use crate::progress_plugin::ProgressResource;
|
use crate::progress_plugin::ProgressResource;
|
||||||
use crate::layout::{Layout, LayoutResource};
|
use crate::layout::{Layout, LayoutResource};
|
||||||
use crate::resources::{DragState, GameStateResource, HintCycleIndex};
|
use crate::replay_playback::ReplayPlaybackState;
|
||||||
|
use crate::resources::{DragState, GameInputConsumedResource, GameStateResource, HintCycleIndex};
|
||||||
use crate::selection_plugin::SelectionState;
|
use crate::selection_plugin::SelectionState;
|
||||||
use crate::time_attack_plugin::TimeAttackResource;
|
use crate::time_attack_plugin::TimeAttackResource;
|
||||||
|
|
||||||
@@ -95,6 +96,7 @@ impl Plugin for InputPlugin {
|
|||||||
app.init_resource::<HintCycleIndex>()
|
app.init_resource::<HintCycleIndex>()
|
||||||
.init_resource::<HintSolverConfig>()
|
.init_resource::<HintSolverConfig>()
|
||||||
.init_resource::<crate::pending_hint::PendingHintTask>()
|
.init_resource::<crate::pending_hint::PendingHintTask>()
|
||||||
|
.init_resource::<GameInputConsumedResource>()
|
||||||
.add_message::<StartZenRequestEvent>()
|
.add_message::<StartZenRequestEvent>()
|
||||||
.add_message::<InfoToastEvent>()
|
.add_message::<InfoToastEvent>()
|
||||||
.add_message::<ForfeitRequestEvent>()
|
.add_message::<ForfeitRequestEvent>()
|
||||||
@@ -174,11 +176,20 @@ fn handle_keyboard_core(
|
|||||||
mut zen_requests: MessageReader<StartZenRequestEvent>,
|
mut zen_requests: MessageReader<StartZenRequestEvent>,
|
||||||
confirm_screens: Query<(), With<ConfirmNewGameScreen>>,
|
confirm_screens: Query<(), With<ConfirmNewGameScreen>>,
|
||||||
restore_prompts: Query<(), With<RestorePromptScreen>>,
|
restore_prompts: Query<(), With<RestorePromptScreen>>,
|
||||||
|
replay_state: Option<Res<ReplayPlaybackState>>,
|
||||||
) {
|
) {
|
||||||
if paused.is_some_and(|p| p.0) {
|
if paused.is_some_and(|p| p.0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// During replay playback (Playing or Completed) all game-input shortcuts
|
||||||
|
// are suppressed. The replay overlay owns Space (pause/resume) and the
|
||||||
|
// arrow keys (step). Letting game input through would mutate
|
||||||
|
// `GameStateResource` and corrupt replay determinism.
|
||||||
|
if replay_state.is_some_and(|r| !matches!(*r, ReplayPlaybackState::Inactive)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if keys.just_pressed(KeyCode::KeyU) {
|
if keys.just_pressed(KeyCode::KeyU) {
|
||||||
ev.undo.write(UndoRequestEvent);
|
ev.undo.write(UndoRequestEvent);
|
||||||
}
|
}
|
||||||
@@ -501,6 +512,7 @@ fn handle_touch_stock_tap(
|
|||||||
layout: Option<Res<LayoutResource>>,
|
layout: Option<Res<LayoutResource>>,
|
||||||
drag: Res<DragState>,
|
drag: Res<DragState>,
|
||||||
mut draw: MessageWriter<DrawRequestEvent>,
|
mut draw: MessageWriter<DrawRequestEvent>,
|
||||||
|
mut game_consumed: ResMut<GameInputConsumedResource>,
|
||||||
) {
|
) {
|
||||||
if paused.is_some_and(|p| p.0) {
|
if paused.is_some_and(|p| p.0) {
|
||||||
return;
|
return;
|
||||||
@@ -522,6 +534,7 @@ fn handle_touch_stock_tap(
|
|||||||
};
|
};
|
||||||
if point_in_rect(world, stock_pos, layout.0.card_size) {
|
if point_in_rect(world, stock_pos, layout.0.card_size) {
|
||||||
draw.write(DrawRequestEvent);
|
draw.write(DrawRequestEvent);
|
||||||
|
game_consumed.0 = true;
|
||||||
break; // one draw per tap frame
|
break; // one draw per tap frame
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -717,13 +730,12 @@ fn end_drag(
|
|||||||
let ok = match &target {
|
let ok = match &target {
|
||||||
PileType::Foundation(_) => {
|
PileType::Foundation(_) => {
|
||||||
count == 1
|
count == 1
|
||||||
&& can_place_on_foundation(
|
&& game.0.piles.get(&target)
|
||||||
&bottom_card,
|
.is_some_and(|p| can_place_on_foundation(&bottom_card, p))
|
||||||
&game.0.piles[&target],
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
PileType::Tableau(_) => {
|
PileType::Tableau(_) => {
|
||||||
can_place_on_tableau(&bottom_card, &game.0.piles[&target])
|
game.0.piles.get(&target)
|
||||||
|
.is_some_and(|p| can_place_on_tableau(&bottom_card, p))
|
||||||
}
|
}
|
||||||
_ => false,
|
_ => false,
|
||||||
};
|
};
|
||||||
@@ -941,10 +953,10 @@ fn touch_end_drag(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Uncommitted tap — cancel cleanly.
|
// Uncommitted tap — cancel cleanly. No StateChangedEvent: nothing
|
||||||
|
// changed. The mouse path (end_drag) follows the same convention.
|
||||||
if !drag.committed {
|
if !drag.committed {
|
||||||
drag.clear();
|
drag.clear();
|
||||||
changed.write(StateChangedEvent);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -972,10 +984,12 @@ fn touch_end_drag(
|
|||||||
let ok = match &target {
|
let ok = match &target {
|
||||||
PileType::Foundation(_) => {
|
PileType::Foundation(_) => {
|
||||||
count == 1
|
count == 1
|
||||||
&& can_place_on_foundation(&bottom_card, &game.0.piles[&target])
|
&& game.0.piles.get(&target)
|
||||||
|
.is_some_and(|p| can_place_on_foundation(&bottom_card, p))
|
||||||
}
|
}
|
||||||
PileType::Tableau(_) => {
|
PileType::Tableau(_) => {
|
||||||
can_place_on_tableau(&bottom_card, &game.0.piles[&target])
|
game.0.piles.get(&target)
|
||||||
|
.is_some_and(|p| can_place_on_tableau(&bottom_card, p))
|
||||||
}
|
}
|
||||||
_ => false,
|
_ => false,
|
||||||
};
|
};
|
||||||
@@ -1161,7 +1175,9 @@ fn find_draggable_at(
|
|||||||
(i, pile_cards.cards.len())
|
(i, pile_cards.cards.len())
|
||||||
} else {
|
} else {
|
||||||
if i != pile_cards.cards.len() - 1 {
|
if i != pile_cards.cards.len() - 1 {
|
||||||
return None;
|
// Non-top card on a non-tableau pile — not draggable; skip
|
||||||
|
// this pile and continue searching remaining piles.
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
(i, i + 1)
|
(i, i + 1)
|
||||||
};
|
};
|
||||||
@@ -1785,9 +1801,8 @@ mod tests {
|
|||||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
// Tableau 6 has 7 cards.
|
// Tableau 6 has 7 cards.
|
||||||
let (_, size) = pile_drop_rect(&PileType::Tableau(6), &layout, &game);
|
let (_, size) = pile_drop_rect(&PileType::Tableau(6), &layout, &game);
|
||||||
// Expected: card_height + 6 * fan. fan = 0.25 * card_height, so
|
// Expected: card_height + 6 fan steps.
|
||||||
// size.y = card_height * (1 + 6 * 0.25) = card_height * 2.5.
|
let expected = layout.card_size.y * (1.0 + 6.0 * layout.tableau_fan_frac);
|
||||||
let expected = layout.card_size.y * 2.5;
|
|
||||||
assert!(
|
assert!(
|
||||||
(size.y - expected).abs() < 1e-3,
|
(size.y - expected).abs() < 1e-3,
|
||||||
"expected {expected}, got {}",
|
"expected {expected}, got {}",
|
||||||
|
|||||||
+132
-23
@@ -48,6 +48,24 @@ pub const MIN_WINDOW: Vec2 = Vec2::new(320.0, 400.0);
|
|||||||
/// which rendered the cards ~3.6 % squashed vertically.
|
/// which rendered the cards ~3.6 % squashed vertically.
|
||||||
const CARD_ASPECT: f32 = 1.4523;
|
const CARD_ASPECT: f32 = 1.4523;
|
||||||
|
|
||||||
|
/// Divisor used to derive the horizontal gap between columns from the card
|
||||||
|
/// width: `h_gap = card_width / H_GAP_DIVISOR`.
|
||||||
|
///
|
||||||
|
/// This constant also drives `card_width_width_based`:
|
||||||
|
/// total layout width = 7*card_width + 8*h_gap = card_width*(7 + 8/H_GAP_DIVISOR)
|
||||||
|
/// → card_width = window.x / (7 + 8/H_GAP_DIVISOR)
|
||||||
|
///
|
||||||
|
/// Desktop (H_GAP_DIVISOR = 4): card_width = window.x / 9 — existing behaviour.
|
||||||
|
/// Android (H_GAP_DIVISOR = 32): card_width = window.x / 7.25 — cards are ~10 %
|
||||||
|
/// wider than at divisor 8, with very tight gaps (~4 px) that are still visible
|
||||||
|
/// as a faint seam between columns. The primary readability boost on Android
|
||||||
|
/// comes from the `AndroidCornerLabel` overlay in `card_plugin`, but maximising
|
||||||
|
/// the physical card size helps too.
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
const H_GAP_DIVISOR: f32 = 4.0;
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
const H_GAP_DIVISOR: f32 = 32.0;
|
||||||
|
|
||||||
/// Fraction of card height used as vertical padding between the top row and
|
/// Fraction of card height used as vertical padding between the top row and
|
||||||
/// the tableau row.
|
/// the tableau row.
|
||||||
const VERTICAL_GAP_FRAC: f32 = 0.2;
|
const VERTICAL_GAP_FRAC: f32 = 0.2;
|
||||||
@@ -57,7 +75,7 @@ const VERTICAL_GAP_FRAC: f32 = 0.2;
|
|||||||
/// column must fit at this fraction). On desktop (height-limited) windows the
|
/// column must fit at this fraction). On desktop (height-limited) windows the
|
||||||
/// adaptive computation returns this value exactly; on portrait phones it
|
/// adaptive computation returns this value exactly; on portrait phones it
|
||||||
/// expands to fill available vertical space.
|
/// expands to fill available vertical space.
|
||||||
const TABLEAU_FAN_FRAC: f32 = 0.25;
|
const TABLEAU_FAN_FRAC: f32 = 0.18;
|
||||||
|
|
||||||
/// Minimum fraction for face-down tableau cards. Scales proportionally with
|
/// Minimum fraction for face-down tableau cards. Scales proportionally with
|
||||||
/// the adaptive face-up fraction so hit-testing and rendering stay in sync.
|
/// the adaptive face-up fraction so hit-testing and rendering stay in sync.
|
||||||
@@ -66,7 +84,7 @@ const TABLEAU_FAN_FRAC: f32 = 0.25;
|
|||||||
/// enough of each card back to read as a meaningful stack rather than a
|
/// enough of each card back to read as a meaningful stack rather than a
|
||||||
/// thin sliver. The ratio to TABLEAU_FAN_FRAC (0.80) is preserved by
|
/// thin sliver. The ratio to TABLEAU_FAN_FRAC (0.80) is preserved by
|
||||||
/// the adaptive scaling in `compute_layout`.
|
/// the adaptive scaling in `compute_layout`.
|
||||||
const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.20;
|
const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.14;
|
||||||
|
|
||||||
/// Largest possible face-up tableau column in Klondike: a King down to an Ace
|
/// Largest possible face-up tableau column in Klondike: a King down to an Ace
|
||||||
/// after every face-down card has flipped on column 7. Layout sizing must keep
|
/// after every face-down card has flipped on column 7. Layout sizing must keep
|
||||||
@@ -77,15 +95,34 @@ const MAX_TABLEAU_CARDS: f32 = 13.0;
|
|||||||
/// (action buttons, Score / Moves / Timer readouts). The card grid starts
|
/// (action buttons, Score / Moves / Timer readouts). The card grid starts
|
||||||
/// below this band so the HUD doesn't bleed into the play surface.
|
/// below this band so the HUD doesn't bleed into the play surface.
|
||||||
///
|
///
|
||||||
/// Desktop: 64 px fits the single-row action bar plus the Score/Moves line.
|
/// Desktop: 64 px fits the score/moves/time + mode badge rows.
|
||||||
/// Android: 128 px accommodates the two-row button wrap on narrow phones
|
/// Android: 112 px — the HUD column has 4 flex tiers with 3 inter-tier
|
||||||
/// (7 buttons × ~52 dp each, with a 65% max-width constraint, wraps to two
|
/// gaps (4 px each) plus a SPACE_2 = 8 px top offset. With empty tiers
|
||||||
/// ~48 dp rows plus row-gap). Without this larger reserve the bottom row of
|
/// still contributing gap height in Bevy's flex layout, the actual HUD
|
||||||
/// buttons overlaps the top card row.
|
/// height can reach ~80 px before the grid starts; 112 px gives ~28 px
|
||||||
|
/// of clearance between the HUD bottom and the top card edge, preventing
|
||||||
|
/// the overlap seen with the previous 80 px value.
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
pub const HUD_BAND_HEIGHT: f32 = 64.0;
|
pub const HUD_BAND_HEIGHT: f32 = 64.0;
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
pub const HUD_BAND_HEIGHT: f32 = 128.0;
|
pub const HUD_BAND_HEIGHT: f32 = 112.0;
|
||||||
|
|
||||||
|
/// Height of the bottom action-bar (the row of ≡ ← || ? ! M + buttons).
|
||||||
|
///
|
||||||
|
/// The action bar sits *above* the OS gesture/navigation zone, so it is NOT
|
||||||
|
/// covered by `safe_area_bottom`. `compute_layout` adds this constant to
|
||||||
|
/// `safe_area_bottom` before computing the height-based card-size candidate
|
||||||
|
/// and the available tableau height, ensuring the deepest fanned column
|
||||||
|
/// never scrolls behind the button row.
|
||||||
|
///
|
||||||
|
/// Derivation (Android): `min_height 44 px` buttons
|
||||||
|
/// + `padding.top 8 px` + `padding.bottom 8 px` outer bar padding = **60 px**.
|
||||||
|
///
|
||||||
|
/// Desktop: no persistent bottom bar, so 0.
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
const BOTTOM_BAR_HEIGHT: f32 = 0.0;
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
const BOTTOM_BAR_HEIGHT: f32 = 60.0;
|
||||||
|
|
||||||
/// Table background colour (dark green felt).
|
/// Table background colour (dark green felt).
|
||||||
pub const TABLE_COLOUR: [f32; 3] = [0.059, 0.322, 0.196];
|
pub const TABLE_COLOUR: [f32; 3] = [0.059, 0.322, 0.196];
|
||||||
@@ -106,7 +143,7 @@ pub struct Layout {
|
|||||||
pub pile_positions: HashMap<PileType, Vec2>,
|
pub pile_positions: HashMap<PileType, Vec2>,
|
||||||
/// Per-step vertical offset fraction for face-up tableau cards, as a
|
/// Per-step vertical offset fraction for face-up tableau cards, as a
|
||||||
/// fraction of `card_size.y`. On height-limited (desktop) windows this
|
/// fraction of `card_size.y`. On height-limited (desktop) windows this
|
||||||
/// equals `TABLEAU_FAN_FRAC` (0.25); on width-limited (portrait phone)
|
/// equals `TABLEAU_FAN_FRAC` (0.18); on width-limited (portrait phone)
|
||||||
/// windows it expands to fill the available vertical space so the tableau
|
/// windows it expands to fill the available vertical space so the tableau
|
||||||
/// stretches to the bottom of the screen. Card rendering (`card_plugin`)
|
/// stretches to the bottom of the screen. Card rendering (`card_plugin`)
|
||||||
/// and hit testing (`input_plugin`) both read from this field so they
|
/// and hit testing (`input_plugin`) both read from this field so they
|
||||||
@@ -150,8 +187,10 @@ pub fn compute_layout(window: Vec2, safe_area_top: f32, safe_area_bottom: f32, h
|
|||||||
let window = window.max(MIN_WINDOW);
|
let window = window.max(MIN_WINDOW);
|
||||||
let band_h = if hud_visible { HUD_BAND_HEIGHT } else { 0.0 };
|
let band_h = if hud_visible { HUD_BAND_HEIGHT } else { 0.0 };
|
||||||
|
|
||||||
// Width-based candidate (existing behaviour): 7 cards + 8 h_gaps = 9*card_width.
|
// Width-based candidate: 7 cards + 8 h_gaps where h_gap = card_width/H_GAP_DIVISOR.
|
||||||
let card_width_width_based = window.x / 9.0;
|
// Total = card_width*(7 + 8/H_GAP_DIVISOR) = window.x → card_width = window.x/card_width_divisor.
|
||||||
|
let card_width_divisor = 7.0 + 8.0 / H_GAP_DIVISOR;
|
||||||
|
let card_width_width_based = window.x / card_width_divisor;
|
||||||
|
|
||||||
// Height-based candidate. The vertical budget below the top row must hold
|
// Height-based candidate. The vertical budget below the top row must hold
|
||||||
// a worst-case fanned tableau column plus a bottom margin equal to h_gap.
|
// a worst-case fanned tableau column plus a bottom margin equal to h_gap.
|
||||||
@@ -168,21 +207,24 @@ pub fn compute_layout(window: Vec2, safe_area_top: f32, safe_area_bottom: f32, h
|
|||||||
// Substituting h_gap = w/4 and h = CARD_ASPECT * w and solving for the
|
// Substituting h_gap = w/4 and h = CARD_ASPECT * w and solving for the
|
||||||
// largest w that fits gives:
|
// largest w that fits gives:
|
||||||
// (window.y - HUD_BAND_HEIGHT) = w * (0.5 + (1 + fan_factor + VERTICAL_GAP_FRAC) * CARD_ASPECT)
|
// (window.y - HUD_BAND_HEIGHT) = w * (0.5 + (1 + fan_factor + VERTICAL_GAP_FRAC) * CARD_ASPECT)
|
||||||
|
// Reserve space for both the OS gesture/nav bar and the app's own action
|
||||||
|
// bar, which sits above it and is invisible to safe_area_bottom.
|
||||||
|
let effective_safe_bottom = safe_area_bottom + BOTTOM_BAR_HEIGHT;
|
||||||
|
|
||||||
let fan_factor = 1.0 + (MAX_TABLEAU_CARDS - 1.0) * TABLEAU_FAN_FRAC;
|
let fan_factor = 1.0 + (MAX_TABLEAU_CARDS - 1.0) * TABLEAU_FAN_FRAC;
|
||||||
let height_denom = 0.5 + (1.0 + fan_factor + VERTICAL_GAP_FRAC) * CARD_ASPECT;
|
let height_denom = 0.5 + (1.0 + fan_factor + VERTICAL_GAP_FRAC) * CARD_ASPECT;
|
||||||
let card_width_height_based = (window.y - safe_area_top - safe_area_bottom - band_h).max(0.0) / height_denom;
|
let card_width_height_based = (window.y - safe_area_top - effective_safe_bottom - band_h).max(0.0) / height_denom;
|
||||||
|
|
||||||
let card_width = card_width_width_based.min(card_width_height_based);
|
let card_width = card_width_width_based.min(card_width_height_based);
|
||||||
let card_height = card_width * CARD_ASPECT;
|
let card_height = card_width * CARD_ASPECT;
|
||||||
let card_size = Vec2::new(card_width, card_height);
|
let card_size = Vec2::new(card_width, card_height);
|
||||||
|
|
||||||
let h_gap = card_width / 4.0;
|
let h_gap = card_width / H_GAP_DIVISOR;
|
||||||
// Total occupied width = 7*card_width + 8*h_gap = 9*card_width. When card
|
// Total occupied width = 7*card_width + 8*h_gap = card_width_divisor*card_width.
|
||||||
// sizing is height-limited (tall/narrow windows), this is smaller than
|
// When card sizing is height-limited (tall/narrow windows) this is smaller than
|
||||||
// window.x, so the grid is centred horizontally; otherwise side_margin
|
// window.x and the grid is centred horizontally; otherwise side_margin collapses
|
||||||
// collapses to h_gap and the geometry matches the original width-based
|
// to h_gap and the geometry fills the window exactly.
|
||||||
// layout exactly.
|
let total_grid_width = card_width_divisor * card_width;
|
||||||
let total_grid_width = 9.0 * card_width;
|
|
||||||
let side_margin = (window.x - total_grid_width) / 2.0 + h_gap;
|
let side_margin = (window.x - total_grid_width) / 2.0 + h_gap;
|
||||||
let left_edge = -window.x / 2.0;
|
let left_edge = -window.x / 2.0;
|
||||||
let col_x = |col: usize| -> f32 {
|
let col_x = |col: usize| -> f32 {
|
||||||
@@ -220,7 +262,7 @@ pub fn compute_layout(window: Vec2, safe_area_top: f32, safe_area_bottom: f32, h
|
|||||||
//
|
//
|
||||||
// avail = distance from the top of the first tableau card to the bottom
|
// avail = distance from the top of the first tableau card to the bottom
|
||||||
// margin — i.e. the space available for 12 fan steps.
|
// margin — i.e. the space available for 12 fan steps.
|
||||||
let avail = (tableau_y - (-window.y / 2.0 + safe_area_bottom + h_gap) - card_height / 2.0).max(0.0);
|
let avail = (tableau_y - (-window.y / 2.0 + effective_safe_bottom + h_gap) - card_height / 2.0).max(0.0);
|
||||||
let ideal_fan_frac = if card_height > 0.0 {
|
let ideal_fan_frac = if card_height > 0.0 {
|
||||||
avail / ((MAX_TABLEAU_CARDS - 1.0) * card_height)
|
avail / ((MAX_TABLEAU_CARDS - 1.0) * card_height)
|
||||||
} else {
|
} else {
|
||||||
@@ -402,11 +444,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn tall_narrow_window_keeps_width_based_sizing() {
|
fn tall_narrow_window_keeps_width_based_sizing() {
|
||||||
// Tall narrow window: there's plenty of vertical budget, so width is
|
// Tall narrow window: there's plenty of vertical budget, so width is
|
||||||
// the bottleneck and card_width matches the legacy window.x / 9
|
// the bottleneck and card_width matches window.x / (7 + 8/H_GAP_DIVISOR).
|
||||||
// derivation exactly.
|
|
||||||
let window = Vec2::new(900.0, 1600.0);
|
let window = Vec2::new(900.0, 1600.0);
|
||||||
let layout = compute_layout(window, 0.0, 0.0, true);
|
let layout = compute_layout(window, 0.0, 0.0, true);
|
||||||
let width_based = window.x / 9.0;
|
let width_based = window.x / (7.0 + 8.0 / H_GAP_DIVISOR);
|
||||||
assert!(
|
assert!(
|
||||||
(layout.card_size.x - width_based).abs() < 1e-3,
|
(layout.card_size.x - width_based).abs() < 1e-3,
|
||||||
"expected width-based sizing (card_width {} should equal {})",
|
"expected width-based sizing (card_width {} should equal {})",
|
||||||
@@ -588,6 +629,74 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Suspend → resume layout-consistency invariant.
|
||||||
|
///
|
||||||
|
/// If the resume handler resets `SafeAreaInsets` to zero and then the JNI
|
||||||
|
/// poller re-resolves the same values, `compute_layout` must produce an
|
||||||
|
/// identical result to the fresh-launch layout. This test also verifies
|
||||||
|
/// that a layout computed with `safe_area_top = 0` (the brief window while
|
||||||
|
/// insets haven't re-resolved after resume) differs visibly from the
|
||||||
|
/// correct layout, confirming that the bug would manifest without the fix.
|
||||||
|
#[test]
|
||||||
|
fn suspend_resume_layout_matches_fresh_launch() {
|
||||||
|
let window = Vec2::new(900.0, 2000.0);
|
||||||
|
let safe_top = 27.0_f32;
|
||||||
|
let safe_bottom = 110.0_f32;
|
||||||
|
|
||||||
|
// Fresh-launch layout — insets known from startup.
|
||||||
|
let fresh = compute_layout(window, safe_top, safe_bottom, true);
|
||||||
|
|
||||||
|
// Layout computed during the brief post-resume window before insets
|
||||||
|
// re-resolve (safe_area_top temporarily 0).
|
||||||
|
let wrong = compute_layout(window, 0.0, safe_bottom, true);
|
||||||
|
|
||||||
|
// Verify the "wrong" layout actually differs — the bug would push the
|
||||||
|
// top card row upward by exactly safe_top pixels.
|
||||||
|
let fresh_stock_y = fresh.pile_positions[&PileType::Stock].y;
|
||||||
|
let wrong_stock_y = wrong.pile_positions[&PileType::Stock].y;
|
||||||
|
// In Bevy's +y-is-up system, adding safe_area_top pushes the stock
|
||||||
|
// downward (−y direction). So wrong_stock_y > fresh_stock_y by safe_top.
|
||||||
|
assert!(
|
||||||
|
(wrong_stock_y - fresh_stock_y - safe_top).abs() < 1e-3,
|
||||||
|
"wrong layout must displace stock upward by safe_top ({safe_top}): \
|
||||||
|
fresh={fresh_stock_y:.2} wrong={wrong_stock_y:.2} delta={:.2}",
|
||||||
|
wrong_stock_y - fresh_stock_y,
|
||||||
|
);
|
||||||
|
|
||||||
|
// After the poller re-resolves correct insets the layout must be
|
||||||
|
// identical to the fresh-launch layout.
|
||||||
|
let corrected = compute_layout(window, safe_top, safe_bottom, true);
|
||||||
|
assert_eq!(
|
||||||
|
corrected.card_size, fresh.card_size,
|
||||||
|
"card size must be preserved after resume",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(corrected.pile_positions[&PileType::Stock].y - fresh_stock_y).abs() < 1e-3,
|
||||||
|
"stock y must match fresh launch after resume: \
|
||||||
|
corrected={:.2} fresh={fresh_stock_y:.2}",
|
||||||
|
corrected.pile_positions[&PileType::Stock].y,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(corrected.pile_positions[&PileType::Stock].x
|
||||||
|
- fresh.pile_positions[&PileType::Stock].x)
|
||||||
|
.abs()
|
||||||
|
< 1e-3,
|
||||||
|
"stock x must be unchanged after resume",
|
||||||
|
);
|
||||||
|
// The HUD band top clearance (distance from window top to card top)
|
||||||
|
// must match as well — this is the quantity directly visible in Bug 2.
|
||||||
|
let card_top = |layout: &super::Layout| {
|
||||||
|
layout.pile_positions[&PileType::Stock].y + layout.card_size.y / 2.0
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
(card_top(&corrected) - card_top(&fresh)).abs() < 1e-3,
|
||||||
|
"top-of-card must match fresh launch after resume: \
|
||||||
|
corrected={:.2} fresh={:.2}",
|
||||||
|
card_top(&corrected),
|
||||||
|
card_top(&fresh),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// safe_area_bottom must not affect horizontal positions.
|
/// safe_area_bottom must not affect horizontal positions.
|
||||||
#[test]
|
#[test]
|
||||||
fn safe_area_bottom_does_not_affect_horizontal_layout() {
|
fn safe_area_bottom_does_not_affect_horizontal_layout() {
|
||||||
|
|||||||
@@ -15,13 +15,13 @@ use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
|
|||||||
use solitaire_data::{save_settings_to, settings::SyncBackend};
|
use solitaire_data::{save_settings_to, settings::SyncBackend};
|
||||||
use solitaire_sync::LeaderboardEntry;
|
use solitaire_sync::LeaderboardEntry;
|
||||||
|
|
||||||
use crate::events::{InfoToastEvent, ToggleLeaderboardRequestEvent};
|
use crate::events::{InfoToastEvent, ToggleLeaderboardRequestEvent, WarningToastEvent};
|
||||||
use crate::font_plugin::FontResource;
|
use crate::font_plugin::FontResource;
|
||||||
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
|
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
|
||||||
use crate::sync_plugin::SyncProviderResource;
|
use crate::sync_plugin::SyncProviderResource;
|
||||||
use crate::ui_modal::{
|
use crate::ui_modal::{
|
||||||
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
||||||
ScrimDismissible,
|
ModalScrim, ScrimDismissible,
|
||||||
};
|
};
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
ACCENT_PRIMARY, BG_ELEVATED, BORDER_SUBTLE, RADIUS_SM, STATE_INFO,
|
ACCENT_PRIMARY, BG_ELEVATED, BORDER_SUBTLE, RADIUS_SM, STATE_INFO,
|
||||||
@@ -138,6 +138,8 @@ impl Plugin for LeaderboardPlugin {
|
|||||||
.init_resource::<OptOutTask>()
|
.init_resource::<OptOutTask>()
|
||||||
.init_resource::<DisplayNameBuffer>()
|
.init_resource::<DisplayNameBuffer>()
|
||||||
.add_message::<ToggleLeaderboardRequestEvent>()
|
.add_message::<ToggleLeaderboardRequestEvent>()
|
||||||
|
.add_message::<WarningToastEvent>()
|
||||||
|
.add_message::<InfoToastEvent>()
|
||||||
// `MouseWheel` and `KeyboardInput` are emitted by Bevy's input
|
// `MouseWheel` and `KeyboardInput` are emitted by Bevy's input
|
||||||
// plugin under `DefaultPlugins`; register them explicitly so all
|
// plugin under `DefaultPlugins`; register them explicitly so all
|
||||||
// leaderboard systems run cleanly under `MinimalPlugins` in tests.
|
// leaderboard systems run cleanly under `MinimalPlugins` in tests.
|
||||||
@@ -159,6 +161,7 @@ impl Plugin for LeaderboardPlugin {
|
|||||||
handle_display_name_text_input,
|
handle_display_name_text_input,
|
||||||
handle_display_name_confirm,
|
handle_display_name_confirm,
|
||||||
handle_display_name_cancel,
|
handle_display_name_cancel,
|
||||||
|
update_leaderboard_public_name_label,
|
||||||
)
|
)
|
||||||
.chain(),
|
.chain(),
|
||||||
)
|
)
|
||||||
@@ -350,7 +353,7 @@ fn handle_opt_in_button(
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.map(str::to_string)
|
.map(|n| n.chars().take(32).collect::<String>())
|
||||||
})
|
})
|
||||||
.unwrap_or_else(|| "Player".to_string());
|
.unwrap_or_else(|| "Player".to_string());
|
||||||
|
|
||||||
@@ -361,10 +364,13 @@ fn handle_opt_in_button(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Polls the opt-in task; fires an `InfoToastEvent` on completion or failure.
|
/// Polls the opt-in task; fires a toast and persists opted-in state on completion.
|
||||||
fn poll_opt_in_task(
|
fn poll_opt_in_task(
|
||||||
mut task_res: ResMut<OptInTask>,
|
mut task_res: ResMut<OptInTask>,
|
||||||
mut toast: MessageWriter<InfoToastEvent>,
|
mut toast: MessageWriter<InfoToastEvent>,
|
||||||
|
mut warn_toast: MessageWriter<WarningToastEvent>,
|
||||||
|
settings: Option<ResMut<SettingsResource>>,
|
||||||
|
settings_path: Option<Res<SettingsStoragePath>>,
|
||||||
) {
|
) {
|
||||||
let Some(task) = task_res.0.as_mut() else { return };
|
let Some(task) = task_res.0.as_mut() else { return };
|
||||||
let Some(result) = future::block_on(future::poll_once(task)) else { return };
|
let Some(result) = future::block_on(future::poll_once(task)) else { return };
|
||||||
@@ -372,10 +378,18 @@ fn poll_opt_in_task(
|
|||||||
match result {
|
match result {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
toast.write(InfoToastEvent("Opted in to leaderboard".to_string()));
|
toast.write(InfoToastEvent("Opted in to leaderboard".to_string()));
|
||||||
|
if let Some(mut s) = settings {
|
||||||
|
s.0.leaderboard_opted_in = true;
|
||||||
|
if let Some(path) = settings_path.as_ref().and_then(|p| p.0.as_ref())
|
||||||
|
&& let Err(e) = save_settings_to(path, &s.0)
|
||||||
|
{
|
||||||
|
warn!("failed to save settings after opt-in: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("leaderboard opt-in failed: {e}");
|
warn!("leaderboard opt-in failed: {e}");
|
||||||
toast.write(InfoToastEvent("Leaderboard update failed".to_string()));
|
warn_toast.write(WarningToastEvent("Failed to join leaderboard".to_string()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -401,10 +415,13 @@ fn handle_opt_out_button(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Polls the opt-out task; fires an `InfoToastEvent` on completion or failure.
|
/// Polls the opt-out task; fires a toast and clears opted-in state on completion.
|
||||||
fn poll_opt_out_task(
|
fn poll_opt_out_task(
|
||||||
mut task_res: ResMut<OptOutTask>,
|
mut task_res: ResMut<OptOutTask>,
|
||||||
mut toast: MessageWriter<InfoToastEvent>,
|
mut toast: MessageWriter<InfoToastEvent>,
|
||||||
|
mut warn_toast: MessageWriter<WarningToastEvent>,
|
||||||
|
settings: Option<ResMut<SettingsResource>>,
|
||||||
|
settings_path: Option<Res<SettingsStoragePath>>,
|
||||||
) {
|
) {
|
||||||
let Some(task) = task_res.0.as_mut() else { return };
|
let Some(task) = task_res.0.as_mut() else { return };
|
||||||
let Some(result) = future::block_on(future::poll_once(task)) else { return };
|
let Some(result) = future::block_on(future::poll_once(task)) else { return };
|
||||||
@@ -412,10 +429,18 @@ fn poll_opt_out_task(
|
|||||||
match result {
|
match result {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
toast.write(InfoToastEvent("Opted out of leaderboard".to_string()));
|
toast.write(InfoToastEvent("Opted out of leaderboard".to_string()));
|
||||||
|
if let Some(mut s) = settings {
|
||||||
|
s.0.leaderboard_opted_in = false;
|
||||||
|
if let Some(path) = settings_path.as_ref().and_then(|p| p.0.as_ref())
|
||||||
|
&& let Err(e) = save_settings_to(path, &s.0)
|
||||||
|
{
|
||||||
|
warn!("failed to save settings after opt-out: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("leaderboard opt-out failed: {e}");
|
warn!("leaderboard opt-out failed: {e}");
|
||||||
toast.write(InfoToastEvent("Leaderboard update failed".to_string()));
|
warn_toast.write(WarningToastEvent("Failed to leave leaderboard".to_string()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -428,6 +453,12 @@ fn poll_opt_out_task(
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct LeaderboardCloseButton;
|
pub struct LeaderboardCloseButton;
|
||||||
|
|
||||||
|
/// Marker on the "Public name: …" label inside the leaderboard panel so it
|
||||||
|
/// can be updated reactively when the player changes their display name
|
||||||
|
/// without a full panel rebuild.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct LeaderboardPublicNameText;
|
||||||
|
|
||||||
fn spawn_leaderboard_screen(
|
fn spawn_leaderboard_screen(
|
||||||
commands: &mut Commands,
|
commands: &mut Commands,
|
||||||
data: &LeaderboardResource,
|
data: &LeaderboardResource,
|
||||||
@@ -481,6 +512,7 @@ fn spawn_leaderboard_screen(
|
|||||||
None => "Public name: (same as username)".to_string(),
|
None => "Public name: (same as username)".to_string(),
|
||||||
};
|
};
|
||||||
row.spawn((
|
row.spawn((
|
||||||
|
LeaderboardPublicNameText,
|
||||||
Text::new(label),
|
Text::new(label),
|
||||||
font_caption.clone(),
|
font_caption.clone(),
|
||||||
TextColor(TEXT_SECONDARY),
|
TextColor(TEXT_SECONDARY),
|
||||||
@@ -683,6 +715,7 @@ fn data_cell(
|
|||||||
fn handle_set_display_name_button(
|
fn handle_set_display_name_button(
|
||||||
button_q: Query<&Interaction, (Changed<Interaction>, With<SetDisplayNameButton>)>,
|
button_q: Query<&Interaction, (Changed<Interaction>, With<SetDisplayNameButton>)>,
|
||||||
existing: Query<(), With<DisplayNameModal>>,
|
existing: Query<(), With<DisplayNameModal>>,
|
||||||
|
other_modal_scrims: Query<(), (With<ModalScrim>, Without<DisplayNameModal>)>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
settings: Option<Res<SettingsResource>>,
|
settings: Option<Res<SettingsResource>>,
|
||||||
font_res: Option<Res<FontResource>>,
|
font_res: Option<Res<FontResource>>,
|
||||||
@@ -694,6 +727,9 @@ fn handle_set_display_name_button(
|
|||||||
if !existing.is_empty() {
|
if !existing.is_empty() {
|
||||||
return; // already open
|
return; // already open
|
||||||
}
|
}
|
||||||
|
if !other_modal_scrims.is_empty() {
|
||||||
|
return; // Another modal is already visible.
|
||||||
|
}
|
||||||
buf.0 = settings
|
buf.0 = settings
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|s| s.0.leaderboard_display_name.clone())
|
.and_then(|s| s.0.leaderboard_display_name.clone())
|
||||||
@@ -733,7 +769,9 @@ fn handle_display_name_text_input(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Saves the typed display name to `SettingsResource` and closes the modal.
|
/// Saves the typed display name to `SettingsResource`, closes the modal, and
|
||||||
|
/// pushes the new name to the server when the player is already opted in.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn handle_display_name_confirm(
|
fn handle_display_name_confirm(
|
||||||
button_q: Query<&Interaction, (Changed<Interaction>, With<DisplayNameConfirmButton>)>,
|
button_q: Query<&Interaction, (Changed<Interaction>, With<DisplayNameConfirmButton>)>,
|
||||||
screens: Query<Entity, With<DisplayNameModal>>,
|
screens: Query<Entity, With<DisplayNameModal>>,
|
||||||
@@ -741,22 +779,58 @@ fn handle_display_name_confirm(
|
|||||||
buf: Res<DisplayNameBuffer>,
|
buf: Res<DisplayNameBuffer>,
|
||||||
settings: Option<ResMut<SettingsResource>>,
|
settings: Option<ResMut<SettingsResource>>,
|
||||||
settings_path: Option<Res<SettingsStoragePath>>,
|
settings_path: Option<Res<SettingsStoragePath>>,
|
||||||
|
provider: Option<Res<SyncProviderResource>>,
|
||||||
|
mut task_res: ResMut<OptInTask>,
|
||||||
) {
|
) {
|
||||||
if !button_q.iter().any(|i| *i == Interaction::Pressed) {
|
if !button_q.iter().any(|i| *i == Interaction::Pressed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let Some(mut settings) = settings {
|
if let Some(mut settings) = settings {
|
||||||
let trimmed = buf.0.trim().to_string();
|
let trimmed: String = buf.0.trim().chars().take(32).collect();
|
||||||
settings.0.leaderboard_display_name = if trimmed.is_empty() {
|
settings.0.leaderboard_display_name = if trimmed.is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(trimmed)
|
Some(trimmed.clone())
|
||||||
};
|
};
|
||||||
if let Some(path) = settings_path.as_ref().and_then(|p| p.0.as_ref())
|
if let Some(path) = settings_path.as_ref().and_then(|p| p.0.as_ref())
|
||||||
&& let Err(e) = save_settings_to(path, &settings.0)
|
&& let Err(e) = save_settings_to(path, &settings.0)
|
||||||
{
|
{
|
||||||
warn!("failed to save settings: {e}");
|
warn!("failed to save settings: {e}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Push updated name to the server when already opted in and no task
|
||||||
|
// is in flight. The server's opt-in endpoint is an upsert, so calling
|
||||||
|
// it a second time only updates the display_name column.
|
||||||
|
let is_remote = provider
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|p| p.0.backend_name() != "local");
|
||||||
|
if settings.0.leaderboard_opted_in && is_remote && task_res.0.is_none() {
|
||||||
|
let display_name = settings
|
||||||
|
.0
|
||||||
|
.leaderboard_display_name
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
if let solitaire_data::settings::SyncBackend::SolitaireServer {
|
||||||
|
ref username,
|
||||||
|
..
|
||||||
|
} = settings.0.sync_backend
|
||||||
|
{
|
||||||
|
username.chars().take(32).collect()
|
||||||
|
} else {
|
||||||
|
"Player".to_string()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if let Some(p) = provider {
|
||||||
|
let provider = p.0.clone();
|
||||||
|
let task = AsyncComputeTaskPool::get().spawn(async move {
|
||||||
|
provider
|
||||||
|
.opt_in_leaderboard(&display_name)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
});
|
||||||
|
task_res.0 = Some(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for entity in &screens {
|
for entity in &screens {
|
||||||
commands.entity(entity).despawn();
|
commands.entity(entity).despawn();
|
||||||
@@ -857,6 +931,25 @@ fn spawn_display_name_modal(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Keeps the "Public name: …" label in the leaderboard panel in sync with
|
||||||
|
/// `SettingsResource` after the player saves a new display name. No-op when
|
||||||
|
/// the panel is closed (`labels.is_empty()` exits immediately).
|
||||||
|
fn update_leaderboard_public_name_label(
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
|
mut labels: Query<&mut Text, With<LeaderboardPublicNameText>>,
|
||||||
|
) {
|
||||||
|
if labels.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let new_label = match settings.as_ref().and_then(|s| s.0.leaderboard_display_name.as_deref()) {
|
||||||
|
Some(n) => format!("Public name: {n}"),
|
||||||
|
None => "Public name: (same as username)".to_string(),
|
||||||
|
};
|
||||||
|
for mut text in &mut labels {
|
||||||
|
text.0 = new_label.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Accepts printable ASCII characters (0x20–0x7e) for the display-name field.
|
/// Accepts printable ASCII characters (0x20–0x7e) for the display-name field.
|
||||||
fn printable_char_dn(text: &str) -> Option<char> {
|
fn printable_char_dn(text: &str) -> Option<char> {
|
||||||
let ch = text.chars().next()?;
|
let ch = text.chars().next()?;
|
||||||
@@ -1048,4 +1141,224 @@ mod tests {
|
|||||||
// 65 seconds = 1:05, not 1:5
|
// 65 seconds = 1:05, not 1:5
|
||||||
assert_eq!(format_secs(65), "1:05");
|
assert_eq!(format_secs(65), "1:05");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Bug-fix regression tests
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn headless_app_with_settings() -> App {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.insert_resource(SettingsResource(solitaire_data::settings::Settings::default()));
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bug 1: opt-in errors must fire `WarningToastEvent`, not `InfoToastEvent`.
|
||||||
|
#[test]
|
||||||
|
fn opt_in_error_fires_warning_toast() {
|
||||||
|
use bevy::ecs::message::Messages;
|
||||||
|
|
||||||
|
let mut app = headless_app_with_settings();
|
||||||
|
|
||||||
|
// Inject a pre-resolved failed task directly into OptInTask.
|
||||||
|
let failed_task = AsyncComputeTaskPool::get()
|
||||||
|
.spawn(async { Err::<(), String>("network error".to_string()) });
|
||||||
|
app.world_mut().resource_mut::<OptInTask>().0 = Some(failed_task);
|
||||||
|
|
||||||
|
// Pump until the task is polled or a deadline elapses. A fixed
|
||||||
|
// update count is unreliable under parallel `cargo test --workspace`
|
||||||
|
// load — the AsyncComputeTaskPool background threads can be starved
|
||||||
|
// long enough that 5 updates finish before the task completes.
|
||||||
|
// Mirrors the deadline-loop pattern used in sync_plugin tests.
|
||||||
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||||
|
loop {
|
||||||
|
app.update();
|
||||||
|
let msgs = app.world().resource::<Messages<WarningToastEvent>>();
|
||||||
|
let mut cursor = msgs.get_cursor();
|
||||||
|
if cursor.read(msgs).next().is_some() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if std::time::Instant::now() >= deadline {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::yield_now();
|
||||||
|
}
|
||||||
|
|
||||||
|
let msgs = app.world().resource::<Messages<WarningToastEvent>>();
|
||||||
|
let mut cursor = msgs.get_cursor();
|
||||||
|
assert!(
|
||||||
|
cursor.read(msgs).next().is_some(),
|
||||||
|
"WarningToastEvent must be fired when opt-in fails"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bug 1: opt-out errors must fire `WarningToastEvent`, not `InfoToastEvent`.
|
||||||
|
#[test]
|
||||||
|
fn opt_out_error_fires_warning_toast() {
|
||||||
|
use bevy::ecs::message::Messages;
|
||||||
|
|
||||||
|
let mut app = headless_app_with_settings();
|
||||||
|
|
||||||
|
let failed_task = AsyncComputeTaskPool::get()
|
||||||
|
.spawn(async { Err::<(), String>("network error".to_string()) });
|
||||||
|
app.world_mut().resource_mut::<OptOutTask>().0 = Some(failed_task);
|
||||||
|
|
||||||
|
// Deadline-bounded pump — see opt_in_error_fires_warning_toast for rationale.
|
||||||
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||||
|
loop {
|
||||||
|
app.update();
|
||||||
|
let msgs = app.world().resource::<Messages<WarningToastEvent>>();
|
||||||
|
let mut cursor = msgs.get_cursor();
|
||||||
|
if cursor.read(msgs).next().is_some() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if std::time::Instant::now() >= deadline {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::yield_now();
|
||||||
|
}
|
||||||
|
|
||||||
|
let msgs = app.world().resource::<Messages<WarningToastEvent>>();
|
||||||
|
let mut cursor = msgs.get_cursor();
|
||||||
|
assert!(
|
||||||
|
cursor.read(msgs).next().is_some(),
|
||||||
|
"WarningToastEvent must be fired when opt-out fails"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bug 2: successful opt-in must set `leaderboard_opted_in = true` in Settings.
|
||||||
|
#[test]
|
||||||
|
fn opt_in_success_sets_opted_in_flag() {
|
||||||
|
let mut app = headless_app_with_settings();
|
||||||
|
|
||||||
|
// Confirm the flag starts false.
|
||||||
|
assert!(!app
|
||||||
|
.world()
|
||||||
|
.resource::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.leaderboard_opted_in);
|
||||||
|
|
||||||
|
let ok_task = AsyncComputeTaskPool::get().spawn(async { Ok::<(), String>(()) });
|
||||||
|
app.world_mut().resource_mut::<OptInTask>().0 = Some(ok_task);
|
||||||
|
|
||||||
|
// Deadline-bounded pump — see opt_in_error_fires_warning_toast for rationale.
|
||||||
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||||
|
loop {
|
||||||
|
app.update();
|
||||||
|
if app
|
||||||
|
.world()
|
||||||
|
.resource::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.leaderboard_opted_in
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if std::time::Instant::now() >= deadline {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::yield_now();
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
app.world()
|
||||||
|
.resource::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.leaderboard_opted_in,
|
||||||
|
"leaderboard_opted_in must be true after successful opt-in"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bug 2: successful opt-out must clear `leaderboard_opted_in`.
|
||||||
|
#[test]
|
||||||
|
fn opt_out_success_clears_opted_in_flag() {
|
||||||
|
let mut app = headless_app_with_settings();
|
||||||
|
|
||||||
|
// Seed as opted in.
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.leaderboard_opted_in = true;
|
||||||
|
|
||||||
|
let ok_task = AsyncComputeTaskPool::get().spawn(async { Ok::<(), String>(()) });
|
||||||
|
app.world_mut().resource_mut::<OptOutTask>().0 = Some(ok_task);
|
||||||
|
|
||||||
|
// Deadline-bounded pump — see opt_in_error_fires_warning_toast for rationale.
|
||||||
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||||
|
loop {
|
||||||
|
app.update();
|
||||||
|
if !app
|
||||||
|
.world()
|
||||||
|
.resource::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.leaderboard_opted_in
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if std::time::Instant::now() >= deadline {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::yield_now();
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!app.world()
|
||||||
|
.resource::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.leaderboard_opted_in,
|
||||||
|
"leaderboard_opted_in must be false after successful opt-out"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bug 3: `LeaderboardPublicNameText` label must reflect a display-name
|
||||||
|
/// change applied to `SettingsResource` without a panel rebuild.
|
||||||
|
#[test]
|
||||||
|
fn public_name_label_updates_reactively() {
|
||||||
|
let mut app = headless_app_with_settings();
|
||||||
|
|
||||||
|
// Open the panel.
|
||||||
|
press(&mut app, KeyCode::KeyL);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
// Verify the label starts with the default copy.
|
||||||
|
let initial: String = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<&Text, With<LeaderboardPublicNameText>>()
|
||||||
|
.iter(app.world())
|
||||||
|
.next()
|
||||||
|
.expect("LeaderboardPublicNameText must exist while panel is open")
|
||||||
|
.0
|
||||||
|
.clone();
|
||||||
|
assert!(
|
||||||
|
initial.contains("same as username"),
|
||||||
|
"initial label should say '(same as username)' when no display name is set"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clear just-pressed state so `toggle_leaderboard_screen` doesn't
|
||||||
|
// re-fire in the next frame (MinimalPlugins has no input-tick system).
|
||||||
|
{
|
||||||
|
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||||
|
input.release(KeyCode::KeyL);
|
||||||
|
input.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the display name in SettingsResource.
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.leaderboard_display_name = Some("TestPlayer".to_string());
|
||||||
|
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let updated: String = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<&Text, With<LeaderboardPublicNameText>>()
|
||||||
|
.iter(app.world())
|
||||||
|
.next()
|
||||||
|
.expect("LeaderboardPublicNameText must still exist")
|
||||||
|
.0
|
||||||
|
.clone();
|
||||||
|
assert!(
|
||||||
|
updated.contains("TestPlayer"),
|
||||||
|
"label must reflect new display name after settings change"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,9 +31,11 @@ use crate::ui_modal::{
|
|||||||
spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button,
|
spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button,
|
||||||
spawn_modal_header, ButtonVariant,
|
spawn_modal_header, ButtonVariant,
|
||||||
};
|
};
|
||||||
|
use crate::ui_theme::{TEXT_SECONDARY, Z_ONBOARDING};
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
|
BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, TEXT_PRIMARY, TYPE_BODY, TYPE_CAPTION,
|
||||||
TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, Z_ONBOARDING,
|
VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -86,6 +88,7 @@ pub struct OnboardingSlideIndex(pub u8);
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// A single `key — description` pair shown on slide 3.
|
/// A single `key — description` pair shown on slide 3.
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
struct HotkeyRow {
|
struct HotkeyRow {
|
||||||
keys: &'static str,
|
keys: &'static str,
|
||||||
description: &'static str,
|
description: &'static str,
|
||||||
@@ -96,6 +99,7 @@ struct HotkeyRow {
|
|||||||
/// Updating the list in `help_plugin.rs` should be mirrored here. The
|
/// Updating the list in `help_plugin.rs` should be mirrored here. The
|
||||||
/// ARCHITECTURE.md decision log calls out that we copy values rather than
|
/// ARCHITECTURE.md decision log calls out that we copy values rather than
|
||||||
/// refactor the help plugin.
|
/// refactor the help plugin.
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
const HOTKEYS: &[HotkeyRow] = &[
|
const HOTKEYS: &[HotkeyRow] = &[
|
||||||
HotkeyRow { keys: "D / Space", description: "Draw from stock" },
|
HotkeyRow { keys: "D / Space", description: "Draw from stock" },
|
||||||
HotkeyRow { keys: "U", description: "Undo last move" },
|
HotkeyRow { keys: "U", description: "Undo last move" },
|
||||||
@@ -359,6 +363,7 @@ fn spawn_slide_how_to_play(commands: &mut Commands, font_res: Option<&FontResour
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Slide 3 — Keyboard shortcuts.
|
/// Slide 3 — Keyboard shortcuts.
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
fn spawn_slide_hotkeys(commands: &mut Commands, font_res: Option<&FontResource>) {
|
fn spawn_slide_hotkeys(commands: &mut Commands, font_res: Option<&FontResource>) {
|
||||||
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
|
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
|
||||||
let font_row = TextFont {
|
let font_row = TextFont {
|
||||||
|
|||||||
@@ -235,7 +235,7 @@ fn toggle_pause(
|
|||||||
// Snapshot current level and streak at pause time.
|
// Snapshot current level and streak at pause time.
|
||||||
let level = progress.as_deref().map(|p| p.0.level);
|
let level = progress.as_deref().map(|p| p.0.level);
|
||||||
let streak = stats.as_deref().map(|s| s.0.win_streak_current);
|
let streak = stats.as_deref().map(|s| s.0.win_streak_current);
|
||||||
let draw_mode = settings.as_deref().map(|s| s.0.draw_mode.clone());
|
let draw_mode = settings.as_deref().map(|s| s.0.draw_mode);
|
||||||
spawn_pause_screen(
|
spawn_pause_screen(
|
||||||
&mut commands,
|
&mut commands,
|
||||||
level,
|
level,
|
||||||
@@ -437,10 +437,15 @@ fn close_forfeit_modal(
|
|||||||
/// The player reaches these overlays via the HUD menu while paused, which
|
/// The player reaches these overlays via the HUD menu while paused, which
|
||||||
/// causes both the pause modal and the overlay to be live simultaneously.
|
/// causes both the pause modal and the overlay to be live simultaneously.
|
||||||
/// That is always unintentional — the overlay should own the screen.
|
/// That is always unintentional — the overlay should own the screen.
|
||||||
|
/// Query filter for modals that are not part of the pause flow.
|
||||||
|
/// Excludes both `PauseScreen` (the pause modal itself) and
|
||||||
|
/// `ForfeitConfirmScreen` (spawned from within the pause flow).
|
||||||
|
type NonPauseFamilyScrim = (With<ModalScrim>, Without<PauseScreen>, Without<ForfeitConfirmScreen>);
|
||||||
|
|
||||||
fn auto_resume_on_overlay(
|
fn auto_resume_on_overlay(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
pause_screens: Query<Entity, With<PauseScreen>>,
|
pause_screens: Query<Entity, With<PauseScreen>>,
|
||||||
other_modal_scrims: Query<Entity, (With<ModalScrim>, Without<PauseScreen>)>,
|
other_modal_scrims: Query<Entity, NonPauseFamilyScrim>,
|
||||||
mut paused: ResMut<PausedResource>,
|
mut paused: ResMut<PausedResource>,
|
||||||
) {
|
) {
|
||||||
if pause_screens.is_empty() || other_modal_scrims.is_empty() {
|
if pause_screens.is_empty() || other_modal_scrims.is_empty() {
|
||||||
@@ -449,7 +454,9 @@ fn auto_resume_on_overlay(
|
|||||||
for entity in &pause_screens {
|
for entity in &pause_screens {
|
||||||
commands.entity(entity).despawn();
|
commands.entity(entity).despawn();
|
||||||
}
|
}
|
||||||
|
if paused.0 {
|
||||||
paused.0 = false;
|
paused.0 = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawns the pause modal using the standard `ui_modal` scaffold —
|
/// Spawns the pause modal using the standard `ui_modal` scaffold —
|
||||||
|
|||||||
@@ -138,12 +138,13 @@ fn handle_open_dialog(
|
|||||||
mut requests: MessageReader<StartPlayBySeedRequestEvent>,
|
mut requests: MessageReader<StartPlayBySeedRequestEvent>,
|
||||||
font_res: Option<Res<FontResource>>,
|
font_res: Option<Res<FontResource>>,
|
||||||
existing: Query<(), With<PlayBySeedScreen>>,
|
existing: Query<(), With<PlayBySeedScreen>>,
|
||||||
|
other_scrims: Query<(), (With<crate::ui_modal::ModalScrim>, Without<PlayBySeedScreen>)>,
|
||||||
) {
|
) {
|
||||||
if requests.read().count() == 0 {
|
if requests.read().count() == 0 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Guard against double-spawn (e.g. two events in one frame).
|
// Guard against double-spawn (e.g. two events in one frame) or stacking over another modal.
|
||||||
if !existing.is_empty() {
|
if !existing.is_empty() || !other_scrims.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let font = font_res.as_deref();
|
let font = font_res.as_deref();
|
||||||
@@ -338,7 +339,7 @@ fn tick_debounce_and_spawn_solver_task(
|
|||||||
|
|
||||||
let draw_mode = settings
|
let draw_mode = settings
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map_or(DrawMode::DrawOne, |s| s.0.draw_mode.clone());
|
.map_or(DrawMode::DrawOne, |s| s.0.draw_mode);
|
||||||
let cfg = SolverConfig::default();
|
let cfg = SolverConfig::default();
|
||||||
let task = AsyncComputeTaskPool::get()
|
let task = AsyncComputeTaskPool::get()
|
||||||
.spawn(async move { try_solve(seed, draw_mode, &cfg) });
|
.spawn(async move { try_solve(seed, draw_mode, &cfg) });
|
||||||
@@ -411,7 +412,11 @@ fn handle_confirm(
|
|||||||
new_game.write(NewGameRequestEvent {
|
new_game.write(NewGameRequestEvent {
|
||||||
seed: Some(seed),
|
seed: Some(seed),
|
||||||
mode: None,
|
mode: None,
|
||||||
confirmed: false,
|
// The player explicitly clicked Play (or pressed Enter) after typing
|
||||||
|
// a seed — treat this as an affirmative confirmation so the
|
||||||
|
// abandon-current-game dialog is not shown on top of the already-
|
||||||
|
// dismissed seed dialog.
|
||||||
|
confirmed: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
for entity in &screen {
|
for entity in &screen {
|
||||||
@@ -566,7 +571,9 @@ mod tests {
|
|||||||
assert_eq!(fired.len(), 1);
|
assert_eq!(fired.len(), 1);
|
||||||
assert_eq!(fired[0].seed, Some(42));
|
assert_eq!(fired[0].seed, Some(42));
|
||||||
assert_eq!(fired[0].mode, None);
|
assert_eq!(fired[0].mode, None);
|
||||||
assert!(!fired[0].confirmed);
|
// confirmed: true — the player explicitly clicked Play, so no
|
||||||
|
// abandon-current-game dialog should appear.
|
||||||
|
assert!(fired[0].confirmed);
|
||||||
|
|
||||||
// Dialog should be gone.
|
// Dialog should be gone.
|
||||||
assert!(!dialog_present(&mut app));
|
assert!(!dialog_present(&mut app));
|
||||||
|
|||||||
@@ -473,8 +473,11 @@ fn radial_open_on_long_press(
|
|||||||
mut state: ResMut<RightClickRadialState>,
|
mut state: ResMut<RightClickRadialState>,
|
||||||
) {
|
) {
|
||||||
// Guard: only count while a touch is down, uncommitted, and radial is idle.
|
// Guard: only count while a touch is down, uncommitted, and radial is idle.
|
||||||
let active_id = drag.active_touch_id;
|
let Some(active_id) = drag.active_touch_id else {
|
||||||
if active_id.is_none() || drag.committed || state.is_active() || paused.is_some_and(|p| p.0) {
|
*hold_timer = 0.0;
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if drag.committed || state.is_active() || paused.is_some_and(|p| p.0) {
|
||||||
*hold_timer = 0.0;
|
*hold_timer = 0.0;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -487,7 +490,7 @@ fn radial_open_on_long_press(
|
|||||||
|
|
||||||
// Resolve current touch world position.
|
// Resolve current touch world position.
|
||||||
let Some(touches) = touches else { return };
|
let Some(touches) = touches else { return };
|
||||||
let Some(touch) = touches.iter().find(|t| t.id() == active_id.unwrap()) else {
|
let Some(touch) = touches.iter().find(|t| t.id() == active_id) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let Some((camera, cam_xf)) = cameras.single().ok() else { return };
|
let Some((camera, cam_xf)) = cameras.single().ok() else { return };
|
||||||
|
|||||||
@@ -28,13 +28,16 @@ use chrono::Datelike;
|
|||||||
|
|
||||||
use crate::font_plugin::FontResource;
|
use crate::font_plugin::FontResource;
|
||||||
use crate::layout::LayoutResource;
|
use crate::layout::LayoutResource;
|
||||||
use crate::events::{DrawRequestEvent, MoveRequestEvent, UndoRequestEvent};
|
use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent, UndoRequestEvent};
|
||||||
use crate::replay_playback::{
|
use crate::replay_playback::{
|
||||||
step_backwards_replay_playback, step_replay_playback, stop_replay_playback,
|
step_backwards_replay_playback, step_replay_playback, stop_replay_playback,
|
||||||
toggle_pause_replay_playback, ReplayPlaybackState,
|
toggle_pause_replay_playback, ReplayPlaybackState,
|
||||||
};
|
};
|
||||||
|
use solitaire_core::card::{Card, Rank, Suit};
|
||||||
|
use solitaire_core::game_state::GameState;
|
||||||
use solitaire_core::pile::PileType;
|
use solitaire_core::pile::PileType;
|
||||||
use solitaire_data::ReplayMove;
|
use solitaire_data::ReplayMove;
|
||||||
|
use crate::resources::GameStateResource;
|
||||||
use crate::ui_modal::{spawn_modal_button, ButtonVariant};
|
use crate::ui_modal::{spawn_modal_button, ButtonVariant};
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBackground, HighContrastBorder,
|
ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBackground, HighContrastBorder,
|
||||||
@@ -154,6 +157,12 @@ const MOVE_LOG_PREV_ROWS: usize = 2;
|
|||||||
/// preview-shape might need rethinking.
|
/// preview-shape might need rethinking.
|
||||||
const MOVE_LOG_NEXT_ROWS: usize = 2;
|
const MOVE_LOG_NEXT_ROWS: usize = 2;
|
||||||
|
|
||||||
|
/// Vertical offset from the top edge of the window to the top edge of the
|
||||||
|
/// mini-tableau preview panel. Places the panel 8 px below the banner's
|
||||||
|
/// bottom edge so the two surfaces don't overlap. Derived from
|
||||||
|
/// `BANNER_HEIGHT` so the gap stays consistent if the banner ever grows.
|
||||||
|
const MINI_TABLEAU_TOP_OFFSET: f32 = BANNER_HEIGHT + 8.0;
|
||||||
|
|
||||||
/// Background colour alpha for the banner. `BG_ELEVATED_HI` at this alpha
|
/// Background colour alpha for the banner. `BG_ELEVATED_HI` at this alpha
|
||||||
/// reads as a clear "this is a UI strip" callout while still letting the
|
/// reads as a clear "this is a UI strip" callout while still letting the
|
||||||
/// felt show through enough to anchor the banner to the play surface.
|
/// felt show through enough to anchor the banner to the play surface.
|
||||||
@@ -404,6 +413,34 @@ pub struct ReplayOverlayMoveLogNextRow {
|
|||||||
pub offset: u8,
|
pub offset: u8,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Marker added to every top-level entity spawned by [`spawn_overlay`].
|
||||||
|
/// `react_to_state_change` uses a single `Query<Entity, With<DespawnWithReplay>>`
|
||||||
|
/// to despawn all of them, rather than keeping a separate query per
|
||||||
|
/// entity type. Future sibling overlay surfaces just need this marker
|
||||||
|
/// at spawn time — no changes to the despawn logic required.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct DespawnWithReplay;
|
||||||
|
|
||||||
|
/// Marker on the mini-tableau preview panel root. A right-edge-anchored
|
||||||
|
/// panel that shows a compact summary of the live game state during
|
||||||
|
/// replay: the four foundation tops and the stock / waste heads.
|
||||||
|
/// Spawned as a sibling root entity (same lifecycle pattern as
|
||||||
|
/// [`ReplayOverlayMoveLogPanel`]) at `right: 0`, `top: MINI_TABLEAU_TOP_OFFSET`.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct ReplayMiniTableauPanel;
|
||||||
|
|
||||||
|
/// Marker on the foundations row `Text` inside the mini-tableau panel.
|
||||||
|
/// Carries `F: A♠ 7♥ 5♦ K♣` (or `--` for empty slots); repainted by
|
||||||
|
/// `update_mini_tableau` whenever [`GameStateResource`] changes.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct ReplayMiniTableauFoundations;
|
||||||
|
|
||||||
|
/// Marker on the stock/waste row `Text` inside the mini-tableau panel.
|
||||||
|
/// Carries `STK:14 WST:7♥`; repainted by `update_mini_tableau` whenever
|
||||||
|
/// [`GameStateResource`] changes.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct ReplayMiniTableauStockWaste;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Plugin
|
// Plugin
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -439,6 +476,7 @@ impl Plugin for ReplayOverlayPlugin {
|
|||||||
.add_message::<MoveRequestEvent>()
|
.add_message::<MoveRequestEvent>()
|
||||||
.add_message::<DrawRequestEvent>()
|
.add_message::<DrawRequestEvent>()
|
||||||
.add_message::<UndoRequestEvent>()
|
.add_message::<UndoRequestEvent>()
|
||||||
|
.add_message::<StateChangedEvent>()
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
@@ -451,6 +489,8 @@ impl Plugin for ReplayOverlayPlugin {
|
|||||||
update_move_log_active_row,
|
update_move_log_active_row,
|
||||||
update_move_log_prev_rows,
|
update_move_log_prev_rows,
|
||||||
update_move_log_next_rows,
|
update_move_log_next_rows,
|
||||||
|
update_mini_tableau_foundations,
|
||||||
|
update_mini_tableau_stock_waste,
|
||||||
update_pause_button_label,
|
update_pause_button_label,
|
||||||
handle_pause_button,
|
handle_pause_button,
|
||||||
handle_step_button,
|
handle_step_button,
|
||||||
@@ -476,10 +516,8 @@ impl Plugin for ReplayOverlayPlugin {
|
|||||||
fn react_to_state_change(
|
fn react_to_state_change(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
state: Res<ReplayPlaybackState>,
|
state: Res<ReplayPlaybackState>,
|
||||||
existing: Query<Entity, With<ReplayOverlayRoot>>,
|
roots: Query<Entity, With<ReplayOverlayRoot>>,
|
||||||
floating_chips: Query<Entity, With<ReplayFloatingProgressChip>>,
|
despawnable: Query<Entity, With<DespawnWithReplay>>,
|
||||||
move_log_panels: Query<Entity, With<ReplayOverlayMoveLogPanel>>,
|
|
||||||
dim_layers: Query<Entity, With<ReplayTableauDimLayer>>,
|
|
||||||
font_res: Option<Res<FontResource>>,
|
font_res: Option<Res<FontResource>>,
|
||||||
) {
|
) {
|
||||||
if !state.is_changed() {
|
if !state.is_changed() {
|
||||||
@@ -487,30 +525,15 @@ fn react_to_state_change(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let should_be_visible = state.is_playing() || state.is_completed();
|
let should_be_visible = state.is_playing() || state.is_completed();
|
||||||
let already_spawned = existing.iter().next().is_some();
|
let already_spawned = roots.iter().next().is_some();
|
||||||
|
|
||||||
if should_be_visible && !already_spawned {
|
if should_be_visible && !already_spawned {
|
||||||
spawn_overlay(&mut commands, font_res.as_deref(), &state);
|
spawn_overlay(&mut commands, font_res.as_deref(), &state);
|
||||||
} else if !should_be_visible && already_spawned {
|
} else if !should_be_visible && already_spawned {
|
||||||
for entity in &existing {
|
// Despawn all sibling root entities in one loop — every entity
|
||||||
commands.entity(entity).despawn();
|
// spawned by `spawn_overlay` carries `DespawnWithReplay` for
|
||||||
}
|
// exactly this purpose.
|
||||||
// Floating chip lives outside the UI tree (world-space
|
for entity in &despawnable {
|
||||||
// entity), so the banner-root despawn doesn't reach it.
|
|
||||||
// Despawn separately on the same state transition so both
|
|
||||||
// disappear together when the replay ends.
|
|
||||||
for entity in &floating_chips {
|
|
||||||
commands.entity(entity).despawn();
|
|
||||||
}
|
|
||||||
// Move-log panel is also a separate root entity (sibling
|
|
||||||
// of the banner anchored to the viewport's bottom edge),
|
|
||||||
// so the banner-root despawn doesn't reach it either.
|
|
||||||
for entity in &move_log_panels {
|
|
||||||
commands.entity(entity).despawn();
|
|
||||||
}
|
|
||||||
// Tableau dim layer is also a separate root entity — same
|
|
||||||
// pattern as the move-log panel.
|
|
||||||
for entity in &dim_layers {
|
|
||||||
commands.entity(entity).despawn();
|
commands.entity(entity).despawn();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -546,6 +569,8 @@ fn spawn_overlay(
|
|||||||
// entity spawned after the banner closure closes. Mirrors the
|
// entity spawned after the banner closure closes. Mirrors the
|
||||||
// floating-chip clone reasoning.
|
// floating-chip clone reasoning.
|
||||||
let font_handle_for_move_log = font_handle.clone();
|
let font_handle_for_move_log = font_handle.clone();
|
||||||
|
// Fourth clone for the mini-tableau preview panel.
|
||||||
|
let font_handle_for_mini_tableau = font_handle.clone();
|
||||||
|
|
||||||
let banner_label = if state.is_completed() {
|
let banner_label = if state.is_completed() {
|
||||||
"\u{258C} replay complete" // ▌ — cursor-block prefix; matches the splash boot-screen convention.
|
"\u{258C} replay complete" // ▌ — cursor-block prefix; matches the splash boot-screen convention.
|
||||||
@@ -562,6 +587,7 @@ fn spawn_overlay(
|
|||||||
// component — purely visual.
|
// component — purely visual.
|
||||||
commands.spawn((
|
commands.spawn((
|
||||||
ReplayTableauDimLayer,
|
ReplayTableauDimLayer,
|
||||||
|
DespawnWithReplay,
|
||||||
Node {
|
Node {
|
||||||
position_type: PositionType::Absolute,
|
position_type: PositionType::Absolute,
|
||||||
left: Val::Px(0.0),
|
left: Val::Px(0.0),
|
||||||
@@ -585,6 +611,7 @@ fn spawn_overlay(
|
|||||||
commands
|
commands
|
||||||
.spawn((
|
.spawn((
|
||||||
ReplayOverlayRoot,
|
ReplayOverlayRoot,
|
||||||
|
DespawnWithReplay,
|
||||||
Node {
|
Node {
|
||||||
position_type: PositionType::Absolute,
|
position_type: PositionType::Absolute,
|
||||||
left: Val::Px(0.0),
|
left: Val::Px(0.0),
|
||||||
@@ -967,6 +994,7 @@ fn spawn_overlay(
|
|||||||
// when the replay state transitions back to `Inactive`.
|
// when the replay state transitions back to `Inactive`.
|
||||||
commands.spawn((
|
commands.spawn((
|
||||||
ReplayFloatingProgressChip,
|
ReplayFloatingProgressChip,
|
||||||
|
DespawnWithReplay,
|
||||||
Text2d::new(format_progress(state)),
|
Text2d::new(format_progress(state)),
|
||||||
TextFont {
|
TextFont {
|
||||||
font: font_handle_for_floating,
|
font: font_handle_for_floating,
|
||||||
@@ -996,6 +1024,7 @@ fn spawn_overlay(
|
|||||||
commands
|
commands
|
||||||
.spawn((
|
.spawn((
|
||||||
ReplayOverlayMoveLogPanel,
|
ReplayOverlayMoveLogPanel,
|
||||||
|
DespawnWithReplay,
|
||||||
Node {
|
Node {
|
||||||
position_type: PositionType::Absolute,
|
position_type: PositionType::Absolute,
|
||||||
left: Val::Px(0.0),
|
left: Val::Px(0.0),
|
||||||
@@ -1111,6 +1140,68 @@ fn spawn_overlay(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Mini-tableau preview panel — right-edge anchor, just below the banner.
|
||||||
|
// Compact two-row readout: foundation tops then stock/waste head.
|
||||||
|
// Sibling-of-banner pattern (separate root entity, own spawn/despawn).
|
||||||
|
let banner_bg = Color::srgba(
|
||||||
|
BG_ELEVATED_HI.to_srgba().red,
|
||||||
|
BG_ELEVATED_HI.to_srgba().green,
|
||||||
|
BG_ELEVATED_HI.to_srgba().blue,
|
||||||
|
BANNER_ALPHA,
|
||||||
|
);
|
||||||
|
commands
|
||||||
|
.spawn((
|
||||||
|
ReplayMiniTableauPanel,
|
||||||
|
DespawnWithReplay,
|
||||||
|
Node {
|
||||||
|
position_type: PositionType::Absolute,
|
||||||
|
right: Val::Px(0.0),
|
||||||
|
top: Val::Px(MINI_TABLEAU_TOP_OFFSET),
|
||||||
|
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_2),
|
||||||
|
flex_direction: FlexDirection::Column,
|
||||||
|
align_items: AlignItems::FlexStart,
|
||||||
|
row_gap: VAL_SPACE_1,
|
||||||
|
border: UiRect::left(Val::Px(1.0)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(banner_bg),
|
||||||
|
BorderColor::all(BORDER_SUBTLE),
|
||||||
|
ZIndex(Z_REPLAY_OVERLAY),
|
||||||
|
GlobalZIndex(Z_REPLAY_OVERLAY),
|
||||||
|
HighContrastBorder::with_default(BORDER_SUBTLE),
|
||||||
|
))
|
||||||
|
.with_children(|panel| {
|
||||||
|
panel.spawn((
|
||||||
|
Text::new("\u{258C} BOARD"),
|
||||||
|
TextFont {
|
||||||
|
font: font_handle_for_mini_tableau.clone(),
|
||||||
|
font_size: TYPE_CAPTION,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
TextColor(ACCENT_PRIMARY),
|
||||||
|
));
|
||||||
|
panel.spawn((
|
||||||
|
ReplayMiniTableauFoundations,
|
||||||
|
Text::new("F: -- -- -- --"),
|
||||||
|
TextFont {
|
||||||
|
font: font_handle_for_mini_tableau.clone(),
|
||||||
|
font_size: TYPE_CAPTION,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
TextColor(TEXT_PRIMARY),
|
||||||
|
));
|
||||||
|
panel.spawn((
|
||||||
|
ReplayMiniTableauStockWaste,
|
||||||
|
Text::new("STK:-- WST:--"),
|
||||||
|
TextFont {
|
||||||
|
font: font_handle_for_mini_tableau,
|
||||||
|
font_size: TYPE_CAPTION,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pure helper — returns the scrub-fill width as a percentage of the
|
/// Pure helper — returns the scrub-fill width as a percentage of the
|
||||||
@@ -1165,6 +1256,7 @@ fn keybind_footer_mode_text() -> &'static str {
|
|||||||
/// pause/resume, the ESC accelerator for stop, and the ← / →
|
/// pause/resume, the ESC accelerator for stop, and the ← / →
|
||||||
/// accelerators for paused single-move stepping. The footer never
|
/// accelerators for paused single-move stepping. The footer never
|
||||||
/// lists unimplemented keybinds (would lie to users).
|
/// lists unimplemented keybinds (would lie to users).
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
fn keybind_footer_hint_text() -> &'static str {
|
fn keybind_footer_hint_text() -> &'static str {
|
||||||
"[SPACE] pause/resume \u{00B7} [ESC] stop \u{00B7} [\u{2190}\u{2192}] step" // · separator
|
"[SPACE] pause/resume \u{00B7} [ESC] stop \u{00B7} [\u{2190}\u{2192}] step" // · separator
|
||||||
}
|
}
|
||||||
@@ -1553,6 +1645,118 @@ fn format_active_move_row(state: &ReplayPlaybackState) -> String {
|
|||||||
format!("\u{25B6} {body}") // ▶
|
format!("\u{25B6} {body}") // ▶
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mini-tableau format helpers and update system
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Pure helper — short rank symbol. Single character for all ranks
|
||||||
|
/// except Ten which uses "T" (keeps every card a consistent 2-char
|
||||||
|
/// wide render: rank-char + suit-glyph). Players familiar with
|
||||||
|
/// solitaire shorthand read "T" instantly; the suit glyph immediately
|
||||||
|
/// follows and disambiguates from an ambiguous "T".
|
||||||
|
fn format_rank_short(rank: Rank) -> &'static str {
|
||||||
|
match rank {
|
||||||
|
Rank::Ace => "A",
|
||||||
|
Rank::Two => "2",
|
||||||
|
Rank::Three => "3",
|
||||||
|
Rank::Four => "4",
|
||||||
|
Rank::Five => "5",
|
||||||
|
Rank::Six => "6",
|
||||||
|
Rank::Seven => "7",
|
||||||
|
Rank::Eight => "8",
|
||||||
|
Rank::Nine => "9",
|
||||||
|
Rank::Ten => "T",
|
||||||
|
Rank::Jack => "J",
|
||||||
|
Rank::Queen => "Q",
|
||||||
|
Rank::King => "K",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure helper — Unicode suit glyph from FiraMono's covered range
|
||||||
|
/// (U+2660–U+2666). These four code points are confirmed present in
|
||||||
|
/// the bundled FiraMono on Android (verified on Pixel 7 / API 34).
|
||||||
|
fn format_suit_glyph(suit: Suit) -> &'static str {
|
||||||
|
match suit {
|
||||||
|
Suit::Spades => "\u{2660}", // ♠
|
||||||
|
Suit::Hearts => "\u{2665}", // ♥
|
||||||
|
Suit::Diamonds => "\u{2666}", // ♦
|
||||||
|
Suit::Clubs => "\u{2663}", // ♣
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure helper — compact 2-char card label (`rank + suit glyph`) for a
|
||||||
|
/// known card, or `"--"` for an absent top card (empty pile).
|
||||||
|
fn format_card_short(card: Option<&Card>) -> String {
|
||||||
|
match card {
|
||||||
|
Some(c) => format!("{}{}", format_rank_short(c.rank), format_suit_glyph(c.suit)),
|
||||||
|
None => "--".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure helper — one-line summary of the four foundation tops.
|
||||||
|
/// Renders as `F: A♠ 7♥ 5♦ K♣` with `--` for any empty slot.
|
||||||
|
/// Foundation slots are displayed in their natural 0-3 order
|
||||||
|
/// (matching the visual left-to-right order on screen).
|
||||||
|
fn format_foundations_row(game: &GameState) -> String {
|
||||||
|
let slots: [String; 4] = std::array::from_fn(|i| {
|
||||||
|
let top = game.piles
|
||||||
|
.get(&PileType::Foundation(i as u8))
|
||||||
|
.and_then(|p| p.cards.last());
|
||||||
|
format_card_short(top)
|
||||||
|
});
|
||||||
|
format!("F: {} {} {} {}", slots[0], slots[1], slots[2], slots[3])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure helper — one-line stock / waste summary.
|
||||||
|
/// Renders as `STK:N WST:X♠` where N is the stock card count and
|
||||||
|
/// X♠ is the top waste card (or `--` when the waste pile is empty).
|
||||||
|
fn format_stock_waste_row(game: &GameState) -> String {
|
||||||
|
let stock_count = game.piles
|
||||||
|
.get(&PileType::Stock)
|
||||||
|
.map(|p| p.cards.len())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let waste_top = game.piles
|
||||||
|
.get(&PileType::Waste)
|
||||||
|
.and_then(|p| p.cards.last());
|
||||||
|
format!("STK:{} WST:{}", stock_count, format_card_short(waste_top))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Repaints the foundations row whenever [`GameStateResource`] changes.
|
||||||
|
/// Split into its own system (rather than combined with the stock/waste
|
||||||
|
/// updater) to avoid a Bevy B0001 query conflict: two `&mut Text`
|
||||||
|
/// queries in one system are always ambiguous regardless of marker
|
||||||
|
/// filters. Each updater owns exactly one `Query<&mut Text, With<…>>`.
|
||||||
|
fn update_mini_tableau_foundations(
|
||||||
|
game: Option<Res<GameStateResource>>,
|
||||||
|
mut q: Query<&mut Text, With<ReplayMiniTableauFoundations>>,
|
||||||
|
) {
|
||||||
|
let Some(game) = game else { return };
|
||||||
|
if !game.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let text = format_foundations_row(&game.0);
|
||||||
|
for mut t in &mut q {
|
||||||
|
**t = text.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Repaints the stock/waste row whenever [`GameStateResource`] changes.
|
||||||
|
/// Sibling of [`update_mini_tableau_foundations`] — same change-detection
|
||||||
|
/// guard, separate system to avoid the B0001 query conflict.
|
||||||
|
fn update_mini_tableau_stock_waste(
|
||||||
|
game: Option<Res<GameStateResource>>,
|
||||||
|
mut q: Query<&mut Text, With<ReplayMiniTableauStockWaste>>,
|
||||||
|
) {
|
||||||
|
let Some(game) = game else { return };
|
||||||
|
if !game.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let text = format_stock_waste_row(&game.0);
|
||||||
|
for mut t in &mut q {
|
||||||
|
**t = text.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Playback-control button handlers
|
// Playback-control button handlers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1681,6 +1885,7 @@ fn handle_pause_keyboard(
|
|||||||
/// resets to 0 on key release so the next fresh press fires
|
/// resets to 0 on key release so the next fresh press fires
|
||||||
/// immediately. This matches the mockup's `[← →] scrub`
|
/// immediately. This matches the mockup's `[← →] scrub`
|
||||||
/// terminology while keeping single-press = single-step semantics.
|
/// terminology while keeping single-press = single-step semantics.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn handle_arrow_keyboard(
|
fn handle_arrow_keyboard(
|
||||||
keys: Option<Res<ButtonInput<KeyCode>>>,
|
keys: Option<Res<ButtonInput<KeyCode>>>,
|
||||||
time: Res<Time>,
|
time: Res<Time>,
|
||||||
@@ -1689,10 +1894,22 @@ fn handle_arrow_keyboard(
|
|||||||
mut moves_writer: MessageWriter<MoveRequestEvent>,
|
mut moves_writer: MessageWriter<MoveRequestEvent>,
|
||||||
mut draws_writer: MessageWriter<DrawRequestEvent>,
|
mut draws_writer: MessageWriter<DrawRequestEvent>,
|
||||||
mut undo_writer: MessageWriter<UndoRequestEvent>,
|
mut undo_writer: MessageWriter<UndoRequestEvent>,
|
||||||
|
mut state_changed: MessageReader<StateChangedEvent>,
|
||||||
|
// `true` while a backward step is in-flight: cursor was decremented and
|
||||||
|
// `UndoRequestEvent` was written, but `handle_undo` hasn't applied it yet.
|
||||||
|
// Cleared when `StateChangedEvent` confirms the game state has caught up.
|
||||||
|
// Prevents rapid ← presses from accumulating multiple cursor decrements
|
||||||
|
// before any undo is applied (Bug #16).
|
||||||
|
mut back_pending: Local<bool>,
|
||||||
) {
|
) {
|
||||||
let Some(keys) = keys else { return };
|
let Some(keys) = keys else { return };
|
||||||
let dt = time.delta_secs();
|
let dt = time.delta_secs();
|
||||||
|
|
||||||
|
// Clear the in-flight flag once the game confirms the undo landed.
|
||||||
|
if state_changed.read().count() > 0 {
|
||||||
|
*back_pending = false;
|
||||||
|
}
|
||||||
|
|
||||||
// Right (forward step) — initial press fires immediately;
|
// Right (forward step) — initial press fires immediately;
|
||||||
// held repeats fire when the accumulator crosses the interval.
|
// held repeats fire when the accumulator crosses the interval.
|
||||||
if keys.just_pressed(KeyCode::ArrowRight) {
|
if keys.just_pressed(KeyCode::ArrowRight) {
|
||||||
@@ -1708,14 +1925,28 @@ fn handle_arrow_keyboard(
|
|||||||
hold.right_held_secs = 0.0;
|
hold.right_held_secs = 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Left (backwards step) — symmetric to the right path.
|
// Left (backwards step) — gate on `back_pending` so at most one undo
|
||||||
|
// is in-flight at a time. The cursor is only decremented inside
|
||||||
|
// `step_backwards_replay_playback`, which also writes `UndoRequestEvent`.
|
||||||
|
// `back_pending` is set after a successful step and cleared above when
|
||||||
|
// `StateChangedEvent` confirms the undo was applied.
|
||||||
if keys.just_pressed(KeyCode::ArrowLeft) {
|
if keys.just_pressed(KeyCode::ArrowLeft) {
|
||||||
step_backwards_replay_playback(&mut state, &mut undo_writer);
|
if !*back_pending {
|
||||||
|
let fired = step_backwards_replay_playback(&mut state, &mut undo_writer);
|
||||||
|
if fired {
|
||||||
|
*back_pending = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
hold.left_held_secs = 0.0;
|
hold.left_held_secs = 0.0;
|
||||||
} else if keys.pressed(KeyCode::ArrowLeft) {
|
} else if keys.pressed(KeyCode::ArrowLeft) {
|
||||||
hold.left_held_secs += dt;
|
hold.left_held_secs += dt;
|
||||||
if hold.left_held_secs >= SCRUB_REPEAT_INTERVAL_SECS {
|
if hold.left_held_secs >= SCRUB_REPEAT_INTERVAL_SECS {
|
||||||
step_backwards_replay_playback(&mut state, &mut undo_writer);
|
if !*back_pending {
|
||||||
|
let fired = step_backwards_replay_playback(&mut state, &mut undo_writer);
|
||||||
|
if fired {
|
||||||
|
*back_pending = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
hold.left_held_secs = 0.0;
|
hold.left_held_secs = 0.0;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -1762,6 +1993,7 @@ fn handle_stop_keyboard(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
|
use solitaire_core::card::{Rank, Suit};
|
||||||
use solitaire_core::game_state::{DrawMode, GameMode};
|
use solitaire_core::game_state::{DrawMode, GameMode};
|
||||||
use solitaire_data::{Replay, ReplayMove};
|
use solitaire_data::{Replay, ReplayMove};
|
||||||
|
|
||||||
@@ -3989,4 +4221,113 @@ mod tests {
|
|||||||
fn dim_layer_z_is_below_replay_chrome() {
|
fn dim_layer_z_is_below_replay_chrome() {
|
||||||
const { assert!(Z_REPLAY_DIM < Z_REPLAY_OVERLAY) }
|
const { assert!(Z_REPLAY_DIM < Z_REPLAY_OVERLAY) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Mini-tableau preview tests
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn mini_tableau_panel_count(app: &mut App) -> usize {
|
||||||
|
app.world_mut()
|
||||||
|
.query::<&ReplayMiniTableauPanel>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mini-tableau panel spawns alongside the other overlay surfaces
|
||||||
|
/// when playback starts and despawns when it ends.
|
||||||
|
#[test]
|
||||||
|
fn mini_tableau_panel_spawns_and_despawns_with_overlay() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
|
||||||
|
app.update();
|
||||||
|
assert_eq!(
|
||||||
|
mini_tableau_panel_count(&mut app),
|
||||||
|
0,
|
||||||
|
"no mini-tableau panel while playback is Inactive",
|
||||||
|
);
|
||||||
|
|
||||||
|
set_state(
|
||||||
|
&mut app,
|
||||||
|
ReplayPlaybackState::Playing {
|
||||||
|
replay: synthetic_replay(5),
|
||||||
|
cursor: 0,
|
||||||
|
secs_to_next: 0.5,
|
||||||
|
paused: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
app.update();
|
||||||
|
assert_eq!(
|
||||||
|
mini_tableau_panel_count(&mut app),
|
||||||
|
1,
|
||||||
|
"mini-tableau panel must spawn when playback starts",
|
||||||
|
);
|
||||||
|
|
||||||
|
set_state(&mut app, ReplayPlaybackState::Inactive);
|
||||||
|
app.update();
|
||||||
|
assert_eq!(
|
||||||
|
mini_tableau_panel_count(&mut app),
|
||||||
|
0,
|
||||||
|
"mini-tableau panel must despawn when playback ends",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `format_rank_short` maps every `Rank` variant to a single ASCII
|
||||||
|
/// character except Ten which maps to `"T"`.
|
||||||
|
#[test]
|
||||||
|
fn format_rank_short_all_ranks() {
|
||||||
|
assert_eq!(format_rank_short(Rank::Ace), "A");
|
||||||
|
assert_eq!(format_rank_short(Rank::Two), "2");
|
||||||
|
assert_eq!(format_rank_short(Rank::Three), "3");
|
||||||
|
assert_eq!(format_rank_short(Rank::Four), "4");
|
||||||
|
assert_eq!(format_rank_short(Rank::Five), "5");
|
||||||
|
assert_eq!(format_rank_short(Rank::Six), "6");
|
||||||
|
assert_eq!(format_rank_short(Rank::Seven), "7");
|
||||||
|
assert_eq!(format_rank_short(Rank::Eight), "8");
|
||||||
|
assert_eq!(format_rank_short(Rank::Nine), "9");
|
||||||
|
assert_eq!(format_rank_short(Rank::Ten), "T");
|
||||||
|
assert_eq!(format_rank_short(Rank::Jack), "J");
|
||||||
|
assert_eq!(format_rank_short(Rank::Queen), "Q");
|
||||||
|
assert_eq!(format_rank_short(Rank::King), "K");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `format_suit_glyph` returns the FiraMono-covered Unicode suit
|
||||||
|
/// glyphs for each `Suit` variant (U+2660–U+2666 confirmed on Android).
|
||||||
|
#[test]
|
||||||
|
fn format_suit_glyph_all_suits() {
|
||||||
|
assert_eq!(format_suit_glyph(Suit::Spades), "\u{2660}");
|
||||||
|
assert_eq!(format_suit_glyph(Suit::Hearts), "\u{2665}");
|
||||||
|
assert_eq!(format_suit_glyph(Suit::Diamonds), "\u{2666}");
|
||||||
|
assert_eq!(format_suit_glyph(Suit::Clubs), "\u{2663}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `format_foundations_row` with a freshly-dealt game (all empty).
|
||||||
|
#[test]
|
||||||
|
fn format_foundations_row_empty_board() {
|
||||||
|
let game = solitaire_core::game_state::GameState::new_with_mode(
|
||||||
|
42,
|
||||||
|
solitaire_core::game_state::DrawMode::DrawOne,
|
||||||
|
solitaire_core::game_state::GameMode::Classic,
|
||||||
|
);
|
||||||
|
assert_eq!(format_foundations_row(&game), "F: -- -- -- --");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `format_stock_waste_row` with a freshly-dealt game: stock has
|
||||||
|
/// 24 cards, waste is empty.
|
||||||
|
#[test]
|
||||||
|
fn format_stock_waste_row_initial_state() {
|
||||||
|
let game = solitaire_core::game_state::GameState::new_with_mode(
|
||||||
|
42,
|
||||||
|
solitaire_core::game_state::DrawMode::DrawOne,
|
||||||
|
solitaire_core::game_state::GameMode::Classic,
|
||||||
|
);
|
||||||
|
let text = format_stock_waste_row(&game);
|
||||||
|
assert!(
|
||||||
|
text.starts_with("STK:"),
|
||||||
|
"row must start with STK: prefix; got {text:?}",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
text.contains("WST:--"),
|
||||||
|
"waste must show -- on a fresh deal; got {text:?}",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ pub fn start_replay_playback(
|
|||||||
) {
|
) {
|
||||||
use solitaire_core::game_state::GameState;
|
use solitaire_core::game_state::GameState;
|
||||||
|
|
||||||
let fresh = GameState::new_with_mode(replay.seed, replay.draw_mode.clone(), replay.mode);
|
let fresh = GameState::new_with_mode(replay.seed, replay.draw_mode, replay.mode);
|
||||||
commands.insert_resource(GameStateResource(fresh));
|
commands.insert_resource(GameStateResource(fresh));
|
||||||
|
|
||||||
// Initial `secs_to_next` uses the constant rather than reading
|
// Initial `secs_to_next` uses the constant rather than reading
|
||||||
@@ -512,6 +512,7 @@ pub struct ReplayPlaybackPlugin;
|
|||||||
impl Plugin for ReplayPlaybackPlugin {
|
impl Plugin for ReplayPlaybackPlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.init_resource::<ReplayPlaybackState>()
|
app.init_resource::<ReplayPlaybackState>()
|
||||||
|
.add_message::<StateChangedEvent>()
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
@@ -552,7 +553,7 @@ mod tests {
|
|||||||
.add_plugins(GamePlugin::headless())
|
.add_plugins(GamePlugin::headless())
|
||||||
.add_plugins(ReplayPlaybackPlugin);
|
.add_plugins(ReplayPlaybackPlugin);
|
||||||
// Disable game-state persistence so tests don't touch the
|
// Disable game-state persistence so tests don't touch the
|
||||||
// real ~/.local/share/solitaire_quest/game_state.json.
|
// real ~/.local/share/ferrous_solitaire/game_state.json.
|
||||||
app.insert_resource(crate::game_plugin::GameStatePath(None));
|
app.insert_resource(crate::game_plugin::GameStatePath(None));
|
||||||
app.insert_resource(crate::game_plugin::ReplayPath(None));
|
app.insert_resource(crate::game_plugin::ReplayPath(None));
|
||||||
// Tick once so any startup systems flush before the first
|
// Tick once so any startup systems flush before the first
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
//! Bevy resources owned by the engine crate.
|
//! Bevy resources owned by the engine crate.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use bevy::math::Vec2;
|
use bevy::math::Vec2;
|
||||||
use bevy::prelude::Resource;
|
use bevy::prelude::{warn, Resource};
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use solitaire_core::game_state::GameState;
|
use solitaire_core::game_state::GameState;
|
||||||
use solitaire_core::pile::PileType;
|
use solitaire_core::pile::PileType;
|
||||||
@@ -111,3 +113,66 @@ pub struct HintCycleIndex(pub usize);
|
|||||||
/// returns to the same position in the list without re-scrolling.
|
/// returns to the same position in the list without re-scrolling.
|
||||||
#[derive(Resource, Debug, Clone, Default)]
|
#[derive(Resource, Debug, Clone, Default)]
|
||||||
pub struct SettingsScrollPos(pub f32);
|
pub struct SettingsScrollPos(pub f32);
|
||||||
|
|
||||||
|
/// Set to `true` by an input system when a touch tap is consumed by game logic
|
||||||
|
/// (e.g. drawing from stock). `toggle_hud_on_tap` checks this flag on
|
||||||
|
/// `TouchPhase::Ended` and skips the HUD visibility toggle when set, then
|
||||||
|
/// resets it to `false` so subsequent taps behave normally.
|
||||||
|
#[derive(Resource, Debug, Clone, Default)]
|
||||||
|
pub struct GameInputConsumedResource(pub bool);
|
||||||
|
|
||||||
|
/// Shared Tokio runtime used by all async-task closures that need HTTP I/O.
|
||||||
|
///
|
||||||
|
/// Bevy's `AsyncComputeTaskPool` uses `async-executor` (not Tokio), so spawned
|
||||||
|
/// closures that call `reqwest`/`hyper` need a Tokio reactor. A single
|
||||||
|
/// multi-threaded runtime is built once at startup and its `Arc` cloned cheaply
|
||||||
|
/// into every network task — safe for concurrent `block_on` calls from multiple
|
||||||
|
/// worker threads.
|
||||||
|
#[derive(Resource, Clone)]
|
||||||
|
pub struct TokioRuntimeResource(pub Arc<tokio::runtime::Runtime>);
|
||||||
|
|
||||||
|
impl TokioRuntimeResource {
|
||||||
|
/// Attempts to build the shared multi-threaded Tokio runtime.
|
||||||
|
///
|
||||||
|
/// Returns `Err` if the OS refuses to create worker threads (e.g. resource
|
||||||
|
/// limits on Android). Callers should log the error and disable sync
|
||||||
|
/// features rather than panicking.
|
||||||
|
pub fn new() -> Result<Self, tokio::io::Error> {
|
||||||
|
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||||
|
.worker_threads(2)
|
||||||
|
.enable_all()
|
||||||
|
.build()?;
|
||||||
|
Ok(Self(Arc::new(rt)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TokioRuntimeResource {
|
||||||
|
fn default() -> Self {
|
||||||
|
// Try multi-threaded first; fall back to current-thread (single
|
||||||
|
// worker) if the OS refuses to create additional threads. Neither
|
||||||
|
// path uses `.expect()` so this never panics at startup.
|
||||||
|
match tokio::runtime::Builder::new_multi_thread()
|
||||||
|
.worker_threads(2)
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(rt) => Self(Arc::new(rt)),
|
||||||
|
Err(e) => {
|
||||||
|
warn!(
|
||||||
|
"sync: failed to build multi-thread Tokio runtime ({e}); \
|
||||||
|
falling back to current-thread runtime"
|
||||||
|
);
|
||||||
|
// current_thread runtime never spawns OS threads, so it
|
||||||
|
// succeeds even under tight sandboxing.
|
||||||
|
let rt = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.expect(
|
||||||
|
"current-thread Tokio runtime failed — \
|
||||||
|
the process cannot do any async I/O",
|
||||||
|
);
|
||||||
|
Self(Arc::new(rt))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
//! changes flow through automatically.
|
//! changes flow through automatically.
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
use bevy::window::{AppLifecycle, WindowResized};
|
||||||
|
|
||||||
use crate::ui_modal::ModalScrim;
|
use crate::ui_modal::ModalScrim;
|
||||||
|
|
||||||
@@ -51,15 +52,39 @@ pub struct SafeAreaAnchoredTop {
|
|||||||
pub base_top: f32,
|
pub base_top: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Marker for `Node` entities whose `bottom` offset should be re-applied
|
||||||
|
/// as `base_bottom + SafeAreaInsets::bottom / scale`.
|
||||||
|
///
|
||||||
|
/// Use this for elements anchored to the bottom edge (e.g. a bottom action
|
||||||
|
/// bar) so they clear the Android gesture-navigation zone automatically.
|
||||||
|
#[derive(Component, Debug, Clone, Copy)]
|
||||||
|
pub struct SafeAreaAnchoredBottom {
|
||||||
|
pub base_bottom: f32,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct SafeAreaInsetsPlugin;
|
pub struct SafeAreaInsetsPlugin;
|
||||||
|
|
||||||
impl Plugin for SafeAreaInsetsPlugin {
|
impl Plugin for SafeAreaInsetsPlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.init_resource::<SafeAreaInsets>()
|
// Both message types may already be registered by GamePlugin / TablePlugin;
|
||||||
.add_systems(Update, (apply_safe_area_anchors, apply_safe_area_to_modal_scrims));
|
// add_message is idempotent.
|
||||||
|
app.add_message::<AppLifecycle>()
|
||||||
|
.add_message::<WindowResized>()
|
||||||
|
.init_resource::<SafeAreaInsets>()
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
(
|
||||||
|
apply_safe_area_anchors,
|
||||||
|
apply_safe_area_bottom_anchors,
|
||||||
|
apply_safe_area_to_modal_scrims,
|
||||||
|
on_app_resumed,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
app.add_systems(Update, android::refresh_insets);
|
app.init_resource::<android::SafeAreaPollTries>()
|
||||||
|
.add_systems(Update, android::refresh_insets)
|
||||||
|
.add_systems(Update, android::rearm_on_resumed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +114,23 @@ fn apply_safe_area_anchors(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Re-applies `base_bottom + insets.bottom / scale` to every entity carrying
|
||||||
|
/// [`SafeAreaAnchoredBottom`] whenever [`SafeAreaInsets`] changes.
|
||||||
|
fn apply_safe_area_bottom_anchors(
|
||||||
|
insets: Res<SafeAreaInsets>,
|
||||||
|
windows: Query<&Window>,
|
||||||
|
mut q: Query<(&SafeAreaAnchoredBottom, &mut Node)>,
|
||||||
|
) {
|
||||||
|
if !insets.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
|
||||||
|
let bottom_logical = insets.bottom / scale;
|
||||||
|
for (anchor, mut node) in &mut q {
|
||||||
|
node.bottom = Val::Px(anchor.base_bottom + bottom_logical);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Pads the bottom of every [`ModalScrim`] by the logical bottom inset so
|
/// Pads the bottom of every [`ModalScrim`] by the logical bottom inset so
|
||||||
/// modal cards don't extend into the Android gesture-navigation zone.
|
/// modal cards don't extend into the Android gesture-navigation zone.
|
||||||
///
|
///
|
||||||
@@ -112,33 +154,73 @@ fn apply_safe_area_to_modal_scrims(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Emits a synthetic `WindowResized` on `AppLifecycle::WillResume` so that
|
||||||
|
/// `on_window_resized` (in `table_plugin`) recomputes the board layout with
|
||||||
|
/// whatever `SafeAreaInsets` are current at that moment.
|
||||||
|
///
|
||||||
|
/// On Android the `android::rearm_on_resumed` system runs in the same frame
|
||||||
|
/// and resets both `SafeAreaPollTries` and `SafeAreaInsets` to zero, causing
|
||||||
|
/// `refresh_insets` to re-poll JNI over the next few frames. When it resolves
|
||||||
|
/// the correct values, `on_safe_area_changed` in `table_plugin` emits a second
|
||||||
|
/// synthetic `WindowResized` and the layout converges to the right position.
|
||||||
|
///
|
||||||
|
/// On non-Android targets this handler still fires — it ensures that a resume
|
||||||
|
/// event always refreshes the layout (e.g., after a minimise/restore on
|
||||||
|
/// desktop) even though insets are always zero.
|
||||||
|
fn on_app_resumed(
|
||||||
|
mut lifecycle: MessageReader<AppLifecycle>,
|
||||||
|
windows: Query<(Entity, &Window)>,
|
||||||
|
mut resize_events: MessageWriter<WindowResized>,
|
||||||
|
) {
|
||||||
|
for event in lifecycle.read() {
|
||||||
|
if !matches!(event, AppLifecycle::WillResume) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some((entity, window)) = windows.iter().next() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
resize_events.write(WindowResized {
|
||||||
|
window: entity,
|
||||||
|
width: window.resolution.width(),
|
||||||
|
height: window.resolution.height(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
mod android {
|
mod android {
|
||||||
use super::SafeAreaInsets;
|
use super::{AppLifecycle, SafeAreaInsets};
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
/// Tracks how many frames `refresh_insets` has polled. Stored as a
|
||||||
|
/// `Resource` (not `Local`) so that `rearm_on_resumed` can reset it to 0
|
||||||
|
/// when `AppLifecycle::WillResume` fires, causing the poller to re-query JNI
|
||||||
|
/// after a background/foreground cycle.
|
||||||
|
#[derive(Resource, Default)]
|
||||||
|
pub(super) struct SafeAreaPollTries(pub u32);
|
||||||
|
|
||||||
/// Polls Android for safe-area insets until we get a non-zero
|
/// Polls Android for safe-area insets until we get a non-zero
|
||||||
/// reading, then stops. `getRootWindowInsets()` returns `null` (or
|
/// reading, then stops. `getRootWindowInsets()` returns `null` (or
|
||||||
/// all-zero `Insets`) until the decor view has been laid out, which
|
/// all-zero `Insets`) until the decor view has been laid out, which
|
||||||
/// is typically frame 1–3 of a fresh launch.
|
/// is typically frame 1–3 of a fresh launch.
|
||||||
pub(super) fn refresh_insets(
|
pub(super) fn refresh_insets(
|
||||||
mut insets: ResMut<SafeAreaInsets>,
|
mut insets: ResMut<SafeAreaInsets>,
|
||||||
mut tries: Local<u32>,
|
mut poll: ResMut<SafeAreaPollTries>,
|
||||||
) {
|
) {
|
||||||
// Cap retries so we don't burn CPU forever on edge-to-edge
|
// Cap retries so we don't burn CPU forever on edge-to-edge
|
||||||
// devices that genuinely report zero insets.
|
// devices that genuinely report zero insets.
|
||||||
const MAX_TRIES: u32 = 120; // ~2 seconds @ 60 fps
|
const MAX_TRIES: u32 = 120; // ~2 seconds @ 60 fps
|
||||||
|
|
||||||
if *tries >= MAX_TRIES || insets.is_populated() {
|
if poll.0 >= MAX_TRIES || insets.is_populated() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
*tries += 1;
|
poll.0 += 1;
|
||||||
|
|
||||||
match query_insets() {
|
match query_insets() {
|
||||||
Ok(v) if v.is_populated() => {
|
Ok(v) if v.is_populated() => {
|
||||||
info!(
|
info!(
|
||||||
"safe_area: insets resolved top={} bottom={} left={} right={} (after {} frames)",
|
"safe_area: insets resolved top={} bottom={} left={} right={} (after {} frames)",
|
||||||
v.top, v.bottom, v.left, v.right, *tries
|
v.top, v.bottom, v.left, v.right, poll.0
|
||||||
);
|
);
|
||||||
*insets = v;
|
*insets = v;
|
||||||
}
|
}
|
||||||
@@ -147,13 +229,35 @@ mod android {
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// Don't spam — log once and let polling continue silently.
|
// Don't spam — log once and let polling continue silently.
|
||||||
if *tries == 1 {
|
if poll.0 == 1 {
|
||||||
warn!("safe_area: JNI query failed (will retry): {e}");
|
warn!("safe_area: JNI query failed (will retry): {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resets the inset poller and clears cached insets on
|
||||||
|
/// `AppLifecycle::WillResume` so that `refresh_insets` re-queries JNI in the
|
||||||
|
/// frames immediately after the app returns to the foreground.
|
||||||
|
///
|
||||||
|
/// Clearing `SafeAreaInsets` to the default (all-zero) fires
|
||||||
|
/// `on_safe_area_changed` in `table_plugin`, which emits a synthetic
|
||||||
|
/// `WindowResized`. `on_window_resized` then recomputes the layout;
|
||||||
|
/// once `refresh_insets` resolves the real values a second synthetic
|
||||||
|
/// `WindowResized` fires and the layout converges to the correct position.
|
||||||
|
pub(super) fn rearm_on_resumed(
|
||||||
|
mut lifecycle: MessageReader<AppLifecycle>,
|
||||||
|
mut poll: ResMut<SafeAreaPollTries>,
|
||||||
|
mut insets: ResMut<SafeAreaInsets>,
|
||||||
|
) {
|
||||||
|
for event in lifecycle.read() {
|
||||||
|
if matches!(event, AppLifecycle::WillResume) {
|
||||||
|
poll.0 = 0;
|
||||||
|
*insets = SafeAreaInsets::default();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn query_insets() -> Result<SafeAreaInsets, String> {
|
fn query_insets() -> Result<SafeAreaInsets, String> {
|
||||||
use bevy::android::ANDROID_APP;
|
use bevy::android::ANDROID_APP;
|
||||||
use jni::{objects::JObject, JavaVM};
|
use jni::{objects::JObject, JavaVM};
|
||||||
|
|||||||
@@ -156,7 +156,13 @@ impl Plugin for SelectionPlugin {
|
|||||||
.in_set(SelectionKeySet)
|
.in_set(SelectionKeySet)
|
||||||
.before(GameMutation),
|
.before(GameMutation),
|
||||||
clear_selection_on_state_change.after(GameMutation),
|
clear_selection_on_state_change.after(GameMutation),
|
||||||
update_selection_highlight.after(GameMutation),
|
update_selection_highlight
|
||||||
|
.after(GameMutation)
|
||||||
|
.run_if(
|
||||||
|
resource_changed::<SelectionState>
|
||||||
|
.or(resource_changed::<KeyboardDragState>)
|
||||||
|
.or(resource_changed::<crate::GameStateResource>),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ pub struct SettingsChangedEvent(pub Settings);
|
|||||||
|
|
||||||
/// Marker on the root Settings panel entity.
|
/// Marker on the root Settings panel entity.
|
||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
struct SettingsPanel;
|
pub struct SettingsPanel;
|
||||||
|
|
||||||
/// Marks the `Text` node showing the live SFX volume value.
|
/// Marks the `Text` node showing the live SFX volume value.
|
||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
@@ -401,8 +401,10 @@ impl Plugin for SettingsPlugin {
|
|||||||
update_anim_speed_text,
|
update_anim_speed_text,
|
||||||
update_color_blind_text,
|
update_color_blind_text,
|
||||||
update_high_contrast_text,
|
update_high_contrast_text,
|
||||||
update_high_contrast_borders,
|
update_high_contrast_borders
|
||||||
update_high_contrast_backgrounds,
|
.run_if(resource_changed::<SettingsResource>),
|
||||||
|
update_high_contrast_backgrounds
|
||||||
|
.run_if(resource_changed::<SettingsResource>),
|
||||||
update_reduce_motion_text,
|
update_reduce_motion_text,
|
||||||
update_tooltip_delay_text,
|
update_tooltip_delay_text,
|
||||||
update_time_bonus_multiplier_text,
|
update_time_bonus_multiplier_text,
|
||||||
@@ -508,6 +510,7 @@ fn toggle_settings_screen(
|
|||||||
fn sync_settings_panel_visibility(
|
fn sync_settings_panel_visibility(
|
||||||
screen: Res<SettingsScreen>,
|
screen: Res<SettingsScreen>,
|
||||||
panels: Query<Entity, With<SettingsPanel>>,
|
panels: Query<Entity, With<SettingsPanel>>,
|
||||||
|
other_modal_scrims: Query<(), (With<ModalScrim>, Without<SettingsPanel>)>,
|
||||||
scroll_nodes: Query<&ScrollPosition, With<SettingsScrollNode>>,
|
scroll_nodes: Query<&ScrollPosition, With<SettingsScrollNode>>,
|
||||||
mut scroll_pos: ResMut<SettingsScrollPos>,
|
mut scroll_pos: ResMut<SettingsScrollPos>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
@@ -523,7 +526,7 @@ fn sync_settings_panel_visibility(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if screen.0 {
|
if screen.0 {
|
||||||
if panels.is_empty() {
|
if panels.is_empty() && other_modal_scrims.is_empty() {
|
||||||
let status_label = sync_status
|
let status_label = sync_status
|
||||||
.map_or_else(|| "Status: local only".to_string(), |s| sync_status_label(&s.0));
|
.map_or_else(|| "Status: local only".to_string(), |s| sync_status_label(&s.0));
|
||||||
let unlocked_backs = progress
|
let unlocked_backs = progress
|
||||||
@@ -1134,6 +1137,7 @@ fn handle_sync_buttons(
|
|||||||
mut configure_sync: MessageWriter<SyncConfigureRequestEvent>,
|
mut configure_sync: MessageWriter<SyncConfigureRequestEvent>,
|
||||||
mut logout_sync: MessageWriter<SyncLogoutRequestEvent>,
|
mut logout_sync: MessageWriter<SyncLogoutRequestEvent>,
|
||||||
mut delete_account: MessageWriter<DeleteAccountRequestEvent>,
|
mut delete_account: MessageWriter<DeleteAccountRequestEvent>,
|
||||||
|
mut screen: ResMut<SettingsScreen>,
|
||||||
) {
|
) {
|
||||||
for (interaction, button) in &interaction_query {
|
for (interaction, button) in &interaction_query {
|
||||||
if *interaction != Interaction::Pressed {
|
if *interaction != Interaction::Pressed {
|
||||||
@@ -1141,7 +1145,12 @@ fn handle_sync_buttons(
|
|||||||
}
|
}
|
||||||
match button {
|
match button {
|
||||||
SettingsButton::SyncNow => { manual_sync.write(ManualSyncRequestEvent); }
|
SettingsButton::SyncNow => { manual_sync.write(ManualSyncRequestEvent); }
|
||||||
SettingsButton::ConnectSync => { configure_sync.write(SyncConfigureRequestEvent); }
|
SettingsButton::ConnectSync => {
|
||||||
|
// Close settings before the sync-setup modal opens so the
|
||||||
|
// guard in open_sync_setup_modal doesn't block on our own scrim.
|
||||||
|
screen.0 = false;
|
||||||
|
configure_sync.write(SyncConfigureRequestEvent);
|
||||||
|
}
|
||||||
SettingsButton::DisconnectSync => { logout_sync.write(SyncLogoutRequestEvent); }
|
SettingsButton::DisconnectSync => { logout_sync.write(SyncLogoutRequestEvent); }
|
||||||
SettingsButton::DeleteAccount => { delete_account.write(DeleteAccountRequestEvent); }
|
SettingsButton::DeleteAccount => { delete_account.write(DeleteAccountRequestEvent); }
|
||||||
_ => {}
|
_ => {}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ pub struct StatsCell;
|
|||||||
/// Resource holding the rolling [`ReplayHistory`] of recent winning
|
/// Resource holding the rolling [`ReplayHistory`] of recent winning
|
||||||
/// replays.
|
/// replays.
|
||||||
///
|
///
|
||||||
/// Populated from `<data_dir>/solitaire_quest/replays.json` at startup
|
/// Populated from `<data_dir>/ferrous_solitaire/replays.json` at startup
|
||||||
/// and refreshed in-place whenever the engine writes a new winning
|
/// and refreshed in-place whenever the engine writes a new winning
|
||||||
/// replay so the Stats overlay's selector always reflects the current
|
/// replay so the Stats overlay's selector always reflects the current
|
||||||
/// on-disk history.
|
/// on-disk history.
|
||||||
@@ -166,7 +166,7 @@ impl Default for StatsPlugin {
|
|||||||
|
|
||||||
impl StatsPlugin {
|
impl StatsPlugin {
|
||||||
/// Plugin configured with no persistence. Use in tests and headless apps
|
/// Plugin configured with no persistence. Use in tests and headless apps
|
||||||
/// where touching `~/.local/share/solitaire_quest/stats.json` would be
|
/// where touching `~/.local/share/ferrous_solitaire/stats.json` would be
|
||||||
/// incorrect.
|
/// incorrect.
|
||||||
pub fn headless() -> Self {
|
pub fn headless() -> Self {
|
||||||
Self { storage_path: None }
|
Self { storage_path: None }
|
||||||
@@ -220,7 +220,13 @@ impl Plugin for StatsPlugin {
|
|||||||
)
|
)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
handle_forfeit.before(GameMutation),
|
// handle_forfeit must run before update_stats_on_new_game so
|
||||||
|
// the NewGameRequestEvent it emits is not visible to
|
||||||
|
// update_stats_on_new_game in the same frame — otherwise
|
||||||
|
// record_abandoned() fires twice on every forfeit (#21).
|
||||||
|
handle_forfeit
|
||||||
|
.before(GameMutation)
|
||||||
|
.before(update_stats_on_new_game),
|
||||||
)
|
)
|
||||||
.add_systems(Update, toggle_stats_screen.after(GameMutation))
|
.add_systems(Update, toggle_stats_screen.after(GameMutation))
|
||||||
.add_systems(Update, handle_stats_close_button)
|
.add_systems(Update, handle_stats_close_button)
|
||||||
|
|||||||
@@ -26,12 +26,12 @@ use solitaire_sync::{merge, SyncPayload, SyncResponse};
|
|||||||
|
|
||||||
use crate::achievement_plugin::{AchievementsResource, AchievementsStoragePath};
|
use crate::achievement_plugin::{AchievementsResource, AchievementsStoragePath};
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
GameWonEvent, InfoToastEvent, ManualSyncRequestEvent, SyncCompleteEvent,
|
GameWonEvent, ManualSyncRequestEvent, SyncCompleteEvent, SyncConfigureRequestEvent,
|
||||||
SyncConfigureRequestEvent,
|
WarningToastEvent,
|
||||||
};
|
};
|
||||||
use crate::game_plugin::RecordingReplay;
|
use crate::game_plugin::RecordingReplay;
|
||||||
use crate::progress_plugin::{ProgressResource, ProgressStoragePath};
|
use crate::progress_plugin::{ProgressResource, ProgressStoragePath};
|
||||||
use crate::resources::{GameStateResource, SyncStatus, SyncStatusResource};
|
use crate::resources::{GameStateResource, SyncStatus, SyncStatusResource, TokioRuntimeResource};
|
||||||
use crate::stats_plugin::{LatestReplayPath, ReplayHistoryResource, StatsResource, StatsStoragePath};
|
use crate::stats_plugin::{LatestReplayPath, ReplayHistoryResource, StatsResource, StatsStoragePath};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -108,7 +108,14 @@ impl Plugin for SyncPlugin {
|
|||||||
.add_message::<ManualSyncRequestEvent>()
|
.add_message::<ManualSyncRequestEvent>()
|
||||||
.add_message::<SyncCompleteEvent>()
|
.add_message::<SyncCompleteEvent>()
|
||||||
.add_message::<SyncConfigureRequestEvent>()
|
.add_message::<SyncConfigureRequestEvent>()
|
||||||
.add_message::<InfoToastEvent>()
|
.add_message::<WarningToastEvent>();
|
||||||
|
|
||||||
|
// Build the shared Tokio runtime; disable all network sync if the OS
|
||||||
|
// refuses to create threads (resource-limited environments, sandboxed
|
||||||
|
// Android builds, etc.).
|
||||||
|
match TokioRuntimeResource::new() {
|
||||||
|
Ok(rt) => {
|
||||||
|
app.insert_resource(rt)
|
||||||
.add_systems(Startup, start_pull)
|
.add_systems(Startup, start_pull)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
@@ -121,6 +128,11 @@ impl Plugin for SyncPlugin {
|
|||||||
)
|
)
|
||||||
.add_systems(Last, push_on_exit);
|
.add_systems(Last, push_on_exit);
|
||||||
}
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("sync: failed to create Tokio runtime — network sync disabled: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -130,19 +142,14 @@ impl Plugin for SyncPlugin {
|
|||||||
/// Startup system: spawns the async pull task and sets status to `Syncing`.
|
/// Startup system: spawns the async pull task and sets status to `Syncing`.
|
||||||
fn start_pull(
|
fn start_pull(
|
||||||
provider: Res<SyncProviderResource>,
|
provider: Res<SyncProviderResource>,
|
||||||
|
rt: Res<TokioRuntimeResource>,
|
||||||
mut task_res: ResMut<PullTask>,
|
mut task_res: ResMut<PullTask>,
|
||||||
mut status: ResMut<SyncStatusResource>,
|
mut status: ResMut<SyncStatusResource>,
|
||||||
) {
|
) {
|
||||||
let provider = provider.0.clone();
|
let provider = provider.0.clone();
|
||||||
|
let rt = rt.0.clone();
|
||||||
let task = AsyncComputeTaskPool::get().spawn(async move {
|
let task = AsyncComputeTaskPool::get().spawn(async move {
|
||||||
// Bevy's AsyncComputeTaskPool uses async-executor (not Tokio), but
|
rt.block_on(provider.pull())
|
||||||
// reqwest/hyper require a Tokio reactor for DNS and HTTP I/O. Provide
|
|
||||||
// a short-lived single-threaded runtime for this network round-trip.
|
|
||||||
tokio::runtime::Builder::new_current_thread()
|
|
||||||
.enable_all()
|
|
||||||
.build()
|
|
||||||
.map_err(|e| SyncError::Network(format!("tokio rt: {e}")))?
|
|
||||||
.block_on(provider.pull())
|
|
||||||
});
|
});
|
||||||
task_res.0 = Some(task);
|
task_res.0 = Some(task);
|
||||||
status.0 = SyncStatus::Syncing;
|
status.0 = SyncStatus::Syncing;
|
||||||
@@ -153,6 +160,7 @@ fn start_pull(
|
|||||||
fn handle_manual_sync_request(
|
fn handle_manual_sync_request(
|
||||||
mut events: MessageReader<ManualSyncRequestEvent>,
|
mut events: MessageReader<ManualSyncRequestEvent>,
|
||||||
provider: Res<SyncProviderResource>,
|
provider: Res<SyncProviderResource>,
|
||||||
|
rt: Res<TokioRuntimeResource>,
|
||||||
mut task_res: ResMut<PullTask>,
|
mut task_res: ResMut<PullTask>,
|
||||||
mut status: ResMut<SyncStatusResource>,
|
mut status: ResMut<SyncStatusResource>,
|
||||||
) {
|
) {
|
||||||
@@ -164,12 +172,9 @@ fn handle_manual_sync_request(
|
|||||||
return; // Already pulling — ignore.
|
return; // Already pulling — ignore.
|
||||||
}
|
}
|
||||||
let provider = provider.0.clone();
|
let provider = provider.0.clone();
|
||||||
|
let rt = rt.0.clone();
|
||||||
let task = AsyncComputeTaskPool::get().spawn(async move {
|
let task = AsyncComputeTaskPool::get().spawn(async move {
|
||||||
tokio::runtime::Builder::new_current_thread()
|
rt.block_on(provider.pull())
|
||||||
.enable_all()
|
|
||||||
.build()
|
|
||||||
.map_err(|e| SyncError::Network(format!("tokio rt: {e}")))?
|
|
||||||
.block_on(provider.pull())
|
|
||||||
});
|
});
|
||||||
task_res.0 = Some(task);
|
task_res.0 = Some(task);
|
||||||
status.0 = SyncStatus::Syncing;
|
status.0 = SyncStatus::Syncing;
|
||||||
@@ -197,7 +202,7 @@ fn poll_pull_result(
|
|||||||
progress_path: Res<ProgressStoragePath>,
|
progress_path: Res<ProgressStoragePath>,
|
||||||
mut complete_writer: MessageWriter<SyncCompleteEvent>,
|
mut complete_writer: MessageWriter<SyncCompleteEvent>,
|
||||||
mut configure_sync: MessageWriter<SyncConfigureRequestEvent>,
|
mut configure_sync: MessageWriter<SyncConfigureRequestEvent>,
|
||||||
mut toast: MessageWriter<InfoToastEvent>,
|
mut warning_toast: MessageWriter<WarningToastEvent>,
|
||||||
) {
|
) {
|
||||||
let Some(task) = task_res.0.as_mut() else {
|
let Some(task) = task_res.0.as_mut() else {
|
||||||
return;
|
return;
|
||||||
@@ -251,13 +256,13 @@ fn poll_pull_result(
|
|||||||
SyncError::Serialization(_) => format!("Unexpected server response: {e}"),
|
SyncError::Serialization(_) => format!("Unexpected server response: {e}"),
|
||||||
SyncError::UnsupportedPlatform => unreachable!("handled above"),
|
SyncError::UnsupportedPlatform => unreachable!("handled above"),
|
||||||
};
|
};
|
||||||
|
warning_toast.write(WarningToastEvent(msg.clone()));
|
||||||
// On auth failure, reopen the Connect modal so the player can
|
// On auth failure, reopen the Connect modal so the player can
|
||||||
// re-enter credentials without having to navigate through Settings.
|
// re-enter credentials without having to navigate through Settings.
|
||||||
// `open_sync_setup_modal` is idempotent — it ignores the event when
|
// `open_sync_setup_modal` is idempotent — it ignores the event when
|
||||||
// the modal is already on screen, so repeated pull failures don't
|
// the modal is already on screen, so repeated pull failures don't
|
||||||
// stack multiple modals.
|
// stack multiple modals.
|
||||||
if matches!(e, SyncError::Auth(_)) {
|
if matches!(e, SyncError::Auth(_)) {
|
||||||
toast.write(InfoToastEvent("Session expired — please reconnect".to_string()));
|
|
||||||
configure_sync.write(SyncConfigureRequestEvent);
|
configure_sync.write(SyncConfigureRequestEvent);
|
||||||
}
|
}
|
||||||
status.0 = SyncStatus::Error(msg.clone());
|
status.0 = SyncStatus::Error(msg.clone());
|
||||||
@@ -274,6 +279,7 @@ fn poll_pull_result(
|
|||||||
fn push_on_exit(
|
fn push_on_exit(
|
||||||
mut exit_events: MessageReader<AppExit>,
|
mut exit_events: MessageReader<AppExit>,
|
||||||
provider: Res<SyncProviderResource>,
|
provider: Res<SyncProviderResource>,
|
||||||
|
rt: Res<TokioRuntimeResource>,
|
||||||
stats: Res<StatsResource>,
|
stats: Res<StatsResource>,
|
||||||
achievements: Res<AchievementsResource>,
|
achievements: Res<AchievementsResource>,
|
||||||
progress: Res<ProgressResource>,
|
progress: Res<ProgressResource>,
|
||||||
@@ -284,21 +290,7 @@ fn push_on_exit(
|
|||||||
exit_events.clear();
|
exit_events.clear();
|
||||||
|
|
||||||
let payload = build_payload(&stats.0, &achievements.0, &progress.0);
|
let payload = build_payload(&stats.0, &achievements.0, &progress.0);
|
||||||
let provider = provider.0.clone();
|
let result = rt.0.block_on(provider.0.push(&payload));
|
||||||
|
|
||||||
// Prefer an existing tokio runtime; fall back to a temporary one for
|
|
||||||
// environments (e.g. tests, Android's non-Tokio async executor) where
|
|
||||||
// reqwest/hyper would otherwise panic with "no reactor running".
|
|
||||||
let result = match tokio::runtime::Handle::try_current() {
|
|
||||||
Ok(handle) => handle.block_on(provider.push(&payload)),
|
|
||||||
Err(_) => match tokio::runtime::Builder::new_current_thread()
|
|
||||||
.enable_all()
|
|
||||||
.build()
|
|
||||||
{
|
|
||||||
Ok(rt) => rt.block_on(provider.push(&payload)),
|
|
||||||
Err(e) => Err(SyncError::Network(format!("tokio rt on exit: {e}"))),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
match result {
|
match result {
|
||||||
Ok(_) => {}
|
Ok(_) => {}
|
||||||
// `UnsupportedPlatform` is the expected response of
|
// `UnsupportedPlatform` is the expected response of
|
||||||
@@ -327,6 +319,7 @@ fn push_on_exit(
|
|||||||
fn push_replay_on_win(
|
fn push_replay_on_win(
|
||||||
mut wins: MessageReader<GameWonEvent>,
|
mut wins: MessageReader<GameWonEvent>,
|
||||||
provider: Res<SyncProviderResource>,
|
provider: Res<SyncProviderResource>,
|
||||||
|
rt: Res<TokioRuntimeResource>,
|
||||||
game: Res<GameStateResource>,
|
game: Res<GameStateResource>,
|
||||||
recording: Res<RecordingReplay>,
|
recording: Res<RecordingReplay>,
|
||||||
mut pending: ResMut<PendingReplayUpload>,
|
mut pending: ResMut<PendingReplayUpload>,
|
||||||
@@ -340,7 +333,7 @@ fn push_replay_on_win(
|
|||||||
}
|
}
|
||||||
let replay = Replay::new(
|
let replay = Replay::new(
|
||||||
game.0.seed,
|
game.0.seed,
|
||||||
game.0.draw_mode.clone(),
|
game.0.draw_mode,
|
||||||
game.0.mode,
|
game.0.mode,
|
||||||
ev.time_seconds,
|
ev.time_seconds,
|
||||||
ev.score,
|
ev.score,
|
||||||
@@ -348,12 +341,9 @@ fn push_replay_on_win(
|
|||||||
recording.moves.clone(),
|
recording.moves.clone(),
|
||||||
);
|
);
|
||||||
let provider = provider.0.clone();
|
let provider = provider.0.clone();
|
||||||
|
let rt = rt.0.clone();
|
||||||
let task = AsyncComputeTaskPool::get().spawn(async move {
|
let task = AsyncComputeTaskPool::get().spawn(async move {
|
||||||
tokio::runtime::Builder::new_current_thread()
|
rt.block_on(provider.push_replay(&replay))
|
||||||
.enable_all()
|
|
||||||
.build()
|
|
||||||
.map_err(|e| SyncError::Network(format!("tokio rt: {e}")))?
|
|
||||||
.block_on(provider.push_replay(&replay))
|
|
||||||
});
|
});
|
||||||
// If a previous upload is still in flight, drop it — the most
|
// If a previous upload is still in flight, drop it — the most
|
||||||
// recent win is the one whose share link the player will care
|
// recent win is the one whose share link the player will care
|
||||||
@@ -571,6 +561,33 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pull_failure_fires_warning_toast() {
|
||||||
|
use bevy::ecs::message::Messages;
|
||||||
|
let mut app = headless_app_with(FailingProvider);
|
||||||
|
let deadline =
|
||||||
|
std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||||
|
loop {
|
||||||
|
app.update();
|
||||||
|
if matches!(
|
||||||
|
app.world().resource::<SyncStatusResource>().0,
|
||||||
|
SyncStatus::Error(_)
|
||||||
|
) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if std::time::Instant::now() >= deadline {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::yield_now();
|
||||||
|
}
|
||||||
|
let msgs = app.world().resource::<Messages<WarningToastEvent>>();
|
||||||
|
let mut cursor = msgs.get_cursor();
|
||||||
|
assert!(
|
||||||
|
cursor.read(msgs).next().is_some(),
|
||||||
|
"a WarningToastEvent must fire when the pull fails"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn build_payload_sets_nil_user_id() {
|
fn build_payload_sets_nil_user_id() {
|
||||||
let payload = build_payload(
|
let payload = build_payload(
|
||||||
|
|||||||
@@ -52,9 +52,10 @@ use crate::events::{
|
|||||||
SyncLogoutRequestEvent,
|
SyncLogoutRequestEvent,
|
||||||
};
|
};
|
||||||
use crate::font_plugin::FontResource;
|
use crate::font_plugin::FontResource;
|
||||||
use crate::settings_plugin::{SettingsResource, SettingsScreen, SettingsStoragePath};
|
use crate::settings_plugin::{SettingsPanel, SettingsResource, SettingsScreen, SettingsStoragePath};
|
||||||
|
use crate::resources::TokioRuntimeResource;
|
||||||
use crate::sync_plugin::SyncProviderResource;
|
use crate::sync_plugin::SyncProviderResource;
|
||||||
use crate::ui_modal::spawn_modal;
|
use crate::ui_modal::{spawn_modal, ModalScrim};
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
ACCENT_PRIMARY, BG_ELEVATED, BG_ELEVATED_HI,
|
ACCENT_PRIMARY, BG_ELEVATED, BG_ELEVATED_HI,
|
||||||
BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, STATE_DANGER, TEXT_DISABLED,
|
BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, STATE_DANGER, TEXT_DISABLED,
|
||||||
@@ -204,9 +205,14 @@ impl Plugin for SyncSetupPlugin {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Opens the sync-setup modal when `SyncConfigureRequestEvent` is received.
|
/// Opens the sync-setup modal when `SyncConfigureRequestEvent` is received.
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
fn open_sync_setup_modal(
|
fn open_sync_setup_modal(
|
||||||
mut events: MessageReader<SyncConfigureRequestEvent>,
|
mut events: MessageReader<SyncConfigureRequestEvent>,
|
||||||
existing: Query<(), With<SyncSetupScreen>>,
|
existing: Query<(), With<SyncSetupScreen>>,
|
||||||
|
// Exclude SettingsPanel: the Connect button closes settings in the same
|
||||||
|
// frame it fires SyncConfigureRequestEvent, but Bevy despawns are deferred
|
||||||
|
// so the settings scrim still exists in the world during this system.
|
||||||
|
other_modal_scrims: Query<(), (With<ModalScrim>, Without<SyncSetupScreen>, Without<SettingsPanel>)>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
mut focused: ResMut<SyncFocusedField>,
|
mut focused: ResMut<SyncFocusedField>,
|
||||||
font_res: Option<Res<FontResource>>,
|
font_res: Option<Res<FontResource>>,
|
||||||
@@ -218,6 +224,9 @@ fn open_sync_setup_modal(
|
|||||||
if !existing.is_empty() {
|
if !existing.is_empty() {
|
||||||
return; // Already open.
|
return; // Already open.
|
||||||
}
|
}
|
||||||
|
if !other_modal_scrims.is_empty() {
|
||||||
|
return; // Another modal is already visible.
|
||||||
|
}
|
||||||
*focused = SyncFocusedField::Url;
|
*focused = SyncFocusedField::Url;
|
||||||
spawn_sync_setup_modal(&mut commands, font_res.as_deref());
|
spawn_sync_setup_modal(&mut commands, font_res.as_deref());
|
||||||
}
|
}
|
||||||
@@ -300,7 +309,8 @@ fn update_field_borders(
|
|||||||
fn handle_auth_button(
|
fn handle_auth_button(
|
||||||
login_q: Query<&Interaction, (Changed<Interaction>, With<SyncLoginButton>)>,
|
login_q: Query<&Interaction, (Changed<Interaction>, With<SyncLoginButton>)>,
|
||||||
register_q: Query<&Interaction, (Changed<Interaction>, With<SyncRegisterButton>)>,
|
register_q: Query<&Interaction, (Changed<Interaction>, With<SyncRegisterButton>)>,
|
||||||
fields: Query<(&SyncFieldKind, &SyncFieldBuffer)>,
|
mut fields: Query<(&SyncFieldKind, &mut SyncFieldBuffer)>,
|
||||||
|
rt: Res<TokioRuntimeResource>,
|
||||||
mut pending: ResMut<PendingAuthTask>,
|
mut pending: ResMut<PendingAuthTask>,
|
||||||
mut error_nodes: Query<(&mut Text, &mut TextColor), With<SyncAuthError>>,
|
mut error_nodes: Query<(&mut Text, &mut TextColor), With<SyncAuthError>>,
|
||||||
mut busy_nodes: Query<&mut Visibility, With<SyncBusyOverlay>>,
|
mut busy_nodes: Query<&mut Visibility, With<SyncBusyOverlay>>,
|
||||||
@@ -352,9 +362,10 @@ fn handle_auth_button(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear error and show busy indicator.
|
// Clear previous error and show busy indicator.
|
||||||
for (mut text, _) in &mut error_nodes {
|
for (mut text, mut color) in &mut error_nodes {
|
||||||
text.0 = "Connecting…".to_string();
|
text.0 = String::new();
|
||||||
|
color.0 = TEXT_SECONDARY;
|
||||||
}
|
}
|
||||||
for mut vis in &mut busy_nodes {
|
for mut vis in &mut busy_nodes {
|
||||||
*vis = Visibility::Visible;
|
*vis = Visibility::Visible;
|
||||||
@@ -363,13 +374,10 @@ fn handle_auth_button(
|
|||||||
let is_register = register_clicked;
|
let is_register = register_clicked;
|
||||||
let client = SolitaireServerClient::new(url.clone(), username.clone());
|
let client = SolitaireServerClient::new(url.clone(), username.clone());
|
||||||
let pw = password.clone();
|
let pw = password.clone();
|
||||||
|
let rt = rt.0.clone();
|
||||||
|
|
||||||
let task = AsyncComputeTaskPool::get().spawn(async move {
|
let task = AsyncComputeTaskPool::get().spawn(async move {
|
||||||
tokio::runtime::Builder::new_current_thread()
|
rt.block_on(async {
|
||||||
.enable_all()
|
|
||||||
.build()
|
|
||||||
.map_err(|e| SyncError::Network(format!("tokio rt: {e}")))?
|
|
||||||
.block_on(async {
|
|
||||||
let (access_token, refresh_token) = if is_register {
|
let (access_token, refresh_token) = if is_register {
|
||||||
client.register(&pw).await?
|
client.register(&pw).await?
|
||||||
} else {
|
} else {
|
||||||
@@ -388,6 +396,14 @@ fn handle_auth_button(
|
|||||||
pending.task = Some(task);
|
pending.task = Some(task);
|
||||||
pending.url = url;
|
pending.url = url;
|
||||||
pending.username = username;
|
pending.username = username;
|
||||||
|
|
||||||
|
// Zero the password buffer immediately — it must not linger in ECS
|
||||||
|
// components after the credential has been handed off to the async task.
|
||||||
|
for (kind, mut buf) in &mut fields {
|
||||||
|
if *kind == SyncFieldKind::Password {
|
||||||
|
buf.0.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Polls the in-flight auth task. On success updates settings + provider.
|
/// Polls the in-flight auth task. On success updates settings + provider.
|
||||||
@@ -541,6 +557,7 @@ fn handle_logout(
|
|||||||
fn open_delete_confirm_modal(
|
fn open_delete_confirm_modal(
|
||||||
mut events: MessageReader<DeleteAccountRequestEvent>,
|
mut events: MessageReader<DeleteAccountRequestEvent>,
|
||||||
existing: Query<(), With<DeleteConfirmScreen>>,
|
existing: Query<(), With<DeleteConfirmScreen>>,
|
||||||
|
other_modal_scrims: Query<(), (With<ModalScrim>, Without<DeleteConfirmScreen>)>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
font_res: Option<Res<FontResource>>,
|
font_res: Option<Res<FontResource>>,
|
||||||
) {
|
) {
|
||||||
@@ -551,6 +568,9 @@ fn open_delete_confirm_modal(
|
|||||||
if !existing.is_empty() {
|
if !existing.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if !other_modal_scrims.is_empty() {
|
||||||
|
return; // Another modal is already visible.
|
||||||
|
}
|
||||||
spawn_delete_confirm_modal(&mut commands, font_res.as_deref());
|
spawn_delete_confirm_modal(&mut commands, font_res.as_deref());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -575,6 +595,7 @@ fn handle_delete_cancel(
|
|||||||
fn handle_delete_confirm(
|
fn handle_delete_confirm(
|
||||||
confirm_q: Query<&Interaction, (Changed<Interaction>, With<DeleteConfirmButton>)>,
|
confirm_q: Query<&Interaction, (Changed<Interaction>, With<DeleteConfirmButton>)>,
|
||||||
provider: Res<SyncProviderResource>,
|
provider: Res<SyncProviderResource>,
|
||||||
|
rt: Res<TokioRuntimeResource>,
|
||||||
mut pending: ResMut<PendingDeleteTask>,
|
mut pending: ResMut<PendingDeleteTask>,
|
||||||
screen: Query<Entity, With<DeleteConfirmScreen>>,
|
screen: Query<Entity, With<DeleteConfirmScreen>>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
@@ -587,12 +608,9 @@ fn handle_delete_confirm(
|
|||||||
commands.entity(entity).despawn();
|
commands.entity(entity).despawn();
|
||||||
}
|
}
|
||||||
let provider = provider.0.clone();
|
let provider = provider.0.clone();
|
||||||
|
let rt = rt.0.clone();
|
||||||
pending.0 = Some(AsyncComputeTaskPool::get().spawn(async move {
|
pending.0 = Some(AsyncComputeTaskPool::get().spawn(async move {
|
||||||
tokio::runtime::Builder::new_current_thread()
|
rt.block_on(provider.delete_account())
|
||||||
.enable_all()
|
|
||||||
.build()
|
|
||||||
.map_err(|e| SyncError::Network(format!("tokio rt: {e}")))?
|
|
||||||
.block_on(provider.delete_account())
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -678,20 +696,31 @@ fn spawn_sync_setup_modal(commands: &mut Commands, font_res: Option<&FontResourc
|
|||||||
font_res,
|
font_res,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Error / status line.
|
// Error / status line — two distinct children so visibility and
|
||||||
|
// text can be controlled independently.
|
||||||
body.spawn(Node {
|
body.spawn(Node {
|
||||||
min_height: Val::Px(18.0),
|
min_height: Val::Px(18.0),
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
column_gap: VAL_SPACE_2,
|
||||||
..default()
|
..default()
|
||||||
})
|
})
|
||||||
.with_children(|row| {
|
.with_children(|row| {
|
||||||
|
// Busy indicator: shown while the auth task is in flight.
|
||||||
row.spawn((
|
row.spawn((
|
||||||
SyncAuthError,
|
|
||||||
SyncBusyOverlay,
|
SyncBusyOverlay,
|
||||||
Text::new(String::new()),
|
Text::new("…"),
|
||||||
make_font(font_res, TYPE_CAPTION),
|
make_font(font_res, TYPE_CAPTION),
|
||||||
TextColor(TEXT_SECONDARY),
|
TextColor(TEXT_SECONDARY),
|
||||||
Visibility::Hidden,
|
Visibility::Hidden,
|
||||||
));
|
));
|
||||||
|
// Error / status text: always laid out, empty when idle.
|
||||||
|
row.spawn((
|
||||||
|
SyncAuthError,
|
||||||
|
Text::new(String::new()),
|
||||||
|
make_font(font_res, TYPE_CAPTION),
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Tab hint — desktop only; no Tab key on Android.
|
// Tab hint — desktop only; no Tab key on Android.
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ fn load_initial_theme(
|
|||||||
let id = settings
|
let id = settings
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map(|s| s.0.selected_theme_id.as_str())
|
.map(|s| s.0.selected_theme_id.as_str())
|
||||||
.unwrap_or("dark");
|
.unwrap_or("classic");
|
||||||
let url = bundled_theme_url(id)
|
let url = bundled_theme_url(id)
|
||||||
.map(str::to_string)
|
.map(str::to_string)
|
||||||
.unwrap_or_else(|| format!("themes://{id}/theme.ron"));
|
.unwrap_or_else(|| format!("themes://{id}/theme.ron"));
|
||||||
@@ -182,12 +182,16 @@ fn sync_card_image_set_with_active_theme(
|
|||||||
mut events: MessageReader<AssetEvent<CardTheme>>,
|
mut events: MessageReader<AssetEvent<CardTheme>>,
|
||||||
active: Option<Res<ActiveTheme>>,
|
active: Option<Res<ActiveTheme>>,
|
||||||
themes: Res<Assets<CardTheme>>,
|
themes: Res<Assets<CardTheme>>,
|
||||||
|
asset_server: Option<Res<AssetServer>>,
|
||||||
mut card_image_set: Option<ResMut<CardImageSet>>,
|
mut card_image_set: Option<ResMut<CardImageSet>>,
|
||||||
mut state_events: MessageWriter<StateChangedEvent>,
|
mut state_events: MessageWriter<StateChangedEvent>,
|
||||||
) {
|
) {
|
||||||
let Some(active) = active else { return };
|
let Some(active) = active else { return };
|
||||||
let active_id = active.0.id();
|
let active_id = active.0.id();
|
||||||
|
|
||||||
let mut should_sync = false;
|
let mut should_sync = false;
|
||||||
|
|
||||||
|
// Consume asset events — covers the normal first-load path.
|
||||||
for ev in events.read() {
|
for ev in events.read() {
|
||||||
let id = match ev {
|
let id = match ev {
|
||||||
AssetEvent::LoadedWithDependencies { id }
|
AssetEvent::LoadedWithDependencies { id }
|
||||||
@@ -198,6 +202,22 @@ fn sync_card_image_set_with_active_theme(
|
|||||||
should_sync = true;
|
should_sync = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A→B→A switch: Bevy does not re-fire LoadedWithDependencies for a
|
||||||
|
// handle whose asset is already cached. Detect this by checking that
|
||||||
|
// `ActiveTheme` itself changed this frame (the resource was just
|
||||||
|
// replaced by `react_to_settings_theme_change`) and the underlying
|
||||||
|
// asset is already fully loaded. If so, sync immediately rather than
|
||||||
|
// waiting for an event that will never arrive.
|
||||||
|
if !should_sync
|
||||||
|
&& active.is_changed()
|
||||||
|
&& asset_server
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|as_| as_.is_loaded_with_dependencies(active.0.id()))
|
||||||
|
{
|
||||||
|
should_sync = true;
|
||||||
|
}
|
||||||
|
|
||||||
if !should_sync {
|
if !should_sync {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -172,14 +172,15 @@ fn advance_time_attack(
|
|||||||
paused: Option<Res<crate::pause_plugin::PausedResource>>,
|
paused: Option<Res<crate::pause_plugin::PausedResource>>,
|
||||||
path: Option<Res<TimeAttackSessionPath>>,
|
path: Option<Res<TimeAttackSessionPath>>,
|
||||||
home_screens: Query<(), With<crate::home_plugin::HomeScreen>>,
|
home_screens: Query<(), With<crate::home_plugin::HomeScreen>>,
|
||||||
|
win_overlays: Query<(), With<crate::win_summary_plugin::WinSummaryOverlay>>,
|
||||||
) {
|
) {
|
||||||
if !session.active {
|
if !session.active {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Mirrors `tick_elapsed_time`: pause while the launch / mode-picker
|
// Pause the countdown while Home, the Pause overlay, or the Win Summary
|
||||||
// Home modal is up so the countdown doesn't burn while the player
|
// overlay is visible — the player should not lose time while reading results
|
||||||
// is choosing what to play next.
|
// or navigating menus.
|
||||||
if paused.is_some_and(|p| p.0) || !home_screens.is_empty() {
|
if paused.is_some_and(|p| p.0) || !home_screens.is_empty() || !win_overlays.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
session.remaining_secs -= time.delta_secs();
|
session.remaining_secs -= time.delta_secs();
|
||||||
@@ -307,7 +308,7 @@ mod tests {
|
|||||||
.add_plugins(TimeAttackPlugin);
|
.add_plugins(TimeAttackPlugin);
|
||||||
app.init_resource::<ButtonInput<KeyCode>>();
|
app.init_resource::<ButtonInput<KeyCode>>();
|
||||||
// Disable session persistence — tests must not touch the real
|
// Disable session persistence — tests must not touch the real
|
||||||
// ~/.local/share/solitaire_quest/time_attack_session.json.
|
// ~/.local/share/ferrous_solitaire/time_attack_session.json.
|
||||||
app.insert_resource(TimeAttackSessionPath(None));
|
app.insert_resource(TimeAttackSessionPath(None));
|
||||||
// The plugin's startup-load hook may have populated TimeAttackResource
|
// The plugin's startup-load hook may have populated TimeAttackResource
|
||||||
// from a real on-disk session. Reset it so each test starts inactive.
|
// from a real on-disk session. Reset it so each test starts inactive.
|
||||||
|
|||||||
@@ -212,6 +212,13 @@ where
|
|||||||
// modal at `Z_PAUSE` (220) in some scenes.
|
// modal at `Z_PAUSE` (220) in some scenes.
|
||||||
GlobalZIndex(z_panel),
|
GlobalZIndex(z_panel),
|
||||||
ZIndex(z_panel),
|
ZIndex(z_panel),
|
||||||
|
// B0004: ModalCard carries Transform (for the scale animation).
|
||||||
|
// Bevy's GlobalTransform hook fires B0004 when a child has
|
||||||
|
// GlobalTransform but the parent does not. Adding Identity
|
||||||
|
// Transform here gives the scrim GlobalTransform so the check
|
||||||
|
// passes. UI layout still uses UiTransform; this has no layout
|
||||||
|
// effect.
|
||||||
|
Transform::default(),
|
||||||
))
|
))
|
||||||
.with_children(|root| {
|
.with_children(|root| {
|
||||||
root.spawn((
|
root.spawn((
|
||||||
@@ -335,8 +342,7 @@ pub fn spawn_modal_button<M: Component>(
|
|||||||
variant: ButtonVariant,
|
variant: ButtonVariant,
|
||||||
font_res: Option<&FontResource>,
|
font_res: Option<&FontResource>,
|
||||||
) {
|
) {
|
||||||
#[cfg(target_os = "android")]
|
let hotkey = if cfg!(target_os = "android") { None } else { hotkey };
|
||||||
let hotkey: Option<&'static str> = None;
|
|
||||||
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
|
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
|
||||||
let font_label = TextFont {
|
let font_label = TextFont {
|
||||||
font: font_handle.clone(),
|
font: font_handle.clone(),
|
||||||
@@ -604,7 +610,7 @@ pub fn dismiss_modal_on_scrim_click(
|
|||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
mouse: Option<Res<ButtonInput<MouseButton>>>,
|
mouse: Option<Res<ButtonInput<MouseButton>>>,
|
||||||
windows: Query<&Window, With<PrimaryWindow>>,
|
windows: Query<&Window, With<PrimaryWindow>>,
|
||||||
scrims: Query<Entity, (With<ModalScrim>, With<ScrimDismissible>)>,
|
scrims: Query<(Entity, &Children), (With<ModalScrim>, With<ScrimDismissible>)>,
|
||||||
cards: Query<(&UiGlobalTransform, &ComputedNode), With<ModalCard>>,
|
cards: Query<(&UiGlobalTransform, &ComputedNode), With<ModalCard>>,
|
||||||
) {
|
) {
|
||||||
let Some(mouse) = mouse else { return };
|
let Some(mouse) = mouse else { return };
|
||||||
@@ -621,15 +627,19 @@ pub fn dismiss_modal_on_scrim_click(
|
|||||||
// Topmost-only: bail after the first dismissible scrim. Stacked
|
// Topmost-only: bail after the first dismissible scrim. Stacked
|
||||||
// dismissible modals are not currently a real case, but this guard
|
// dismissible modals are not currently a real case, but this guard
|
||||||
// keeps the behaviour predictable if they ever arise.
|
// keeps the behaviour predictable if they ever arise.
|
||||||
let Some(scrim_entity) = scrims.iter().next() else {
|
let Some((scrim_entity, scrim_children)) = scrims.iter().next() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let cursor_over_card = cards.iter().any(|(transform, computed)| {
|
// Only test the ModalCard(s) that belong to THIS scrim, not cards
|
||||||
|
// from any other concurrently-open modal.
|
||||||
|
let cursor_over_card = scrim_children.iter().any(|child| {
|
||||||
|
cards.get(child).is_ok_and(|(transform, computed)| {
|
||||||
let inv = computed.inverse_scale_factor;
|
let inv = computed.inverse_scale_factor;
|
||||||
let size_logical = computed.size() * inv;
|
let size_logical = computed.size() * inv;
|
||||||
let centre_logical = transform.translation * inv;
|
let centre_logical = transform.translation * inv;
|
||||||
cursor_is_inside_rect(cursor, centre_logical, size_logical)
|
cursor_is_inside_rect(cursor, centre_logical, size_logical)
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
if !cursor_over_card {
|
if !cursor_over_card {
|
||||||
|
|||||||
@@ -313,10 +313,10 @@ impl HighContrastBackground {
|
|||||||
/// `outline` from the design system. `#505050`.
|
/// `outline` from the design system. `#505050`.
|
||||||
pub const BORDER_STRONG: Color = Color::srgba(0.314, 0.314, 0.314, 1.0);
|
pub const BORDER_STRONG: Color = Color::srgba(0.314, 0.314, 0.314, 1.0);
|
||||||
|
|
||||||
/// 2 px ring drawn around the focused interactive element. Cyan
|
/// 2 px ring drawn around the focused interactive element. Brick-red
|
||||||
/// (matches `ACCENT_PRIMARY`) at 85% alpha so the ring stays legible
|
/// (matches `ACCENT_PRIMARY`) at 85% alpha so the ring stays legible
|
||||||
/// against both elevated surfaces and the modal scrim backdrop.
|
/// against both elevated surfaces and the modal scrim backdrop.
|
||||||
/// `rgba(111, 194, 239, 0.85)`.
|
/// `rgba(165, 66, 66, 0.85)`.
|
||||||
pub const FOCUS_RING: Color = Color::srgba(0.647, 0.259, 0.259, 0.85);
|
pub const FOCUS_RING: Color = Color::srgba(0.647, 0.259, 0.259, 0.85);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -401,8 +401,13 @@ pub const Z_BACKGROUND: i32 = -10;
|
|||||||
pub const Z_PILE_MARKER: i32 = -1;
|
pub const Z_PILE_MARKER: i32 = -1;
|
||||||
/// Base layer for HUD readouts (top-left).
|
/// Base layer for HUD readouts (top-left).
|
||||||
pub const Z_HUD: i32 = 50;
|
pub const Z_HUD: i32 = 50;
|
||||||
/// Action bar + popovers — above HUD readouts so dropdowns can overlap.
|
/// Fullscreen transparent dismiss-backdrop spawned behind a HUD popover so
|
||||||
pub const Z_HUD_TOP: i32 = 60;
|
/// tapping outside it light-dismisses the panel without blocking other input.
|
||||||
|
pub const Z_HUD_POPOVER_BACKDROP: i32 = Z_HUD + 4;
|
||||||
|
/// HUD popovers (Modes dropdown, etc.) — above the dismiss backdrop.
|
||||||
|
pub const Z_HUD_POPOVER: i32 = Z_HUD + 5;
|
||||||
|
/// Transient HUD annotations (score-delta floaters) — above popovers.
|
||||||
|
pub const Z_HUD_TOP: i32 = Z_HUD + 10;
|
||||||
pub const Z_MODAL_SCRIM: i32 = 200;
|
pub const Z_MODAL_SCRIM: i32 = 200;
|
||||||
pub const Z_MODAL_PANEL: i32 = 210;
|
pub const Z_MODAL_PANEL: i32 = 210;
|
||||||
/// Pause overlay outranks normal modals — pausing should always be on top.
|
/// Pause overlay outranks normal modals — pausing should always be on top.
|
||||||
@@ -648,6 +653,8 @@ mod tests {
|
|||||||
Z_BACKGROUND,
|
Z_BACKGROUND,
|
||||||
Z_PILE_MARKER,
|
Z_PILE_MARKER,
|
||||||
Z_HUD,
|
Z_HUD,
|
||||||
|
Z_HUD_POPOVER_BACKDROP,
|
||||||
|
Z_HUD_POPOVER,
|
||||||
Z_HUD_TOP,
|
Z_HUD_TOP,
|
||||||
Z_MODAL_SCRIM,
|
Z_MODAL_SCRIM,
|
||||||
Z_MODAL_PANEL,
|
Z_MODAL_PANEL,
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ fn evaluate_weekly_goals(
|
|||||||
let ctx = WeeklyGoalContext {
|
let ctx = WeeklyGoalContext {
|
||||||
time_seconds: ev.time_seconds,
|
time_seconds: ev.time_seconds,
|
||||||
used_undo: game.0.undo_count > 0,
|
used_undo: game.0.undo_count > 0,
|
||||||
draw_mode: game.0.draw_mode.clone(),
|
draw_mode: game.0.draw_mode,
|
||||||
};
|
};
|
||||||
for def in WEEKLY_GOALS {
|
for def in WEEKLY_GOALS {
|
||||||
if !def.matches(&ctx) {
|
if !def.matches(&ctx) {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ use crate::progress_plugin::ProgressResource;
|
|||||||
use crate::resources::GameStateResource;
|
use crate::resources::GameStateResource;
|
||||||
use crate::settings_plugin::SettingsResource;
|
use crate::settings_plugin::SettingsResource;
|
||||||
use crate::stats_plugin::{StatsResource, StatsUpdate};
|
use crate::stats_plugin::{StatsResource, StatsUpdate};
|
||||||
|
use crate::ui_modal::ModalScrim;
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
scaled_duration, ACCENT_PRIMARY, BG_BASE, BG_ELEVATED, MOTION_SCORE_BREAKDOWN_FADE_SECS,
|
scaled_duration, ACCENT_PRIMARY, BG_BASE, BG_ELEVATED, MOTION_SCORE_BREAKDOWN_FADE_SECS,
|
||||||
MOTION_SCORE_BREAKDOWN_STAGGER_SECS, MOTION_WIN_SHAKE_AMPLITUDE, MOTION_WIN_SHAKE_SECS,
|
MOTION_SCORE_BREAKDOWN_STAGGER_SECS, MOTION_WIN_SHAKE_AMPLITUDE, MOTION_WIN_SHAKE_SECS,
|
||||||
@@ -507,7 +508,7 @@ fn collect_session_achievements(
|
|||||||
) {
|
) {
|
||||||
// Reset on any new-game request (including mode switches via Z/X/C/T) so
|
// Reset on any new-game request (including mode switches via Z/X/C/T) so
|
||||||
// achievements from the previous session are not carried into the next one.
|
// achievements from the previous session are not carried into the next one.
|
||||||
if new_games.read().last().is_some() {
|
if new_games.read().next().is_some() {
|
||||||
session.names.clear();
|
session.names.clear();
|
||||||
}
|
}
|
||||||
for ev in unlocks.read() {
|
for ev in unlocks.read() {
|
||||||
@@ -538,6 +539,7 @@ fn spawn_win_summary_after_delay(
|
|||||||
settings: Option<Res<SettingsResource>>,
|
settings: Option<Res<SettingsResource>>,
|
||||||
time: Res<Time>,
|
time: Res<Time>,
|
||||||
overlays: Query<Entity, With<WinSummaryOverlay>>,
|
overlays: Query<Entity, With<WinSummaryOverlay>>,
|
||||||
|
other_scrims: Query<(), (With<ModalScrim>, Without<WinSummaryOverlay>)>,
|
||||||
mut delay: Local<Option<f32>>,
|
mut delay: Local<Option<f32>>,
|
||||||
) {
|
) {
|
||||||
// Process new win events.
|
// Process new win events.
|
||||||
@@ -568,8 +570,8 @@ fn spawn_win_summary_after_delay(
|
|||||||
*remaining -= time.delta_secs();
|
*remaining -= time.delta_secs();
|
||||||
if *remaining <= 0.0 {
|
if *remaining <= 0.0 {
|
||||||
*delay = None;
|
*delay = None;
|
||||||
// Only spawn if there is no overlay already.
|
// Only spawn if no overlay of any kind is already visible.
|
||||||
if overlays.is_empty() {
|
if overlays.is_empty() && other_scrims.is_empty() {
|
||||||
// Drain any XpAwardedEvents that arrived this frame but were
|
// Drain any XpAwardedEvents that arrived this frame but were
|
||||||
// not yet consumed by `cache_win_data` (which may run later in
|
// not yet consumed by `cache_win_data` (which may run later in
|
||||||
// the same schedule). Accumulating here ensures the modal
|
// the same schedule). Accumulating here ensures the modal
|
||||||
@@ -757,6 +759,7 @@ fn spawn_overlay(
|
|||||||
commands
|
commands
|
||||||
.spawn((
|
.spawn((
|
||||||
WinSummaryOverlay,
|
WinSummaryOverlay,
|
||||||
|
ModalScrim,
|
||||||
Node {
|
Node {
|
||||||
position_type: PositionType::Absolute,
|
position_type: PositionType::Absolute,
|
||||||
left: Val::Percent(0.0),
|
left: Val::Percent(0.0),
|
||||||
@@ -769,6 +772,7 @@ fn spawn_overlay(
|
|||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
BackgroundColor(SCRIM),
|
BackgroundColor(SCRIM),
|
||||||
|
GlobalZIndex(Z_WIN_CASCADE),
|
||||||
ZIndex(Z_WIN_CASCADE),
|
ZIndex(Z_WIN_CASCADE),
|
||||||
))
|
))
|
||||||
.with_children(|root| {
|
.with_children(|root| {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ..
|
context: ..
|
||||||
dockerfile: solitaire_server/Dockerfile
|
dockerfile: solitaire_server/Dockerfile
|
||||||
image: solitaire-quest-server:latest
|
image: ferrous-solitaire-server:latest
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "${SERVER_PORT:-8080}:8080"
|
- "${SERVER_PORT:-8080}:8080"
|
||||||
|
|||||||
@@ -336,13 +336,11 @@ pub async fn get_me(
|
|||||||
|
|
||||||
Ok(Json(MeResponse {
|
Ok(Json(MeResponse {
|
||||||
id: user.user_id,
|
id: user.user_id,
|
||||||
username: row.username.unwrap_or_default(),
|
username: row.username.ok_or(AppError::Unauthorized)?,
|
||||||
avatar_url: row.avatar_url,
|
avatar_url: row.avatar_url,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Allowed MIME types for uploaded avatars.
|
|
||||||
const ALLOWED_IMAGE_TYPES: &[&str] = &["image/jpeg", "image/png", "image/webp", "image/gif"];
|
|
||||||
/// Maximum avatar upload size in bytes (1 MB).
|
/// Maximum avatar upload size in bytes (1 MB).
|
||||||
const AVATAR_MAX_BYTES: usize = 1024 * 1024;
|
const AVATAR_MAX_BYTES: usize = 1024 * 1024;
|
||||||
|
|
||||||
@@ -361,23 +359,15 @@ pub async fn upload_avatar(
|
|||||||
.and_then(|v| v.to_str().ok())
|
.and_then(|v| v.to_str().ok())
|
||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.to_string();
|
.to_string();
|
||||||
let ext = if mime.contains("jpeg") || mime.contains("jpg") {
|
let ext = match mime.as_str() {
|
||||||
"jpg"
|
"image/jpeg" | "image/jpg" => "jpg",
|
||||||
} else if mime.contains("png") {
|
"image/png" => "png",
|
||||||
"png"
|
"image/webp" => "webp",
|
||||||
} else if mime.contains("webp") {
|
"image/gif" => "gif",
|
||||||
"webp"
|
_ => return Err(AppError::BadRequest(
|
||||||
} else if mime.contains("gif") {
|
|
||||||
"gif"
|
|
||||||
} else {
|
|
||||||
return Err(AppError::BadRequest(
|
|
||||||
"avatar must be image/jpeg, image/png, image/webp, or image/gif".into(),
|
"avatar must be image/jpeg, image/png, image/webp, or image/gif".into(),
|
||||||
));
|
)),
|
||||||
};
|
};
|
||||||
|
|
||||||
if !ALLOWED_IMAGE_TYPES.iter().any(|t| mime.starts_with(t)) {
|
|
||||||
return Err(AppError::BadRequest("unsupported image type".into()));
|
|
||||||
}
|
|
||||||
if body.len() > AVATAR_MAX_BYTES {
|
if body.len() > AVATAR_MAX_BYTES {
|
||||||
return Err(AppError::BadRequest("avatar must be ≤ 1 MB".into()));
|
return Err(AppError::BadRequest("avatar must be ≤ 1 MB".into()));
|
||||||
}
|
}
|
||||||
@@ -386,13 +376,22 @@ pub async fn upload_avatar(
|
|||||||
std::fs::create_dir_all("avatars").map_err(|e| AppError::Internal(e.to_string()))?;
|
std::fs::create_dir_all("avatars").map_err(|e| AppError::Internal(e.to_string()))?;
|
||||||
let filename = format!("{}.{}", user.user_id, ext);
|
let filename = format!("{}.{}", user.user_id, ext);
|
||||||
let path = std::path::Path::new("avatars").join(&filename);
|
let path = std::path::Path::new("avatars").join(&filename);
|
||||||
// Remove stale files with other extensions first.
|
let tmp_path = std::path::Path::new("avatars").join(format!("{}.{}.tmp", user.user_id, ext));
|
||||||
|
// Write to a temp file then atomically rename so concurrent readers never
|
||||||
|
// see a partially-written avatar.
|
||||||
|
std::fs::write(&tmp_path, &body).map_err(|e| AppError::Internal(e.to_string()))?;
|
||||||
|
if let Err(e) = std::fs::rename(&tmp_path, &path) {
|
||||||
|
let _ = std::fs::remove_file(&tmp_path);
|
||||||
|
return Err(AppError::Internal(e.to_string()));
|
||||||
|
}
|
||||||
|
// Remove stale files with other extensions after the atomic rename.
|
||||||
for old_ext in &["jpg", "png", "webp", "gif"] {
|
for old_ext in &["jpg", "png", "webp", "gif"] {
|
||||||
|
if *old_ext != ext {
|
||||||
let _ = std::fs::remove_file(
|
let _ = std::fs::remove_file(
|
||||||
std::path::Path::new("avatars").join(format!("{}.{}", user.user_id, old_ext)),
|
std::path::Path::new("avatars").join(format!("{}.{}", user.user_id, old_ext)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
std::fs::write(&path, &body).map_err(|e| AppError::Internal(e.to_string()))?;
|
}
|
||||||
|
|
||||||
let avatar_url = format!("/avatars/{filename}");
|
let avatar_url = format!("/avatars/{filename}");
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
@@ -412,7 +411,7 @@ pub async fn upload_avatar(
|
|||||||
|
|
||||||
Ok(Json(MeResponse {
|
Ok(Json(MeResponse {
|
||||||
id: user.user_id,
|
id: user.user_id,
|
||||||
username: username.unwrap_or_default(),
|
username: username.ok_or(AppError::Unauthorized)?,
|
||||||
avatar_url: Some(avatar_url),
|
avatar_url: Some(avatar_url),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
|
|||||||
.route("/api/account", delete(auth::delete_account))
|
.route("/api/account", delete(auth::delete_account))
|
||||||
.route("/api/me", get(auth::get_me))
|
.route("/api/me", get(auth::get_me))
|
||||||
.route("/api/me/avatar", put(auth::upload_avatar))
|
.route("/api/me/avatar", put(auth::upload_avatar))
|
||||||
|
.nest_service("/avatars", ServeDir::new("avatars"))
|
||||||
.layer(axum_middleware::from_fn_with_state(
|
.layer(axum_middleware::from_fn_with_state(
|
||||||
state.clone(),
|
state.clone(),
|
||||||
middleware::require_auth,
|
middleware::require_auth,
|
||||||
@@ -231,7 +232,6 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
|
|||||||
)
|
)
|
||||||
.nest_service("/web", ServeDir::new("solitaire_server/web"))
|
.nest_service("/web", ServeDir::new("solitaire_server/web"))
|
||||||
.nest_service("/assets", ServeDir::new("assets"))
|
.nest_service("/assets", ServeDir::new("assets"))
|
||||||
.nest_service("/avatars", ServeDir::new("avatars"))
|
|
||||||
.layer(axum_middleware::from_fn(security_headers));
|
.layer(axum_middleware::from_fn(security_headers));
|
||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ use crate::{error::AppError, middleware::AuthenticatedUser, AppState};
|
|||||||
/// the desktop client's transitive dependencies.
|
/// the desktop client's transitive dependencies.
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct ReplayHeader {
|
struct ReplayHeader {
|
||||||
seed: i64,
|
seed: u64,
|
||||||
draw_mode: String,
|
draw_mode: String,
|
||||||
mode: String,
|
mode: String,
|
||||||
time_seconds: i64,
|
time_seconds: i64,
|
||||||
@@ -94,6 +94,9 @@ pub async fn upload(
|
|||||||
let id = Uuid::new_v4().to_string();
|
let id = Uuid::new_v4().to_string();
|
||||||
let received_at = Utc::now().to_rfc3339();
|
let received_at = Utc::now().to_rfc3339();
|
||||||
let replay_json = serde_json::to_string(&payload)?;
|
let replay_json = serde_json::to_string(&payload)?;
|
||||||
|
// SQLite INTEGER columns bind as i64. Reinterpret the u64 bits — the
|
||||||
|
// database stores the same 8 bytes; high-bit seeds round-trip correctly.
|
||||||
|
let seed_i64 = header.seed as i64;
|
||||||
|
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
r#"INSERT INTO replays (
|
r#"INSERT INTO replays (
|
||||||
@@ -102,7 +105,7 @@ pub async fn upload(
|
|||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#,
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#,
|
||||||
id,
|
id,
|
||||||
user.user_id,
|
user.user_id,
|
||||||
header.seed,
|
seed_i64,
|
||||||
header.draw_mode,
|
header.draw_mode,
|
||||||
header.mode,
|
header.mode,
|
||||||
header.time_seconds,
|
header.time_seconds,
|
||||||
@@ -116,7 +119,7 @@ pub async fn upload(
|
|||||||
|
|
||||||
// Update leaderboard best score/time for opted-in users when this replay
|
// Update leaderboard best score/time for opted-in users when this replay
|
||||||
// beats their existing best. Only classic mode counts for the leaderboard.
|
// beats their existing best. Only classic mode counts for the leaderboard.
|
||||||
if header.mode == "classic" {
|
if header.mode == "Classic" {
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
r#"UPDATE leaderboard
|
r#"UPDATE leaderboard
|
||||||
SET best_score = ?,
|
SET best_score = ?,
|
||||||
|
|||||||
@@ -59,6 +59,25 @@ header {
|
|||||||
.hud-center { display: flex; gap: 20px; font-size: 14px; font-weight: 600; }
|
.hud-center { display: flex; gap: 20px; font-size: 14px; font-weight: 600; }
|
||||||
.hud-right { display: flex; align-items: center; gap: 10px; }
|
.hud-right { display: flex; align-items: center; gap: 10px; }
|
||||||
|
|
||||||
|
.hud-avatar-link { display: flex; align-items: center; text-decoration: none; }
|
||||||
|
.hud-avatar-inner {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 2px solid rgba(255,255,255,0.15);
|
||||||
|
background: var(--panel-hi);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: border-color 120ms;
|
||||||
|
}
|
||||||
|
.hud-avatar-link:hover .hud-avatar-inner { border-color: var(--accent); }
|
||||||
|
.hud-avatar-inner img { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; }
|
||||||
|
|
||||||
.logo { font-size: 16px; font-weight: 700; }
|
.logo { font-size: 16px; font-weight: 700; }
|
||||||
.muted { color: var(--text-muted); font-size: 12px; }
|
.muted { color: var(--text-muted); font-size: 12px; }
|
||||||
.home-link {
|
.home-link {
|
||||||
@@ -98,6 +117,16 @@ button:disabled { opacity: 0.4; cursor: default; }
|
|||||||
|
|
||||||
/* ── Board ───────────────────────────────────────────────────────────── */
|
/* ── Board ───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.board-undo {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 24px;
|
||||||
|
right: 24px;
|
||||||
|
z-index: 50;
|
||||||
|
font-size: 15px;
|
||||||
|
padding: 8px 20px;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
main {
|
main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -108,6 +137,7 @@ main {
|
|||||||
|
|
||||||
/* Full-bleed felt surface — flex:1 reliably fills main's remaining height. */
|
/* Full-bleed felt surface — flex:1 reliably fills main's remaining height. */
|
||||||
#board {
|
#board {
|
||||||
|
position: relative;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background: var(--felt);
|
background: var(--felt);
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -200,6 +230,68 @@ main {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Resume overlay ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
#resume-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(21, 21, 21, 0.92);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
#resume-overlay.hidden { display: none; }
|
||||||
|
|
||||||
|
.resume-card {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid rgba(255,255,255,0.12);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 40px 48px;
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0,0,0,0.6);
|
||||||
|
max-width: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resume-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resume-detail {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resume-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resume-actions button {
|
||||||
|
padding: 12px 24px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resume-actions button.secondary {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid rgba(255,255,255,0.2);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resume-actions button.secondary:hover {
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Win overlay ─────────────────────────────────────────────────────── */
|
/* ── Win overlay ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
#win-overlay {
|
#win-overlay {
|
||||||
|
|||||||
@@ -40,15 +40,33 @@
|
|||||||
<input type="checkbox" id="chk-draw3"> Draw 3
|
<input type="checkbox" id="chk-draw3"> Draw 3
|
||||||
</label>
|
</label>
|
||||||
<button id="btn-theme" title="Switch card theme">Dark</button>
|
<button id="btn-theme" title="Switch card theme">Dark</button>
|
||||||
|
<a id="hud-avatar" href="/account" title="Account" class="hud-avatar-link" style="display:none">
|
||||||
|
<div class="hud-avatar-inner">
|
||||||
|
<img id="hud-avatar-img" src="" alt="" style="display:none">
|
||||||
|
<span id="hud-avatar-initials"></span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<section id="board">
|
<section id="board">
|
||||||
<div id="card-area"></div>
|
<div id="card-area"></div>
|
||||||
|
<button id="btn-board-undo" class="board-undo" title="Undo (Z)" disabled>↩ Undo</button>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<div id="resume-overlay" class="hidden">
|
||||||
|
<div class="resume-card">
|
||||||
|
<div class="resume-title">Resume Game?</div>
|
||||||
|
<p class="resume-detail">You have an unfinished game saved. Would you like to continue where you left off?</p>
|
||||||
|
<div class="resume-actions">
|
||||||
|
<button id="btn-resume">↩ Resume</button>
|
||||||
|
<button id="btn-resume-new" class="secondary">↺ New Game</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="win-overlay" class="hidden">
|
<div id="win-overlay" class="hidden">
|
||||||
<div class="win-card">
|
<div class="win-card">
|
||||||
<div class="win-title">You Won!</div>
|
<div class="win-title">You Won!</div>
|
||||||
|
|||||||
+125
-10
@@ -69,6 +69,34 @@ function preloadTheme(theme) {
|
|||||||
preloadTheme("classic");
|
preloadTheme("classic");
|
||||||
preloadTheme("dark");
|
preloadTheme("dark");
|
||||||
|
|
||||||
|
// ── Persistence ──────────────────────────────────────────────────────────────
|
||||||
|
const LS_SAVE_KEY = "fs_game_save";
|
||||||
|
|
||||||
|
function saveState() {
|
||||||
|
if (!game) return;
|
||||||
|
try {
|
||||||
|
const gameState = game.serialize();
|
||||||
|
if (typeof gameState !== "string") return;
|
||||||
|
localStorage.setItem(LS_SAVE_KEY, JSON.stringify({ gameState, elapsedSecs, drawThree }));
|
||||||
|
} catch (e) {
|
||||||
|
// localStorage may be unavailable (private browsing quota, etc.) — never block gameplay.
|
||||||
|
console.warn("fs: save failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSave() {
|
||||||
|
try { localStorage.removeItem(LS_SAVE_KEY); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSave() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(LS_SAVE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
const save = JSON.parse(raw);
|
||||||
|
return save?.gameState ? save : null;
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
// ── State ────────────────────────────────────────────────────────────────────
|
// ── State ────────────────────────────────────────────────────────────────────
|
||||||
let game = null;
|
let game = null;
|
||||||
let snap = null; // last rendered GameSnapshot
|
let snap = null; // last rendered GameSnapshot
|
||||||
@@ -104,6 +132,7 @@ const hudTimer = document.getElementById("hud-timer");
|
|||||||
const hudStock = document.getElementById("hud-stock");
|
const hudStock = document.getElementById("hud-stock");
|
||||||
const hudSeed = document.getElementById("hud-seed");
|
const hudSeed = document.getElementById("hud-seed");
|
||||||
const btnUndo = document.getElementById("btn-undo");
|
const btnUndo = document.getElementById("btn-undo");
|
||||||
|
const btnBoardUndo = document.getElementById("btn-board-undo");
|
||||||
const btnNew = document.getElementById("btn-new");
|
const btnNew = document.getElementById("btn-new");
|
||||||
const chkDraw3 = document.getElementById("chk-draw3");
|
const chkDraw3 = document.getElementById("chk-draw3");
|
||||||
const btnTheme = document.getElementById("btn-theme");
|
const btnTheme = document.getElementById("btn-theme");
|
||||||
@@ -137,16 +166,72 @@ async function bootstrap() {
|
|||||||
await init();
|
await init();
|
||||||
syncThemeButton();
|
syncThemeButton();
|
||||||
|
|
||||||
|
buildSlots();
|
||||||
|
scaleBoard();
|
||||||
|
window.addEventListener("resize", scaleBoard);
|
||||||
|
attachHandlers();
|
||||||
|
|
||||||
|
const saved = loadSave();
|
||||||
|
if (saved) {
|
||||||
|
showResumeDialog(saved);
|
||||||
|
} else {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const urlSeed = params.has("seed") ? Number(params.get("seed")) : randomSeed();
|
const urlSeed = params.has("seed") ? Number(params.get("seed")) : randomSeed();
|
||||||
drawThree = params.has("draw3");
|
drawThree = params.has("draw3");
|
||||||
chkDraw3.checked = drawThree;
|
chkDraw3.checked = drawThree;
|
||||||
|
|
||||||
buildSlots();
|
|
||||||
scaleBoard();
|
|
||||||
window.addEventListener("resize", scaleBoard);
|
|
||||||
startGame(urlSeed);
|
startGame(urlSeed);
|
||||||
attachHandlers();
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showResumeDialog(saved) {
|
||||||
|
const overlay = document.getElementById("resume-overlay");
|
||||||
|
if (overlay) overlay.classList.remove("hidden");
|
||||||
|
|
||||||
|
document.getElementById("btn-resume").onclick = () => {
|
||||||
|
if (overlay) overlay.classList.add("hidden");
|
||||||
|
resumeGame(saved);
|
||||||
|
};
|
||||||
|
document.getElementById("btn-resume-new").onclick = () => {
|
||||||
|
clearSave();
|
||||||
|
if (overlay) overlay.classList.add("hidden");
|
||||||
|
drawThree = false;
|
||||||
|
chkDraw3.checked = false;
|
||||||
|
startGame(randomSeed());
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resumeGame(saved) {
|
||||||
|
let restored;
|
||||||
|
try {
|
||||||
|
restored = SolitaireGame.from_saved(saved.gameState);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("fs: restore failed, starting new game", e);
|
||||||
|
clearSave();
|
||||||
|
startGame(randomSeed());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
game = restored;
|
||||||
|
drawThree = !!saved.drawThree;
|
||||||
|
elapsedSecs = saved.elapsedSecs || 0;
|
||||||
|
chkDraw3.checked = drawThree;
|
||||||
|
|
||||||
|
const displaySeed = Math.round(game.seed());
|
||||||
|
hudSeed.textContent = `seed ${displaySeed}`;
|
||||||
|
winOverlay.classList.add("hidden");
|
||||||
|
cardEls.clear();
|
||||||
|
board.querySelectorAll(".card, .recycle-label").forEach(el => el.remove());
|
||||||
|
|
||||||
|
const url = new URL(window.location);
|
||||||
|
url.searchParams.set("seed", displaySeed);
|
||||||
|
if (drawThree) url.searchParams.set("draw3", "");
|
||||||
|
else url.searchParams.delete("draw3");
|
||||||
|
history.replaceState(null, "", url);
|
||||||
|
|
||||||
|
const s = game.state();
|
||||||
|
snap = s;
|
||||||
|
render(s);
|
||||||
|
if (!s.is_won) startTimer();
|
||||||
}
|
}
|
||||||
|
|
||||||
function randomSeed() {
|
function randomSeed() {
|
||||||
@@ -244,6 +329,7 @@ function render(s) {
|
|||||||
hudMoves.textContent = `Moves: ${s.move_count}`;
|
hudMoves.textContent = `Moves: ${s.move_count}`;
|
||||||
if (hudStock) hudStock.textContent = `Stock: ${s.stock.length}`;
|
if (hudStock) hudStock.textContent = `Stock: ${s.stock.length}`;
|
||||||
btnUndo.disabled = s.undo_stack_len === 0;
|
btnUndo.disabled = s.undo_stack_len === 0;
|
||||||
|
btnBoardUndo.disabled = s.undo_stack_len === 0;
|
||||||
|
|
||||||
const visible = new Map();
|
const visible = new Map();
|
||||||
const addPile = (name, cards) =>
|
const addPile = (name, cards) =>
|
||||||
@@ -298,12 +384,16 @@ function render(s) {
|
|||||||
board.querySelectorAll(".card.drop-target").forEach(e => e.classList.remove("drop-target"));
|
board.querySelectorAll(".card.drop-target").forEach(e => e.classList.remove("drop-target"));
|
||||||
|
|
||||||
if (s.is_auto_completable && !s.is_won && !acTimer) {
|
if (s.is_auto_completable && !s.is_won && !acTimer) {
|
||||||
|
stopTimer(); // freeze elapsed time at the moment the player's last move completes
|
||||||
acTimer = setInterval(doAutoCompleteStep, 380);
|
acTimer = setInterval(doAutoCompleteStep, 380);
|
||||||
}
|
}
|
||||||
if (s.is_won) {
|
if (s.is_won) {
|
||||||
|
clearSave();
|
||||||
stopTimer();
|
stopTimer();
|
||||||
if (acTimer) { clearInterval(acTimer); acTimer = null; }
|
if (acTimer) { clearInterval(acTimer); acTimer = null; }
|
||||||
showWin(s);
|
showWin(s);
|
||||||
|
} else {
|
||||||
|
saveState();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,10 +475,9 @@ function flashIllegal(cardIds) {
|
|||||||
|
|
||||||
// ── Input ─────────────────────────────────────────────────────────────────────
|
// ── Input ─────────────────────────────────────────────────────────────────────
|
||||||
function attachHandlers() {
|
function attachHandlers() {
|
||||||
btnUndo.addEventListener("click", () => {
|
const doUndo = () => { const r = game.undo(); if (r.ok) render(r.snapshot); };
|
||||||
const r = game.undo();
|
btnUndo.addEventListener("click", doUndo);
|
||||||
if (r.ok) render(r.snapshot);
|
btnBoardUndo.addEventListener("click", doUndo);
|
||||||
});
|
|
||||||
btnNew.addEventListener("click", () => startGame(randomSeed()));
|
btnNew.addEventListener("click", () => startGame(randomSeed()));
|
||||||
btnWinNew.addEventListener("click", () => startGame(randomSeed()));
|
btnWinNew.addEventListener("click", () => startGame(randomSeed()));
|
||||||
chkDraw3.addEventListener("change", () => {
|
chkDraw3.addEventListener("change", () => {
|
||||||
@@ -404,7 +493,7 @@ function attachHandlers() {
|
|||||||
|
|
||||||
document.addEventListener("keydown", (e) => {
|
document.addEventListener("keydown", (e) => {
|
||||||
if (e.target.tagName === "INPUT") return;
|
if (e.target.tagName === "INPUT") return;
|
||||||
if (e.key === "z" || e.key === "Z") { const r = game.undo(); if (r.ok) render(r.snapshot); }
|
if (e.key === "z" || e.key === "Z") doUndo();
|
||||||
if (e.key === "n" || e.key === "N") startGame(randomSeed());
|
if (e.key === "n" || e.key === "N") startGame(randomSeed());
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -662,5 +751,31 @@ function onBoardDblClick(e) {
|
|||||||
if (!smartMove(hit.pileName, fromIndex)) flashIllegal([cards[fromIndex].id]);
|
if (!smartMove(hit.pileName, fromIndex)) flashIllegal([cards[fromIndex].id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Avatar ────────────────────────────────────────────────────────────────────
|
||||||
|
async function loadAvatar() {
|
||||||
|
const token = localStorage.getItem("fs_token");
|
||||||
|
if (!token) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/me", {
|
||||||
|
headers: { Authorization: "Bearer " + token },
|
||||||
|
});
|
||||||
|
if (!res.ok) return;
|
||||||
|
const me = await res.json();
|
||||||
|
const link = document.getElementById("hud-avatar");
|
||||||
|
const img = document.getElementById("hud-avatar-img");
|
||||||
|
const init = document.getElementById("hud-avatar-initials");
|
||||||
|
link.style.display = "flex";
|
||||||
|
if (me.avatar_url) {
|
||||||
|
img.src = me.avatar_url;
|
||||||
|
img.style.display = "block";
|
||||||
|
init.style.display = "none";
|
||||||
|
} else {
|
||||||
|
img.style.display = "none";
|
||||||
|
init.textContent = (me.username || "P")[0].toUpperCase();
|
||||||
|
}
|
||||||
|
} catch { /* not signed in — avatar stays hidden */ }
|
||||||
|
}
|
||||||
|
|
||||||
// ── Start ─────────────────────────────────────────────────────────────────────
|
// ── Start ─────────────────────────────────────────────────────────────────────
|
||||||
bootstrap().catch(console.error);
|
bootstrap().catch(console.error);
|
||||||
|
loadAvatar();
|
||||||
|
|||||||
@@ -30,7 +30,8 @@
|
|||||||
<section id="board"></section>
|
<section id="board"></section>
|
||||||
|
|
||||||
<section id="controls">
|
<section id="controls">
|
||||||
<button id="btn-prev" disabled>⏮ Restart</button>
|
<button id="btn-restart" disabled>⏮ Restart</button>
|
||||||
|
<button id="btn-prev" disabled>◀ Back</button>
|
||||||
<button id="btn-play">▶ Play</button>
|
<button id="btn-play">▶ Play</button>
|
||||||
<button id="btn-step">⏭ Step</button>
|
<button id="btn-step">⏭ Step</button>
|
||||||
<span id="progress" class="muted">step 0 / 0</span>
|
<span id="progress" class="muted">step 0 / 0</span>
|
||||||
|
|||||||
@@ -40,20 +40,32 @@ export class ReplayPlayer {
|
|||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Snapshot the current `GameState` as a JS object (see `StateSnapshot`).
|
* Snapshot the current `GameState` as a JS object (see `StateSnapshot`).
|
||||||
|
*
|
||||||
|
* Throws a JS string exception on serialisation failure (should never
|
||||||
|
* occur in practice — `StateSnapshot` contains only primitive types).
|
||||||
* @returns {any}
|
* @returns {any}
|
||||||
*/
|
*/
|
||||||
state() {
|
state() {
|
||||||
const ret = wasm.replayplayer_state(this.__wbg_ptr);
|
const ret = wasm.replayplayer_state(this.__wbg_ptr);
|
||||||
return ret;
|
if (ret[2]) {
|
||||||
|
throw takeFromExternrefTable0(ret[1]);
|
||||||
|
}
|
||||||
|
return takeFromExternrefTable0(ret[0]);
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Apply the next move; returns the post-step snapshot, or `null`
|
* Apply the next move; returns the post-step snapshot, or `null`
|
||||||
* once the move list is exhausted.
|
* once the move list is exhausted.
|
||||||
|
*
|
||||||
|
* Returns `null` (not an exception) when the replay is finished.
|
||||||
|
* Throws a JS string exception on serialisation failure.
|
||||||
* @returns {any}
|
* @returns {any}
|
||||||
*/
|
*/
|
||||||
step() {
|
step() {
|
||||||
const ret = wasm.replayplayer_step(this.__wbg_ptr);
|
const ret = wasm.replayplayer_step(this.__wbg_ptr);
|
||||||
return ret;
|
if (ret[2]) {
|
||||||
|
throw takeFromExternrefTable0(ret[1]);
|
||||||
|
}
|
||||||
|
return takeFromExternrefTable0(ret[0]);
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* 0-indexed position of the next move to apply.
|
* 0-indexed position of the next move to apply.
|
||||||
@@ -157,11 +169,16 @@ export class SolitaireGame {
|
|||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Full pile snapshot as a JS object.
|
* Full pile snapshot as a JS object.
|
||||||
|
*
|
||||||
|
* Throws a JS string exception on serialisation failure.
|
||||||
* @returns {any}
|
* @returns {any}
|
||||||
*/
|
*/
|
||||||
state() {
|
state() {
|
||||||
const ret = wasm.solitairegame_state(this.__wbg_ptr);
|
const ret = wasm.solitairegame_state(this.__wbg_ptr);
|
||||||
return ret;
|
if (ret[2]) {
|
||||||
|
throw takeFromExternrefTable0(ret[1]);
|
||||||
|
}
|
||||||
|
return takeFromExternrefTable0(ret[0]);
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Undo the last move. Returns `{ok, error?, snapshot?}`.
|
* Undo the last move. Returns `{ok, error?, snapshot?}`.
|
||||||
@@ -180,6 +197,13 @@ function __wbg_get_imports() {
|
|||||||
const ret = Error(getStringFromWasm0(arg0, arg1));
|
const ret = Error(getStringFromWasm0(arg0, arg1));
|
||||||
return ret;
|
return ret;
|
||||||
},
|
},
|
||||||
|
__wbg_String_8564e559799eccda: function(arg0, arg1) {
|
||||||
|
const ret = String(arg1);
|
||||||
|
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||||
|
const len1 = WASM_VECTOR_LEN;
|
||||||
|
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
|
||||||
|
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
|
||||||
|
},
|
||||||
__wbg___wbindgen_throw_9c75d47bf9e7731e: function(arg0, arg1) {
|
__wbg___wbindgen_throw_9c75d47bf9e7731e: function(arg0, arg1) {
|
||||||
throw new Error(getStringFromWasm0(arg0, arg1));
|
throw new Error(getStringFromWasm0(arg0, arg1));
|
||||||
},
|
},
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user