Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 73c7f50f74 | |||
| 83c40116af | |||
| 347d5a4b4f | |||
| 93f2ceaabe | |||
| e390b72222 | |||
| 3650788dc5 | |||
| 39cf8dcd6c | |||
| 456b4d42e3 | |||
| e1c8ae0743 | |||
| 8f86d66ffe |
@@ -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,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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -20,4 +20,4 @@ resources:
|
||||
images:
|
||||
- name: solitaire-server
|
||||
newName: git.aleshym.co/funman300/solitaire-server
|
||||
newTag: eb6c93fb
|
||||
newTag: 83c40116
|
||||
|
||||
+88
-33
@@ -10,6 +10,9 @@ pub enum 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).
|
||||
pub fn is_red(self) -> bool {
|
||||
matches!(self, Suit::Diamonds | Suit::Hearts)
|
||||
@@ -24,38 +27,63 @@ impl Suit {
|
||||
/// Card rank, Ace through King.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum Rank {
|
||||
Ace,
|
||||
Two,
|
||||
Three,
|
||||
Four,
|
||||
Five,
|
||||
Six,
|
||||
Seven,
|
||||
Eight,
|
||||
Nine,
|
||||
Ten,
|
||||
Jack,
|
||||
Queen,
|
||||
King,
|
||||
Ace = 1,
|
||||
Two = 2,
|
||||
Three = 3,
|
||||
Four = 4,
|
||||
Five = 5,
|
||||
Six = 6,
|
||||
Seven = 7,
|
||||
Eight = 8,
|
||||
Nine = 9,
|
||||
Ten = 10,
|
||||
Jack = 11,
|
||||
Queen = 12,
|
||||
King = 13,
|
||||
}
|
||||
|
||||
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.
|
||||
pub fn value(self) -> u8 {
|
||||
match self {
|
||||
Rank::Ace => 1,
|
||||
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 => 11,
|
||||
Rank::Queen => 12,
|
||||
Rank::King => 13,
|
||||
self as u8
|
||||
}
|
||||
|
||||
const fn new(n: u8) -> Option<Self> {
|
||||
match n {
|
||||
1 => Some(Self::Ace),
|
||||
2 => Some(Self::Two),
|
||||
3 => Some(Self::Three),
|
||||
4 => Some(Self::Four),
|
||||
5 => Some(Self::Five),
|
||||
6 => Some(Self::Six),
|
||||
7 => Some(Self::Seven),
|
||||
8 => Some(Self::Eight),
|
||||
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]
|
||||
fn rank_values_are_sequential() {
|
||||
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,
|
||||
];
|
||||
for (i, r) in ranks.iter().enumerate() {
|
||||
for (i, r) in Rank::RANKS.iter().enumerate() {
|
||||
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]
|
||||
fn suit_red_and_black_are_complementary() {
|
||||
for suit in [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades] {
|
||||
|
||||
@@ -440,6 +440,91 @@ impl GameState {
|
||||
})
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// `None` if no such move exists (or `is_auto_completable` is not set).
|
||||
///
|
||||
@@ -1366,4 +1451,78 @@ mod tests {
|
||||
.unwrap_err();
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::card::Card;
|
||||
use crate::card::{Card, Rank};
|
||||
use crate::pile::Pile;
|
||||
|
||||
/// Returns `true` if `card` can be placed on the foundation `pile`.
|
||||
@@ -12,8 +12,8 @@ use crate::pile::Pile;
|
||||
#[must_use]
|
||||
pub fn can_place_on_foundation(card: &Card, pile: &Pile) -> bool {
|
||||
match pile.cards.last() {
|
||||
None => card.rank.value() == 1,
|
||||
Some(top) => card.suit == top.suit && card.rank.value() == top.rank.value() + 1,
|
||||
None => card.rank == Rank::Ace,
|
||||
Some(top) => card.suit == top.suit && card.rank.checked_sub(1) == Some(top.rank),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,10 +23,10 @@ pub fn can_place_on_foundation(card: &Card, pile: &Pile) -> bool {
|
||||
#[must_use]
|
||||
pub fn can_place_on_tableau(card: &Card, pile: &Pile) -> bool {
|
||||
match pile.cards.last() {
|
||||
None => card.rank.value() == 13,
|
||||
None => card.rank == Rank::King,
|
||||
Some(top) => {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,7 @@ pub fn can_place_on_tableau(card: &Card, pile: &Pile) -> bool {
|
||||
#[must_use]
|
||||
pub fn is_valid_tableau_sequence(cards: &[Card]) -> bool {
|
||||
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()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -238,6 +238,12 @@ pub struct Settings {
|
||||
/// field existed deserialize cleanly to `None` via `#[serde(default)]`.
|
||||
#[serde(default)]
|
||||
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
|
||||
/// onto a compatible tableau column. Enabled by default (standard Klondike
|
||||
/// rules). Older `settings.json` files without this key deserialize to
|
||||
@@ -387,6 +393,7 @@ impl Default for Settings {
|
||||
replay_move_interval_secs: default_replay_move_interval_secs(),
|
||||
last_difficulty: None,
|
||||
leaderboard_display_name: None,
|
||||
leaderboard_opted_in: false,
|
||||
take_from_foundation: true,
|
||||
analytics_enabled: false,
|
||||
matomo_url: None,
|
||||
|
||||
@@ -691,6 +691,22 @@ fn sync_cards(
|
||||
) {
|
||||
let positions = card_positions(game, layout);
|
||||
|
||||
// The waste buffer card exists only to keep its entity alive while the new
|
||||
// top card's slide animation plays — it must never be visible to the player.
|
||||
// Without this, the buffer sits at waste_base uncovered during the animation
|
||||
// and its rank/suit peek behind the incoming card.
|
||||
let waste_buffer_id: Option<u32> = {
|
||||
let visible = match game.draw_mode {
|
||||
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, has_card_animation) for
|
||||
// in-place updates. The `has_card_animation` flag lets `update_card_entity`
|
||||
// skip the snap/slide path on cards that are already being driven by a
|
||||
@@ -711,17 +727,26 @@ 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 {
|
||||
match existing.get(&card.id) {
|
||||
let entity = match existing.get(&card.id) {
|
||||
Some(&(entity, cur, has_anim)) => {
|
||||
update_card_entity(
|
||||
&mut commands, entity, card, position, z, layout,
|
||||
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, font_handle),
|
||||
}
|
||||
};
|
||||
let visibility = if waste_buffer_id == Some(card.id) {
|
||||
Visibility::Hidden
|
||||
} else {
|
||||
Visibility::Inherited
|
||||
};
|
||||
commands.entity(entity).insert(visibility);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -831,7 +856,7 @@ fn spawn_card_entity(
|
||||
card_images: Option<&CardImageSet>,
|
||||
selected_back: usize,
|
||||
font_handle: Option<&Handle<Font>>,
|
||||
) {
|
||||
) -> Entity {
|
||||
let sprite = card_sprite(card, layout.card_size, back_colour, card_images, selected_back);
|
||||
|
||||
let mut entity = commands.spawn((
|
||||
@@ -840,6 +865,7 @@ fn spawn_card_entity(
|
||||
Transform::from_xyz(pos.x, pos.y, z),
|
||||
Visibility::default(),
|
||||
));
|
||||
let entity_id = entity.id();
|
||||
// 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
|
||||
// system retunes its offset / alpha when this card joins the dragged
|
||||
@@ -880,6 +906,7 @@ fn spawn_card_entity(
|
||||
// Suppress unused-variable warning when not building for Android.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
let _ = font_handle;
|
||||
entity_id
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -2357,6 +2384,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]
|
||||
fn card_positions_tableau_cards_are_fanned_downward() {
|
||||
let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne);
|
||||
|
||||
@@ -45,7 +45,7 @@ use crate::layout::LayoutSystem;
|
||||
use crate::pause_plugin::PausedResource;
|
||||
use crate::resources::GameStateResource;
|
||||
#[cfg(target_os = "android")]
|
||||
use crate::resources::DragState;
|
||||
use crate::resources::{DragState, GameInputConsumedResource};
|
||||
use crate::selection_plugin::SelectionState;
|
||||
use crate::time_attack_plugin::TimeAttackResource;
|
||||
use crate::ui_focus::{FocusGroup, Focusable};
|
||||
@@ -2492,6 +2492,7 @@ fn toggle_hud_on_tap(
|
||||
mut tracker: ResMut<HudTapTracker>,
|
||||
mut hud_vis: ResMut<HudVisibility>,
|
||||
buttons: Query<&Interaction, With<ActionButton>>,
|
||||
mut game_consumed: ResMut<GameInputConsumedResource>,
|
||||
) {
|
||||
use bevy::input::touch::TouchPhase;
|
||||
if !scrims.is_empty() || paused.is_some_and(|p| p.0) {
|
||||
@@ -2502,6 +2503,7 @@ fn toggle_hud_on_tap(
|
||||
for _ in touch_events.read() {}
|
||||
tracker.start_pos = None;
|
||||
tracker.started_on_button = false;
|
||||
game_consumed.0 = false;
|
||||
return;
|
||||
}
|
||||
for event in touch_events.read() {
|
||||
@@ -2515,7 +2517,13 @@ fn toggle_hud_on_tap(
|
||||
buttons.iter().any(|i| *i != Interaction::None);
|
||||
}
|
||||
TouchPhase::Ended if drag.is_idle() => {
|
||||
let on_button = tracker.started_on_button;
|
||||
// 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 !on_button && (event.position - start).length() < HUD_TAP_SLOP_PX {
|
||||
*hud_vis = match *hud_vis {
|
||||
@@ -2532,6 +2540,7 @@ fn toggle_hud_on_tap(
|
||||
TouchPhase::Canceled => {
|
||||
tracker.start_pos = None;
|
||||
tracker.started_on_button = false;
|
||||
game_consumed.0 = false;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ use crate::game_plugin::{ConfirmNewGameScreen, GameMutation, RestorePromptScreen
|
||||
use crate::pause_plugin::PausedResource;
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
use crate::layout::{Layout, LayoutResource};
|
||||
use crate::resources::{DragState, GameStateResource, HintCycleIndex};
|
||||
use crate::resources::{DragState, GameInputConsumedResource, GameStateResource, HintCycleIndex};
|
||||
use crate::selection_plugin::SelectionState;
|
||||
use crate::time_attack_plugin::TimeAttackResource;
|
||||
|
||||
@@ -95,6 +95,7 @@ impl Plugin for InputPlugin {
|
||||
app.init_resource::<HintCycleIndex>()
|
||||
.init_resource::<HintSolverConfig>()
|
||||
.init_resource::<crate::pending_hint::PendingHintTask>()
|
||||
.init_resource::<GameInputConsumedResource>()
|
||||
.add_message::<StartZenRequestEvent>()
|
||||
.add_message::<InfoToastEvent>()
|
||||
.add_message::<ForfeitRequestEvent>()
|
||||
@@ -501,6 +502,7 @@ fn handle_touch_stock_tap(
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
drag: Res<DragState>,
|
||||
mut draw: MessageWriter<DrawRequestEvent>,
|
||||
mut game_consumed: ResMut<GameInputConsumedResource>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
@@ -522,6 +524,7 @@ fn handle_touch_stock_tap(
|
||||
};
|
||||
if point_in_rect(world, stock_pos, layout.0.card_size) {
|
||||
draw.write(DrawRequestEvent);
|
||||
game_consumed.0 = true;
|
||||
break; // one draw per tap frame
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
|
||||
use solitaire_data::{save_settings_to, settings::SyncBackend};
|
||||
use solitaire_sync::LeaderboardEntry;
|
||||
|
||||
use crate::events::{InfoToastEvent, ToggleLeaderboardRequestEvent};
|
||||
use crate::events::{InfoToastEvent, ToggleLeaderboardRequestEvent, WarningToastEvent};
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
|
||||
use crate::sync_plugin::SyncProviderResource;
|
||||
@@ -138,6 +138,7 @@ impl Plugin for LeaderboardPlugin {
|
||||
.init_resource::<OptOutTask>()
|
||||
.init_resource::<DisplayNameBuffer>()
|
||||
.add_message::<ToggleLeaderboardRequestEvent>()
|
||||
.add_message::<WarningToastEvent>()
|
||||
// `MouseWheel` and `KeyboardInput` are emitted by Bevy's input
|
||||
// plugin under `DefaultPlugins`; register them explicitly so all
|
||||
// leaderboard systems run cleanly under `MinimalPlugins` in tests.
|
||||
@@ -159,6 +160,7 @@ impl Plugin for LeaderboardPlugin {
|
||||
handle_display_name_text_input,
|
||||
handle_display_name_confirm,
|
||||
handle_display_name_cancel,
|
||||
update_leaderboard_public_name_label,
|
||||
)
|
||||
.chain(),
|
||||
)
|
||||
@@ -361,10 +363,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(
|
||||
mut task_res: ResMut<OptInTask>,
|
||||
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(result) = future::block_on(future::poll_once(task)) else { return };
|
||||
@@ -372,10 +377,18 @@ fn poll_opt_in_task(
|
||||
match result {
|
||||
Ok(()) => {
|
||||
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) => {
|
||||
warn!("leaderboard opt-in failed: {e}");
|
||||
toast.write(InfoToastEvent("Failed to join leaderboard".to_string()));
|
||||
warn_toast.write(WarningToastEvent("Failed to join leaderboard".to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -401,10 +414,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(
|
||||
mut task_res: ResMut<OptOutTask>,
|
||||
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(result) = future::block_on(future::poll_once(task)) else { return };
|
||||
@@ -412,10 +428,18 @@ fn poll_opt_out_task(
|
||||
match result {
|
||||
Ok(()) => {
|
||||
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) => {
|
||||
warn!("leaderboard opt-out failed: {e}");
|
||||
toast.write(InfoToastEvent("Failed to leave leaderboard".to_string()));
|
||||
warn_toast.write(WarningToastEvent("Failed to leave leaderboard".to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -428,6 +452,12 @@ fn poll_opt_out_task(
|
||||
#[derive(Component, Debug)]
|
||||
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(
|
||||
commands: &mut Commands,
|
||||
data: &LeaderboardResource,
|
||||
@@ -481,6 +511,7 @@ fn spawn_leaderboard_screen(
|
||||
None => "Public name: (same as username)".to_string(),
|
||||
};
|
||||
row.spawn((
|
||||
LeaderboardPublicNameText,
|
||||
Text::new(label),
|
||||
font_caption.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
@@ -733,7 +764,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(
|
||||
button_q: Query<&Interaction, (Changed<Interaction>, With<DisplayNameConfirmButton>)>,
|
||||
screens: Query<Entity, With<DisplayNameModal>>,
|
||||
@@ -741,6 +774,8 @@ fn handle_display_name_confirm(
|
||||
buf: Res<DisplayNameBuffer>,
|
||||
settings: Option<ResMut<SettingsResource>>,
|
||||
settings_path: Option<Res<SettingsStoragePath>>,
|
||||
provider: Option<Res<SyncProviderResource>>,
|
||||
mut task_res: ResMut<OptInTask>,
|
||||
) {
|
||||
if !button_q.iter().any(|i| *i == Interaction::Pressed) {
|
||||
return;
|
||||
@@ -750,13 +785,47 @@ fn handle_display_name_confirm(
|
||||
settings.0.leaderboard_display_name = if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed)
|
||||
Some(trimmed.clone())
|
||||
};
|
||||
if let Some(path) = settings_path.as_ref().and_then(|p| p.0.as_ref())
|
||||
&& let Err(e) = save_settings_to(path, &settings.0)
|
||||
{
|
||||
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 {
|
||||
commands.entity(entity).despawn();
|
||||
@@ -857,6 +926,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.
|
||||
fn printable_char_dn(text: &str) -> Option<char> {
|
||||
let ch = text.chars().next()?;
|
||||
@@ -1048,4 +1136,171 @@ mod tests {
|
||||
// 65 seconds = 1:05, not 1:5
|
||||
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);
|
||||
|
||||
// Allow the task to complete and be polled.
|
||||
for _ in 0..5 {
|
||||
app.update();
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
for _ in 0..5 {
|
||||
app.update();
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
for _ in 0..5 {
|
||||
app.update();
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
for _ in 0..5 {
|
||||
app.update();
|
||||
}
|
||||
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +114,13 @@ pub struct HintCycleIndex(pub usize);
|
||||
#[derive(Resource, Debug, Clone, Default)]
|
||||
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
|
||||
|
||||
@@ -300,6 +300,7 @@ function render(s) {
|
||||
board.querySelectorAll(".card.drop-target").forEach(e => e.classList.remove("drop-target"));
|
||||
|
||||
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);
|
||||
}
|
||||
if (s.is_won) {
|
||||
|
||||
@@ -40,20 +40,32 @@ export class ReplayPlayer {
|
||||
}
|
||||
/**
|
||||
* 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}
|
||||
*/
|
||||
state() {
|
||||
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`
|
||||
* 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}
|
||||
*/
|
||||
step() {
|
||||
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.
|
||||
@@ -157,11 +169,16 @@ export class SolitaireGame {
|
||||
}
|
||||
/**
|
||||
* Full pile snapshot as a JS object.
|
||||
*
|
||||
* Throws a JS string exception on serialisation failure.
|
||||
* @returns {any}
|
||||
*/
|
||||
state() {
|
||||
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?}`.
|
||||
@@ -180,6 +197,13 @@ function __wbg_get_imports() {
|
||||
const ret = Error(getStringFromWasm0(arg0, arg1));
|
||||
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) {
|
||||
throw new Error(getStringFromWasm0(arg0, arg1));
|
||||
},
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user