Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ac36c73d40 |
@@ -1,7 +0,0 @@
|
|||||||
# Claude Flow runtime files
|
|
||||||
data/
|
|
||||||
logs/
|
|
||||||
sessions/
|
|
||||||
neural/
|
|
||||||
*.log
|
|
||||||
*.tmp
|
|
||||||
@@ -1,403 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"initialized": "2026-05-19T00:18:20.864Z",
|
|
||||||
"status": "PENDING",
|
|
||||||
"cvesFixed": 0,
|
|
||||||
"totalCves": 3,
|
|
||||||
"lastScan": null,
|
|
||||||
"_note": "Run: npx @claude-flow/cli@latest security scan"
|
|
||||||
}
|
|
||||||
@@ -4,12 +4,6 @@ on:
|
|||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
tag:
|
|
||||||
description: 'Release tag (e.g. v0.36.2)'
|
|
||||||
required: true
|
|
||||||
default: 'v0.36.2'
|
|
||||||
|
|
||||||
env:
|
env:
|
||||||
APK_OUT: target/release/apk/ferrous-solitaire.apk
|
APK_OUT: target/release/apk/ferrous-solitaire.apk
|
||||||
@@ -48,12 +42,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Get tag name
|
- name: Get tag name
|
||||||
id: tag
|
id: tag
|
||||||
run: |
|
run: echo "name=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
||||||
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
|
- name: Decode release keystore
|
||||||
run: echo "${{ secrets.RELEASE_KEYSTORE_B64 }}" | base64 -d > release.jks
|
run: echo "${{ secrets.RELEASE_KEYSTORE_B64 }}" | base64 -d > release.jks
|
||||||
|
|||||||
@@ -8,10 +8,6 @@
|
|||||||
data/
|
data/
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
# ruflo runtime state
|
|
||||||
agentdb.rvf
|
|
||||||
agentdb.rvf.lock
|
|
||||||
|
|
||||||
# IDE project files
|
# IDE project files
|
||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 112px on Android vs 64px on desktop;
|
* `HUD_BAND_HEIGHT` is 128px on Android (two-row wrap) 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
|
||||||
|
|||||||
@@ -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: ea9dd848
|
newTag: eb6c93fb
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ impl GameState {
|
|||||||
is_auto_completable: false,
|
is_auto_completable: false,
|
||||||
undo_count: 0,
|
undo_count: 0,
|
||||||
recycle_count: 0,
|
recycle_count: 0,
|
||||||
take_from_foundation: false,
|
take_from_foundation: true,
|
||||||
schema_version: GAME_STATE_SCHEMA_VERSION,
|
schema_version: GAME_STATE_SCHEMA_VERSION,
|
||||||
undo_stack: VecDeque::new(),
|
undo_stack: VecDeque::new(),
|
||||||
}
|
}
|
||||||
@@ -407,7 +407,7 @@ impl GameState {
|
|||||||
self.score = if self.mode == GameMode::Zen {
|
self.score = if self.mode == GameMode::Zen {
|
||||||
0
|
0
|
||||||
} else {
|
} else {
|
||||||
(self.score + scoring_undo()).max(0)
|
(snapshot.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;
|
||||||
@@ -416,25 +416,12 @@ impl GameState {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns `true` when all four foundation slots each contain a valid A→K
|
/// Returns `true` when all four foundation slots each contain 13 cards.
|
||||||
/// 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| self.is_valid_foundation_pile(slot))
|
(0..4_u8).all(|slot| {
|
||||||
}
|
self.piles
|
||||||
|
.get(&PileType::Foundation(slot))
|
||||||
fn is_valid_foundation_pile(&self, slot: u8) -> bool {
|
.is_some_and(|p| p.cards.len() == 13)
|
||||||
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)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1408,9 +1395,12 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn take_from_foundation_disabled_by_default() {
|
fn take_from_foundation_allowed_by_default() {
|
||||||
let g = setup_take_from_foundation_game();
|
let mut g = setup_take_from_foundation_game();
|
||||||
assert!(!g.take_from_foundation, "take_from_foundation is off by default (non-standard rule)");
|
assert!(g.take_from_foundation, "standard Klondike allows take-from-foundation by default");
|
||||||
|
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]
|
||||||
|
|||||||
@@ -9,11 +9,9 @@ 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(_) => match from {
|
PileType::Tableau(_) => {
|
||||||
PileType::Waste => 5,
|
if matches!(from, PileType::Waste) { 5 } else { 0 }
|
||||||
PileType::Foundation(_) => -15,
|
}
|
||||||
_ => 0,
|
|
||||||
},
|
|
||||||
_ => 0,
|
_ => 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,12 +71,13 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn foundation_to_tableau_penalises_fifteen() {
|
fn non_waste_to_tableau_scores_zero() {
|
||||||
// Moving a card back off a foundation (take_from_foundation rule) costs -15.
|
// Foundation → Tableau is impossible in practice but must score 0.
|
||||||
assert_eq!(score_move(&PileType::Foundation(0), &PileType::Tableau(0)), -15);
|
assert_eq!(score_move(&PileType::Foundation(0), &PileType::Tableau(0)), 0);
|
||||||
|
// 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,16 +298,9 @@ impl SolverState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True when every foundation slot holds a complete Ace-through-King sequence.
|
/// True when every foundation slot has 13 cards.
|
||||||
fn is_won(&self) -> bool {
|
fn is_won(&self) -> bool {
|
||||||
self.foundation.iter().all(|pile| {
|
self.foundation.iter().all(|f| f.len() == 13)
|
||||||
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
|
||||||
|
|||||||
@@ -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,
|
||||||
ModalScrim, ScrimDismissible,
|
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,7 +162,10 @@ fn evaluate_on_win(
|
|||||||
mut achievements: ResMut<AchievementsResource>,
|
mut achievements: ResMut<AchievementsResource>,
|
||||||
mut progress: ResMut<ProgressResource>,
|
mut progress: ResMut<ProgressResource>,
|
||||||
) {
|
) {
|
||||||
for ev in wins.read() {
|
let Some(ev) = wins.read().last() else {
|
||||||
|
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,
|
||||||
@@ -181,7 +184,7 @@ fn evaluate_on_win(
|
|||||||
|
|
||||||
let hits = check_achievements(&ctx);
|
let hits = check_achievements(&ctx);
|
||||||
if hits.is_empty() {
|
if hits.is_empty() {
|
||||||
continue;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
@@ -247,7 +250,6 @@ fn evaluate_on_win(
|
|||||||
warn!("failed to save progress after reward: {e}");
|
warn!("failed to save progress after reward: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// Cinephile unlock observer.
|
/// Cinephile unlock observer.
|
||||||
///
|
///
|
||||||
@@ -389,7 +391,6 @@ 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 {
|
||||||
@@ -397,7 +398,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 if other_modal_scrims.is_empty() {
|
} else {
|
||||||
spawn_achievements_screen(&mut commands, &achievements.0, font_res.as_deref());
|
spawn_achievements_screen(&mut commands, &achievements.0, font_res.as_deref());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,17 +72,6 @@ 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
|
||||||
@@ -258,11 +247,6 @@ 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
|
||||||
@@ -270,11 +254,7 @@ 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);
|
||||||
let mut pos = anim.start.lerp(anim.target, s);
|
transform.translation = 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>();
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ 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.
|
||||||
@@ -73,14 +72,9 @@ 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.
|
||||||
@@ -112,15 +106,11 @@ 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 {
|
||||||
|
|||||||
@@ -142,13 +142,6 @@ 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).
|
||||||
|
|||||||
@@ -41,9 +41,7 @@ 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.
|
||||||
/// Must match `layout::TABLEAU_FAN_FRAC` so the initial layout and the first
|
pub const TABLEAU_FAN_FRAC: f32 = 0.25;
|
||||||
/// 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
|
||||||
@@ -53,22 +51,18 @@ pub const TABLEAU_FAN_FRAC: f32 = 0.18;
|
|||||||
/// 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.14). Both constants must
|
/// Matches `layout::TABLEAU_FACEDOWN_FAN_FRAC` (0.20). 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.14;
|
pub const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.20;
|
||||||
|
|
||||||
/// 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`].
|
||||||
// Must exceed the highest child local-z of any card entity (0.02 for the
|
pub const STACK_FAN_FRAC: f32 = 0.003;
|
||||||
// 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;
|
||||||
@@ -184,8 +178,8 @@ pub struct CardLabel;
|
|||||||
/// readable at phone scale. Only exists when `CardImageSet` is present
|
/// readable at phone scale. Only exists when `CardImageSet` is present
|
||||||
/// (the fallback solid-colour path uses a plain `CardLabel` instead).
|
/// (the fallback solid-colour path uses a plain `CardLabel` instead).
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
#[derive(Component, Debug, Clone)]
|
#[derive(Component, Debug, Clone, Copy)]
|
||||||
struct AndroidCornerLabel(pub String);
|
struct AndroidCornerLabel;
|
||||||
|
|
||||||
/// Solid-colour background sprite behind [`AndroidCornerLabel`].
|
/// Solid-colour background sprite behind [`AndroidCornerLabel`].
|
||||||
///
|
///
|
||||||
@@ -713,20 +707,15 @@ fn sync_cards(
|
|||||||
.map(|c| c.id)
|
.map(|c| c.id)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Map card_id -> (Entity, current_translation, anim_end) for in-place
|
// Map card_id -> (Entity, current_translation, has_card_animation) for
|
||||||
// updates. `anim_end` is `Some(end_xy)` when a curve-based `CardAnimation`
|
// in-place updates. The `has_card_animation` flag lets `update_card_entity`
|
||||||
// is currently driving the card (e.g. a drag-rejection return tween).
|
// skip the snap/slide path on cards that are already being driven by a
|
||||||
//
|
// curve-based `CardAnimation` tween (e.g. the drag-rejection return tween
|
||||||
// In the position loop below we compare `anim_end` against the new game-
|
// — see `input_plugin::end_drag`). Otherwise the StateChangedEvent that
|
||||||
// state target position to decide whether to honour or cancel the tween:
|
// accompanies a rejection would race the tween and the card would jump.
|
||||||
// • end ≈ target → animation is still heading to the right place; let
|
let mut existing: HashMap<u32, (Entity, Vec3, bool)> = HashMap::new();
|
||||||
// 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.map(|a| a.end)));
|
existing.insert(marker.card_id, (entity, transform.translation, anim.is_some()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let live_ids: HashSet<u32> = positions.iter().map(|(c, _, _)| c.id).collect();
|
let live_ids: HashSet<u32> = positions.iter().map(|(c, _, _)| c.id).collect();
|
||||||
@@ -743,19 +732,7 @@ fn sync_cards(
|
|||||||
// behind the incoming top card during the draw slide animation.
|
// behind the incoming top card during the draw slide animation.
|
||||||
for (card, position, z) in positions {
|
for (card, position, z) in positions {
|
||||||
let entity = match existing.get(&card.id) {
|
let entity = match existing.get(&card.id) {
|
||||||
Some(&(entity, cur, anim_end)) => {
|
Some(&(entity, cur, has_anim)) => {
|
||||||
// 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, font_handle,
|
slide_secs, back_colour, color_blind, high_contrast, cur, has_anim, card_images, selected_back, font_handle,
|
||||||
@@ -1147,13 +1124,11 @@ fn add_android_corner_label(
|
|||||||
let bg_w = font_size * 2.0;
|
let bg_w = font_size * 2.0;
|
||||||
let bg_h = font_size * 1.25;
|
let bg_h = font_size * 1.25;
|
||||||
|
|
||||||
// Background covers the PNG's baked-in small corner text (top-left).
|
// Solid background that hides the card art's small corner label.
|
||||||
// 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((
|
parent.spawn((
|
||||||
AndroidCornerBg,
|
AndroidCornerBg,
|
||||||
Sprite {
|
Sprite {
|
||||||
color: Color::WHITE,
|
color: CARD_FACE_COLOUR,
|
||||||
custom_size: Some(Vec2::new(bg_w, bg_h)),
|
custom_size: Some(Vec2::new(bg_w, bg_h)),
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
@@ -1163,51 +1138,20 @@ fn add_android_corner_label(
|
|||||||
0.015,
|
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
|
// 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
|
// 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.
|
// 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((
|
parent.spawn((
|
||||||
AndroidCornerLabel(label_text.clone()),
|
AndroidCornerLabel,
|
||||||
CardLabel,
|
CardLabel,
|
||||||
Text2d::new(label_text),
|
Text2d::new(mobile_label_for(card)),
|
||||||
TextFont {
|
TextFont {
|
||||||
font: font_handle.cloned().unwrap_or_default(),
|
font: font_handle.cloned().unwrap_or_default(),
|
||||||
font_size,
|
font_size,
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
TextColor(text_col),
|
TextColor(text_colour(card, color_blind, high_contrast)),
|
||||||
Anchor::TOP_LEFT,
|
Anchor::TOP_LEFT,
|
||||||
Transform::from_xyz(
|
Transform::from_xyz(
|
||||||
-card_size.x / 2.0 + inset,
|
-card_size.x / 2.0 + inset,
|
||||||
@@ -2145,7 +2089,7 @@ fn resize_cards_in_place(
|
|||||||
fn resize_android_corner_labels(
|
fn resize_android_corner_labels(
|
||||||
layout: Res<LayoutResource>,
|
layout: Res<LayoutResource>,
|
||||||
card_images: Option<Res<CardImageSet>>,
|
card_images: Option<Res<CardImageSet>>,
|
||||||
mut text_query: Query<(&AndroidCornerLabel, &mut Text2d, &mut TextFont, &mut Transform)>,
|
mut text_query: Query<(&mut TextFont, &mut Transform), With<AndroidCornerLabel>>,
|
||||||
mut bg_query: Query<
|
mut bg_query: Query<
|
||||||
(&mut Sprite, &mut Transform),
|
(&mut Sprite, &mut Transform),
|
||||||
(With<AndroidCornerBg>, Without<AndroidCornerLabel>),
|
(With<AndroidCornerBg>, Without<AndroidCornerLabel>),
|
||||||
@@ -2161,8 +2105,7 @@ fn resize_android_corner_labels(
|
|||||||
let text_x = -layout.0.card_size.x / 2.0 + inset;
|
let text_x = -layout.0.card_size.x / 2.0 + inset;
|
||||||
let text_y = layout.0.card_size.y / 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() {
|
for (mut font, mut transform) in text_query.iter_mut() {
|
||||||
text2d.0 = label.0.clone();
|
|
||||||
font.font_size = font_size;
|
font.font_size = font_size;
|
||||||
transform.translation.x = text_x;
|
transform.translation.x = text_x;
|
||||||
transform.translation.y = text_y;
|
transform.translation.y = text_y;
|
||||||
|
|||||||
@@ -382,8 +382,8 @@ 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) -> Option<(Vec2, Vec2)> {
|
fn drop_overlay_rect(pile: &PileType, layout: &Layout, game: &GameState) -> (Vec2, Vec2) {
|
||||||
let centre = layout.pile_positions.get(pile).copied()?;
|
let centre = layout.pile_positions[pile];
|
||||||
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 {
|
||||||
@@ -393,13 +393,13 @@ fn drop_overlay_rect(pile: &PileType, layout: &Layout, game: &GameState) -> Opti
|
|||||||
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 Some((
|
return (
|
||||||
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),
|
||||||
));
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some((centre, layout.card_size))
|
(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,10 +410,7 @@ fn spawn_drop_target_overlay(
|
|||||||
layout: &Layout,
|
layout: &Layout,
|
||||||
game: &GameState,
|
game: &GameState,
|
||||||
) {
|
) {
|
||||||
let Some((centre, size)) = drop_overlay_rect(pile, layout, game) else {
|
let (centre, size) = drop_overlay_rect(pile, layout, game);
|
||||||
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
|
||||||
|
|||||||
@@ -210,15 +210,10 @@ 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,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,15 +31,8 @@ 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 = match Font::try_from_bytes(BUNDLED_FONT_BYTES.to_vec()) {
|
let font = Font::try_from_bytes(BUNDLED_FONT_BYTES.to_vec())
|
||||||
Ok(f) => f,
|
.expect("bundled FiraMono failed to parse — binary is corrupt");
|
||||||
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,8 +202,6 @@ 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),
|
||||||
@@ -230,7 +228,6 @@ 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)
|
||||||
@@ -238,23 +235,6 @@ 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(
|
||||||
@@ -634,7 +614,6 @@ 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>>,
|
||||||
@@ -660,10 +639,6 @@ 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 {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ use crate::font_plugin::FontResource;
|
|||||||
use crate::hud_plugin::ANDROID_HINT_LABEL;
|
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,
|
||||||
ModalScrim, ScrimDismissible,
|
ScrimDismissible,
|
||||||
};
|
};
|
||||||
use crate::ui_theme::{SPACE_2, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, 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"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
@@ -67,7 +67,6 @@ 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
|
||||||
@@ -78,7 +77,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 if other_modal_scrims.is_empty() {
|
} else {
|
||||||
spawn_help_screen(&mut commands, font_res.as_deref());
|
spawn_help_screen(&mut commands, font_res.as_deref());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,12 +140,6 @@ 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
|
/// Marker on the circular profile-picture button anchored to the
|
||||||
/// top-right of the HUD band. Pressing it opens the Profile overlay.
|
/// 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
|
/// Shows the server avatar image when loaded; falls back to the player's
|
||||||
@@ -495,11 +489,6 @@ 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>),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -854,25 +843,11 @@ fn handle_avatar_button(
|
|||||||
/// 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>>,
|
||||||
windows: Query<&Window>,
|
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
) {
|
) {
|
||||||
// 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: action_font_size,
|
font_size: TYPE_BODY,
|
||||||
..default()
|
..default()
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -895,12 +870,12 @@ fn spawn_action_buttons(
|
|||||||
);
|
);
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
let labels = (
|
let labels = (
|
||||||
"Menu \u{2193}",
|
"Menu \u{25BE}",
|
||||||
"Undo",
|
"Undo",
|
||||||
"Pause",
|
"Pause",
|
||||||
"Help",
|
"Help",
|
||||||
"Hint",
|
"Hint",
|
||||||
"Modes \u{2193}",
|
"Modes \u{25BE}",
|
||||||
"New Game",
|
"New Game",
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -989,7 +964,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(52.0), Val::Px(44.0));
|
let (pad, min_w, min_h) = (UiRect::axes(Val::Px(4.0), Val::Px(4.0)), Val::Px(44.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));
|
||||||
|
|
||||||
@@ -1017,9 +992,6 @@ 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 —
|
||||||
@@ -2511,32 +2483,6 @@ 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>,
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ 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::replay_playback::ReplayPlaybackState;
|
|
||||||
use crate::resources::{DragState, GameInputConsumedResource, GameStateResource, HintCycleIndex};
|
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;
|
||||||
@@ -176,20 +175,11 @@ 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,33 +96,13 @@ const MAX_TABLEAU_CARDS: f32 = 13.0;
|
|||||||
/// 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 score/moves/time + mode badge rows.
|
/// Desktop: 64 px fits the score/moves/time + mode badge rows.
|
||||||
/// Android: 112 px — the HUD column has 4 flex tiers with 3 inter-tier
|
/// Android: 80 px gives the same content rows comfortable clearance.
|
||||||
/// gaps (4 px each) plus a SPACE_2 = 8 px top offset. With empty tiers
|
/// (Previously 128 px when action buttons lived in the top band; those are
|
||||||
/// still contributing gap height in Bevy's flex layout, the actual HUD
|
/// now in the bottom bar so the larger reserve is no longer needed.)
|
||||||
/// 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 = 112.0;
|
pub const HUD_BAND_HEIGHT: f32 = 80.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];
|
||||||
@@ -143,7 +123,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.18); on width-limited (portrait phone)
|
/// equals `TABLEAU_FAN_FRAC` (0.25); 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
|
||||||
@@ -207,13 +187,9 @@ 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 - effective_safe_bottom - band_h).max(0.0) / height_denom;
|
let card_width_height_based = (window.y - safe_area_top - safe_area_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;
|
||||||
@@ -262,7 +238,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 + effective_safe_bottom + h_gap) - card_height / 2.0).max(0.0);
|
let avail = (tableau_y - (-window.y / 2.0 + safe_area_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 {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ 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,
|
||||||
ModalScrim, ScrimDismissible,
|
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,
|
||||||
@@ -139,7 +139,6 @@ impl Plugin for LeaderboardPlugin {
|
|||||||
.init_resource::<DisplayNameBuffer>()
|
.init_resource::<DisplayNameBuffer>()
|
||||||
.add_message::<ToggleLeaderboardRequestEvent>()
|
.add_message::<ToggleLeaderboardRequestEvent>()
|
||||||
.add_message::<WarningToastEvent>()
|
.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.
|
||||||
@@ -715,7 +714,6 @@ 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>>,
|
||||||
@@ -727,9 +725,6 @@ 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())
|
||||||
@@ -1164,23 +1159,9 @@ mod tests {
|
|||||||
.spawn(async { Err::<(), String>("network error".to_string()) });
|
.spawn(async { Err::<(), String>("network error".to_string()) });
|
||||||
app.world_mut().resource_mut::<OptInTask>().0 = Some(failed_task);
|
app.world_mut().resource_mut::<OptInTask>().0 = Some(failed_task);
|
||||||
|
|
||||||
// Pump until the task is polled or a deadline elapses. A fixed
|
// Allow the task to complete and be polled.
|
||||||
// update count is unreliable under parallel `cargo test --workspace`
|
for _ in 0..5 {
|
||||||
// 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();
|
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 msgs = app.world().resource::<Messages<WarningToastEvent>>();
|
||||||
@@ -1202,19 +1183,8 @@ mod tests {
|
|||||||
.spawn(async { Err::<(), String>("network error".to_string()) });
|
.spawn(async { Err::<(), String>("network error".to_string()) });
|
||||||
app.world_mut().resource_mut::<OptOutTask>().0 = Some(failed_task);
|
app.world_mut().resource_mut::<OptOutTask>().0 = Some(failed_task);
|
||||||
|
|
||||||
// Deadline-bounded pump — see opt_in_error_fires_warning_toast for rationale.
|
for _ in 0..5 {
|
||||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
|
||||||
loop {
|
|
||||||
app.update();
|
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 msgs = app.world().resource::<Messages<WarningToastEvent>>();
|
||||||
@@ -1240,22 +1210,8 @@ mod tests {
|
|||||||
let ok_task = AsyncComputeTaskPool::get().spawn(async { Ok::<(), String>(()) });
|
let ok_task = AsyncComputeTaskPool::get().spawn(async { Ok::<(), String>(()) });
|
||||||
app.world_mut().resource_mut::<OptInTask>().0 = Some(ok_task);
|
app.world_mut().resource_mut::<OptInTask>().0 = Some(ok_task);
|
||||||
|
|
||||||
// Deadline-bounded pump — see opt_in_error_fires_warning_toast for rationale.
|
for _ in 0..5 {
|
||||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
|
||||||
loop {
|
|
||||||
app.update();
|
app.update();
|
||||||
if app
|
|
||||||
.world()
|
|
||||||
.resource::<SettingsResource>()
|
|
||||||
.0
|
|
||||||
.leaderboard_opted_in
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if std::time::Instant::now() >= deadline {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
std::thread::yield_now();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
@@ -1281,22 +1237,8 @@ mod tests {
|
|||||||
let ok_task = AsyncComputeTaskPool::get().spawn(async { Ok::<(), String>(()) });
|
let ok_task = AsyncComputeTaskPool::get().spawn(async { Ok::<(), String>(()) });
|
||||||
app.world_mut().resource_mut::<OptOutTask>().0 = Some(ok_task);
|
app.world_mut().resource_mut::<OptOutTask>().0 = Some(ok_task);
|
||||||
|
|
||||||
// Deadline-bounded pump — see opt_in_error_fires_warning_toast for rationale.
|
for _ in 0..5 {
|
||||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
|
||||||
loop {
|
|
||||||
app.update();
|
app.update();
|
||||||
if !app
|
|
||||||
.world()
|
|
||||||
.resource::<SettingsResource>()
|
|
||||||
.0
|
|
||||||
.leaderboard_opted_in
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if std::time::Instant::now() >= deadline {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
std::thread::yield_now();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@@ -138,13 +138,12 @@ 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) or stacking over another modal.
|
// Guard against double-spawn (e.g. two events in one frame).
|
||||||
if !existing.is_empty() || !other_scrims.is_empty() {
|
if !existing.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let font = font_res.as_deref();
|
let font = font_res.as_deref();
|
||||||
@@ -412,11 +411,7 @@ fn handle_confirm(
|
|||||||
new_game.write(NewGameRequestEvent {
|
new_game.write(NewGameRequestEvent {
|
||||||
seed: Some(seed),
|
seed: Some(seed),
|
||||||
mode: None,
|
mode: None,
|
||||||
// The player explicitly clicked Play (or pressed Enter) after typing
|
confirmed: false,
|
||||||
// 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 {
|
||||||
@@ -571,9 +566,7 @@ 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);
|
||||||
// confirmed: true — the player explicitly clicked Play, so no
|
assert!(!fired[0].confirmed);
|
||||||
// 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,11 +473,8 @@ 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 Some(active_id) = drag.active_touch_id else {
|
let active_id = drag.active_touch_id;
|
||||||
*hold_timer = 0.0;
|
if active_id.is_none() || drag.committed || state.is_active() || paused.is_some_and(|p| p.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;
|
||||||
}
|
}
|
||||||
@@ -490,7 +487,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) else {
|
let Some(touch) = touches.iter().find(|t| t.id() == active_id.unwrap()) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let Some((camera, cam_xf)) = cameras.single().ok() else { return };
|
let Some((camera, cam_xf)) = cameras.single().ok() else { return };
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ 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, StateChangedEvent, UndoRequestEvent};
|
use crate::events::{DrawRequestEvent, MoveRequestEvent, 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,
|
||||||
@@ -476,7 +476,6 @@ 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,
|
||||||
(
|
(
|
||||||
@@ -1885,7 +1884,6 @@ 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>,
|
||||||
@@ -1894,22 +1892,10 @@ 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) {
|
||||||
@@ -1925,28 +1911,14 @@ fn handle_arrow_keyboard(
|
|||||||
hold.right_held_secs = 0.0;
|
hold.right_held_secs = 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Left (backwards step) — gate on `back_pending` so at most one undo
|
// Left (backwards step) — symmetric to the right path.
|
||||||
// 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) {
|
||||||
if !*back_pending {
|
step_backwards_replay_playback(&mut state, &mut undo_writer);
|
||||||
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 {
|
||||||
if !*back_pending {
|
step_backwards_replay_playback(&mut state, &mut undo_writer);
|
||||||
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 {
|
||||||
|
|||||||
@@ -512,7 +512,6 @@ 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,
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use bevy::math::Vec2;
|
use bevy::math::Vec2;
|
||||||
use bevy::prelude::{warn, Resource};
|
use bevy::prelude::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;
|
||||||
@@ -131,48 +131,15 @@ pub struct GameInputConsumedResource(pub bool);
|
|||||||
#[derive(Resource, Clone)]
|
#[derive(Resource, Clone)]
|
||||||
pub struct TokioRuntimeResource(pub Arc<tokio::runtime::Runtime>);
|
pub struct TokioRuntimeResource(pub Arc<tokio::runtime::Runtime>);
|
||||||
|
|
||||||
impl TokioRuntimeResource {
|
impl Default for TokioRuntimeResource {
|
||||||
/// Attempts to build the shared multi-threaded Tokio runtime.
|
fn default() -> Self {
|
||||||
///
|
// Building the Tokio runtime is startup-time initialization; failure
|
||||||
/// Returns `Err` if the OS refuses to create worker threads (e.g. resource
|
// here means the OS refused to create threads, which is unrecoverable.
|
||||||
/// 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()
|
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)
|
.worker_threads(2)
|
||||||
.enable_all()
|
.enable_all()
|
||||||
.build()
|
.build()
|
||||||
{
|
.expect("failed to build shared Tokio runtime");
|
||||||
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))
|
Self(Arc::new(rt))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -510,7 +510,6 @@ 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,
|
||||||
@@ -526,7 +525,7 @@ fn sync_settings_panel_visibility(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if screen.0 {
|
if screen.0 {
|
||||||
if panels.is_empty() && other_modal_scrims.is_empty() {
|
if panels.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
|
||||||
|
|||||||
@@ -220,13 +220,7 @@ impl Plugin for StatsPlugin {
|
|||||||
)
|
)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
// handle_forfeit must run before update_stats_on_new_game so
|
handle_forfeit.before(GameMutation),
|
||||||
// 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)
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ impl SyncPlugin {
|
|||||||
impl Plugin for SyncPlugin {
|
impl Plugin for SyncPlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.insert_resource(SyncProviderResource(self.provider.clone()))
|
app.insert_resource(SyncProviderResource(self.provider.clone()))
|
||||||
|
.init_resource::<TokioRuntimeResource>()
|
||||||
.init_resource::<SyncStatusResource>()
|
.init_resource::<SyncStatusResource>()
|
||||||
.init_resource::<PullTaskResult>()
|
.init_resource::<PullTaskResult>()
|
||||||
.init_resource::<PullTask>()
|
.init_resource::<PullTask>()
|
||||||
@@ -108,14 +109,7 @@ impl Plugin for SyncPlugin {
|
|||||||
.add_message::<ManualSyncRequestEvent>()
|
.add_message::<ManualSyncRequestEvent>()
|
||||||
.add_message::<SyncCompleteEvent>()
|
.add_message::<SyncCompleteEvent>()
|
||||||
.add_message::<SyncConfigureRequestEvent>()
|
.add_message::<SyncConfigureRequestEvent>()
|
||||||
.add_message::<WarningToastEvent>();
|
.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,
|
||||||
@@ -128,11 +122,6 @@ 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}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ use crate::font_plugin::FontResource;
|
|||||||
use crate::settings_plugin::{SettingsResource, SettingsScreen, SettingsStoragePath};
|
use crate::settings_plugin::{SettingsResource, SettingsScreen, SettingsStoragePath};
|
||||||
use crate::resources::TokioRuntimeResource;
|
use crate::resources::TokioRuntimeResource;
|
||||||
use crate::sync_plugin::SyncProviderResource;
|
use crate::sync_plugin::SyncProviderResource;
|
||||||
use crate::ui_modal::{spawn_modal, ModalScrim};
|
use crate::ui_modal::spawn_modal;
|
||||||
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,
|
||||||
@@ -208,7 +208,6 @@ impl Plugin for SyncSetupPlugin {
|
|||||||
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>>,
|
||||||
other_modal_scrims: Query<(), (With<ModalScrim>, Without<SyncSetupScreen>)>,
|
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
mut focused: ResMut<SyncFocusedField>,
|
mut focused: ResMut<SyncFocusedField>,
|
||||||
font_res: Option<Res<FontResource>>,
|
font_res: Option<Res<FontResource>>,
|
||||||
@@ -220,9 +219,6 @@ 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());
|
||||||
}
|
}
|
||||||
@@ -305,7 +301,7 @@ 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>)>,
|
||||||
mut fields: Query<(&SyncFieldKind, &mut SyncFieldBuffer)>,
|
fields: Query<(&SyncFieldKind, &SyncFieldBuffer)>,
|
||||||
rt: Res<TokioRuntimeResource>,
|
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>>,
|
||||||
@@ -358,10 +354,9 @@ fn handle_auth_button(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear previous error and show busy indicator.
|
// Clear error and show busy indicator.
|
||||||
for (mut text, mut color) in &mut error_nodes {
|
for (mut text, _) in &mut error_nodes {
|
||||||
text.0 = String::new();
|
text.0 = "Connecting…".to_string();
|
||||||
color.0 = TEXT_SECONDARY;
|
|
||||||
}
|
}
|
||||||
for mut vis in &mut busy_nodes {
|
for mut vis in &mut busy_nodes {
|
||||||
*vis = Visibility::Visible;
|
*vis = Visibility::Visible;
|
||||||
@@ -392,14 +387,6 @@ 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.
|
||||||
@@ -553,7 +540,6 @@ 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>>,
|
||||||
) {
|
) {
|
||||||
@@ -564,9 +550,6 @@ 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());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -692,30 +675,19 @@ fn spawn_sync_setup_modal(commands: &mut Commands, font_res: Option<&FontResourc
|
|||||||
font_res,
|
font_res,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Error / status line — two distinct children so visibility and
|
// Error / status line.
|
||||||
// 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((
|
|
||||||
SyncBusyOverlay,
|
|
||||||
Text::new("…"),
|
|
||||||
make_font(font_res, TYPE_CAPTION),
|
|
||||||
TextColor(TEXT_SECONDARY),
|
|
||||||
Visibility::Hidden,
|
|
||||||
));
|
|
||||||
// Error / status text: always laid out, empty when idle.
|
|
||||||
row.spawn((
|
row.spawn((
|
||||||
SyncAuthError,
|
SyncAuthError,
|
||||||
|
SyncBusyOverlay,
|
||||||
Text::new(String::new()),
|
Text::new(String::new()),
|
||||||
make_font(font_res, TYPE_CAPTION),
|
make_font(font_res, TYPE_CAPTION),
|
||||||
TextColor(TEXT_SECONDARY),
|
TextColor(TEXT_SECONDARY),
|
||||||
|
Visibility::Hidden,
|
||||||
));
|
));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -182,16 +182,12 @@ 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 }
|
||||||
@@ -202,22 +198,6 @@ 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,15 +172,14 @@ 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;
|
||||||
}
|
}
|
||||||
// Pause the countdown while Home, the Pause overlay, or the Win Summary
|
// Mirrors `tick_elapsed_time`: pause while the launch / mode-picker
|
||||||
// overlay is visible — the player should not lose time while reading results
|
// Home modal is up so the countdown doesn't burn while the player
|
||||||
// or navigating menus.
|
// is choosing what to play next.
|
||||||
if paused.is_some_and(|p| p.0) || !home_screens.is_empty() || !win_overlays.is_empty() {
|
if paused.is_some_and(|p| p.0) || !home_screens.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
session.remaining_secs -= time.delta_secs();
|
session.remaining_secs -= time.delta_secs();
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ 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,
|
||||||
@@ -508,7 +507,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().next().is_some() {
|
if new_games.read().last().is_some() {
|
||||||
session.names.clear();
|
session.names.clear();
|
||||||
}
|
}
|
||||||
for ev in unlocks.read() {
|
for ev in unlocks.read() {
|
||||||
@@ -539,7 +538,6 @@ 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.
|
||||||
@@ -570,8 +568,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 no overlay of any kind is already visible.
|
// Only spawn if there is no overlay already.
|
||||||
if overlays.is_empty() && other_scrims.is_empty() {
|
if overlays.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
|
||||||
@@ -759,7 +757,6 @@ 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),
|
||||||
@@ -772,7 +769,6 @@ 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| {
|
||||||
|
|||||||
@@ -341,6 +341,8 @@ pub async fn get_me(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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;
|
||||||
|
|
||||||
@@ -359,15 +361,23 @@ 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 = match mime.as_str() {
|
let ext = if mime.contains("jpeg") || mime.contains("jpg") {
|
||||||
"image/jpeg" | "image/jpg" => "jpg",
|
"jpg"
|
||||||
"image/png" => "png",
|
} else if mime.contains("png") {
|
||||||
"image/webp" => "webp",
|
"png"
|
||||||
"image/gif" => "gif",
|
} else if mime.contains("webp") {
|
||||||
_ => return Err(AppError::BadRequest(
|
"webp"
|
||||||
|
} 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()));
|
||||||
}
|
}
|
||||||
@@ -380,10 +390,7 @@ pub async fn upload_avatar(
|
|||||||
// Write to a temp file then atomically rename so concurrent readers never
|
// Write to a temp file then atomically rename so concurrent readers never
|
||||||
// see a partially-written avatar.
|
// see a partially-written avatar.
|
||||||
std::fs::write(&tmp_path, &body).map_err(|e| AppError::Internal(e.to_string()))?;
|
std::fs::write(&tmp_path, &body).map_err(|e| AppError::Internal(e.to_string()))?;
|
||||||
if let Err(e) = std::fs::rename(&tmp_path, &path) {
|
std::fs::rename(&tmp_path, &path).map_err(|e| AppError::Internal(e.to_string()))?;
|
||||||
let _ = std::fs::remove_file(&tmp_path);
|
|
||||||
return Err(AppError::Internal(e.to_string()));
|
|
||||||
}
|
|
||||||
// Remove stale files with other extensions after the atomic rename.
|
// 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 {
|
if *old_ext != ext {
|
||||||
|
|||||||
@@ -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: u64,
|
seed: i64,
|
||||||
draw_mode: String,
|
draw_mode: String,
|
||||||
mode: String,
|
mode: String,
|
||||||
time_seconds: i64,
|
time_seconds: i64,
|
||||||
@@ -94,9 +94,6 @@ 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 (
|
||||||
@@ -105,7 +102,7 @@ pub async fn upload(
|
|||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#,
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#,
|
||||||
id,
|
id,
|
||||||
user.user_id,
|
user.user_id,
|
||||||
seed_i64,
|
header.seed,
|
||||||
header.draw_mode,
|
header.draw_mode,
|
||||||
header.mode,
|
header.mode,
|
||||||
header.time_seconds,
|
header.time_seconds,
|
||||||
@@ -119,7 +116,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 = ?,
|
||||||
|
|||||||
@@ -300,7 +300,6 @@ 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) {
|
||||||
|
|||||||
@@ -40,32 +40,20 @@ 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);
|
||||||
if (ret[2]) {
|
return ret;
|
||||||
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);
|
||||||
if (ret[2]) {
|
return ret;
|
||||||
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.
|
||||||
@@ -169,16 +157,11 @@ 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);
|
||||||
if (ret[2]) {
|
return ret;
|
||||||
throw takeFromExternrefTable0(ret[1]);
|
|
||||||
}
|
|
||||||
return takeFromExternrefTable0(ret[0]);
|
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Undo the last move. Returns `{ok, error?, snapshot?}`.
|
* Undo the last move. Returns `{ok, error?, snapshot?}`.
|
||||||
@@ -197,13 +180,6 @@ 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));
|
||||||
},
|
},
|
||||||
|
|||||||
Binary file not shown.
+17
-34
@@ -108,27 +108,23 @@ fn merge_stats(
|
|||||||
let merged_games_won = local.games_won.max(remote.games_won);
|
let merged_games_won = local.games_won.max(remote.games_won);
|
||||||
let merged_games_played = local.games_played.max(remote.games_played);
|
let merged_games_played = local.games_played.max(remote.games_played);
|
||||||
|
|
||||||
// Carry the average time from whichever side contributed merged_games_won.
|
// Recompute average time from the merged totals. If no wins yet, keep 0.
|
||||||
// Taking max(total_time)/max(wins) misattributes time when the side with
|
|
||||||
// more wins has a lower total — use the winning side's average directly.
|
|
||||||
let avg_time_seconds = if merged_games_won == 0 {
|
let avg_time_seconds = if merged_games_won == 0 {
|
||||||
0
|
0
|
||||||
} else if local.games_won >= remote.games_won {
|
|
||||||
local.avg_time_seconds
|
|
||||||
} else {
|
} else {
|
||||||
remote.avg_time_seconds
|
// Use whichever side has more wins to approximate total time, then blend.
|
||||||
|
// We don't have total_time stored, so we reconstruct it from avg * count.
|
||||||
|
let local_total = local.avg_time_seconds as u128 * local.games_won as u128;
|
||||||
|
let remote_total = remote.avg_time_seconds as u128 * remote.games_won as u128;
|
||||||
|
// Take max total time (conservative — avoids underestimating total play time).
|
||||||
|
let best_total = local_total.max(remote_total);
|
||||||
|
(best_total / merged_games_won as u128) as u64
|
||||||
};
|
};
|
||||||
|
|
||||||
// Derive games_lost from the merged played/won counts so the invariant
|
|
||||||
// games_won + games_lost <= games_played is always satisfied. Computing
|
|
||||||
// max(local.games_lost, remote.games_lost) independently can push
|
|
||||||
// games_won + games_lost above games_played after a divergent merge.
|
|
||||||
let merged_games_lost = merged_games_played.saturating_sub(merged_games_won);
|
|
||||||
|
|
||||||
StatsSnapshot {
|
StatsSnapshot {
|
||||||
games_played: merged_games_played,
|
games_played: merged_games_played,
|
||||||
games_won: merged_games_won,
|
games_won: merged_games_won,
|
||||||
games_lost: merged_games_lost,
|
games_lost: local.games_lost.max(remote.games_lost),
|
||||||
win_streak_current: local.win_streak_current.max(remote.win_streak_current),
|
win_streak_current: local.win_streak_current.max(remote.win_streak_current),
|
||||||
win_streak_best: local.win_streak_best.max(remote.win_streak_best),
|
win_streak_best: local.win_streak_best.max(remote.win_streak_best),
|
||||||
avg_time_seconds,
|
avg_time_seconds,
|
||||||
@@ -458,28 +454,14 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stats_games_lost_derived_from_played_minus_won() {
|
fn stats_games_lost_takes_max() {
|
||||||
// games_lost must equal games_played - games_won so the invariant
|
|
||||||
// games_won + games_lost <= games_played is always satisfied.
|
|
||||||
let mut local = default_payload();
|
let mut local = default_payload();
|
||||||
local.stats.games_played = 20;
|
|
||||||
local.stats.games_won = 8;
|
|
||||||
local.stats.games_lost = 12;
|
local.stats.games_lost = 12;
|
||||||
let mut remote = default_payload();
|
let mut remote = default_payload();
|
||||||
remote.stats.games_played = 15;
|
remote.stats.games_lost = 8;
|
||||||
remote.stats.games_won = 10;
|
|
||||||
remote.stats.games_lost = 5;
|
|
||||||
|
|
||||||
// merged: games_played = max(20, 15) = 20; games_won = max(8, 10) = 10
|
|
||||||
// games_lost must be 20 - 10 = 10, NOT max(12, 5) = 12
|
|
||||||
let (merged, _) = merge(&local, &remote);
|
let (merged, _) = merge(&local, &remote);
|
||||||
assert_eq!(merged.stats.games_played, 20);
|
assert_eq!(merged.stats.games_lost, 12);
|
||||||
assert_eq!(merged.stats.games_won, 10);
|
|
||||||
assert_eq!(merged.stats.games_lost, 10);
|
|
||||||
assert!(
|
|
||||||
merged.stats.games_won + merged.stats.games_lost <= merged.stats.games_played,
|
|
||||||
"games_won + games_lost must never exceed games_played"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -520,10 +502,11 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stats_avg_time_recomputed_from_merged_totals() {
|
fn stats_avg_time_recomputed_from_merged_totals() {
|
||||||
// local: 4 wins averaging 100s each
|
// local: 4 wins averaging 100s each (total = 400s)
|
||||||
// remote: 6 wins averaging 200s each
|
// remote: 6 wins averaging 200s each (total = 1200s)
|
||||||
// merged_games_won = max(4, 6) = 6 → remote contributed the wins
|
// merged_games_won = max(4, 6) = 6
|
||||||
// avg_time_seconds must be remote's 200s, not a blend of totals
|
// best_total = max(400, 1200) = 1200
|
||||||
|
// avg = 1200 / 6 = 200
|
||||||
let mut local = default_payload();
|
let mut local = default_payload();
|
||||||
local.stats.games_won = 4;
|
local.stats.games_won = 4;
|
||||||
local.stats.avg_time_seconds = 100;
|
local.stats.avg_time_seconds = 100;
|
||||||
|
|||||||
Reference in New Issue
Block a user