Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a2f02e1cbc | |||
| 8426d89856 | |||
| ecab227b8d | |||
| da601bebd6 | |||
| a2dd8d220c | |||
| d5d869a6c8 | |||
| 42898c0b3f | |||
| f6e7de1093 | |||
| b5a780ddf4 | |||
| 3322fd4250 | |||
| 90eb5fd207 | |||
| 76cf41e7a9 | |||
| fae5933d29 | |||
| 6cd8c6c013 | |||
| ec94cb34aa | |||
| 40768f3b0a | |||
| 2186f55913 | |||
| e0f369d322 | |||
| ea98774ccb | |||
| ea9dd848fd | |||
| a328059933 | |||
| 18659d19d1 | |||
| 7840ef9eb2 | |||
| 6d061d23a1 | |||
| 25f22231a6 | |||
| c66ff26d1d | |||
| cd792b20b2 | |||
| 73c7f50f74 | |||
| 83c40116af | |||
| 347d5a4b4f | |||
| 93f2ceaabe | |||
| e390b72222 | |||
| 3650788dc5 | |||
| 39cf8dcd6c |
@@ -0,0 +1,7 @@
|
||||
# Claude Flow runtime files
|
||||
data/
|
||||
logs/
|
||||
sessions/
|
||||
neural/
|
||||
*.log
|
||||
*.tmp
|
||||
@@ -0,0 +1,403 @@
|
||||
# RuFlo V3 - Complete Capabilities Reference
|
||||
> Generated: 2026-05-19T00:18:20.864Z
|
||||
> Full documentation: https://github.com/ruvnet/claude-flow
|
||||
|
||||
## 📋 Table of Contents
|
||||
|
||||
1. [Overview](#overview)
|
||||
2. [Swarm Orchestration](#swarm-orchestration)
|
||||
3. [Available Agents (60+)](#available-agents)
|
||||
4. [CLI Commands (26 Commands, 140+ Subcommands)](#cli-commands)
|
||||
5. [Hooks System (27 Hooks + 12 Workers)](#hooks-system)
|
||||
6. [Memory & Intelligence (RuVector)](#memory--intelligence)
|
||||
7. [Hive-Mind Consensus](#hive-mind-consensus)
|
||||
8. [Performance Targets](#performance-targets)
|
||||
9. [Integration Ecosystem](#integration-ecosystem)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
RuFlo V3 is a domain-driven design architecture for multi-agent AI coordination with:
|
||||
|
||||
- **15-Agent Swarm Coordination** with hierarchical and mesh topologies
|
||||
- **HNSW Vector Search** - 150x-12,500x faster pattern retrieval
|
||||
- **SONA Neural Learning** - Self-optimizing with <0.05ms adaptation
|
||||
- **Byzantine Fault Tolerance** - Queen-led consensus mechanisms
|
||||
- **MCP Server Integration** - Model Context Protocol support
|
||||
|
||||
### Current Configuration
|
||||
| Setting | Value |
|
||||
|---------|-------|
|
||||
| Topology | hierarchical-mesh |
|
||||
| Max Agents | 15 |
|
||||
| Memory Backend | hybrid |
|
||||
| HNSW Indexing | Enabled |
|
||||
| Neural Learning | Enabled |
|
||||
| LearningBridge | Enabled (SONA + ReasoningBank) |
|
||||
| Knowledge Graph | Enabled (PageRank + Communities) |
|
||||
| Agent Scopes | Enabled (project/local/user) |
|
||||
|
||||
---
|
||||
|
||||
## Swarm Orchestration
|
||||
|
||||
### Topologies
|
||||
| Topology | Description | Best For |
|
||||
|----------|-------------|----------|
|
||||
| `hierarchical` | Queen controls workers directly | Anti-drift, tight control |
|
||||
| `mesh` | Fully connected peer network | Distributed tasks |
|
||||
| `hierarchical-mesh` | V3 hybrid (recommended) | 10+ agents |
|
||||
| `ring` | Circular communication | Sequential workflows |
|
||||
| `star` | Central coordinator | Simple coordination |
|
||||
| `adaptive` | Dynamic based on load | Variable workloads |
|
||||
|
||||
### Strategies
|
||||
- `balanced` - Even distribution across agents
|
||||
- `specialized` - Clear roles, no overlap (anti-drift)
|
||||
- `adaptive` - Dynamic task routing
|
||||
|
||||
### Quick Commands
|
||||
```bash
|
||||
# Initialize swarm
|
||||
npx @claude-flow/cli@latest swarm init --topology hierarchical --max-agents 8 --strategy specialized
|
||||
|
||||
# Check status
|
||||
npx @claude-flow/cli@latest swarm status
|
||||
|
||||
# Monitor activity
|
||||
npx @claude-flow/cli@latest swarm monitor
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Available Agents
|
||||
|
||||
### Core Development (5)
|
||||
`coder`, `reviewer`, `tester`, `planner`, `researcher`
|
||||
|
||||
### V3 Specialized (4)
|
||||
`security-architect`, `security-auditor`, `memory-specialist`, `performance-engineer`
|
||||
|
||||
### Swarm Coordination (5)
|
||||
`hierarchical-coordinator`, `mesh-coordinator`, `adaptive-coordinator`, `collective-intelligence-coordinator`, `swarm-memory-manager`
|
||||
|
||||
### Consensus & Distributed (7)
|
||||
`byzantine-coordinator`, `raft-manager`, `gossip-coordinator`, `consensus-builder`, `crdt-synchronizer`, `quorum-manager`, `security-manager`
|
||||
|
||||
### Performance & Optimization (5)
|
||||
`perf-analyzer`, `performance-benchmarker`, `task-orchestrator`, `memory-coordinator`, `smart-agent`
|
||||
|
||||
### GitHub & Repository (9)
|
||||
`github-modes`, `pr-manager`, `code-review-swarm`, `issue-tracker`, `release-manager`, `workflow-automation`, `project-board-sync`, `repo-architect`, `multi-repo-swarm`
|
||||
|
||||
### SPARC Methodology (6)
|
||||
`sparc-coord`, `sparc-coder`, `specification`, `pseudocode`, `architecture`, `refinement`
|
||||
|
||||
### Specialized Development (8)
|
||||
`backend-dev`, `mobile-dev`, `ml-developer`, `cicd-engineer`, `api-docs`, `system-architect`, `code-analyzer`, `base-template-generator`
|
||||
|
||||
### Testing & Validation (2)
|
||||
`tdd-london-swarm`, `production-validator`
|
||||
|
||||
### Agent Routing by Task
|
||||
| Task Type | Recommended Agents | Topology |
|
||||
|-----------|-------------------|----------|
|
||||
| Bug Fix | researcher, coder, tester | mesh |
|
||||
| New Feature | coordinator, architect, coder, tester, reviewer | hierarchical |
|
||||
| Refactoring | architect, coder, reviewer | mesh |
|
||||
| Performance | researcher, perf-engineer, coder | hierarchical |
|
||||
| Security | security-architect, auditor, reviewer | hierarchical |
|
||||
| Docs | researcher, api-docs | mesh |
|
||||
|
||||
---
|
||||
|
||||
## CLI Commands
|
||||
|
||||
### Core Commands (12)
|
||||
| Command | Subcommands | Description |
|
||||
|---------|-------------|-------------|
|
||||
| `init` | 4 | Project initialization |
|
||||
| `agent` | 8 | Agent lifecycle management |
|
||||
| `swarm` | 6 | Multi-agent coordination |
|
||||
| `memory` | 11 | AgentDB with HNSW search |
|
||||
| `mcp` | 9 | MCP server management |
|
||||
| `task` | 6 | Task assignment |
|
||||
| `session` | 7 | Session persistence |
|
||||
| `config` | 7 | Configuration |
|
||||
| `status` | 3 | System monitoring |
|
||||
| `workflow` | 6 | Workflow templates |
|
||||
| `hooks` | 17 | Self-learning hooks |
|
||||
| `hive-mind` | 6 | Consensus coordination |
|
||||
|
||||
### Advanced Commands (14)
|
||||
| Command | Subcommands | Description |
|
||||
|---------|-------------|-------------|
|
||||
| `daemon` | 5 | Background workers |
|
||||
| `neural` | 5 | Pattern training |
|
||||
| `security` | 6 | Security scanning |
|
||||
| `performance` | 5 | Profiling & benchmarks |
|
||||
| `providers` | 5 | AI provider config |
|
||||
| `plugins` | 5 | Plugin management |
|
||||
| `deployment` | 5 | Deploy management |
|
||||
| `embeddings` | 4 | Vector embeddings |
|
||||
| `claims` | 4 | Authorization |
|
||||
| `migrate` | 5 | V2→V3 migration |
|
||||
| `process` | 4 | Process management |
|
||||
| `doctor` | 1 | Health diagnostics |
|
||||
| `completions` | 4 | Shell completions |
|
||||
|
||||
### Example Commands
|
||||
```bash
|
||||
# Initialize
|
||||
npx @claude-flow/cli@latest init --wizard
|
||||
|
||||
# Spawn agent
|
||||
npx @claude-flow/cli@latest agent spawn -t coder --name my-coder
|
||||
|
||||
# Memory operations
|
||||
npx @claude-flow/cli@latest memory store --key "pattern" --value "data" --namespace patterns
|
||||
npx @claude-flow/cli@latest memory search --query "authentication"
|
||||
|
||||
# Diagnostics
|
||||
npx @claude-flow/cli@latest doctor --fix
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Hooks System
|
||||
|
||||
### 27 Available Hooks
|
||||
|
||||
#### Core Hooks (6)
|
||||
| Hook | Description |
|
||||
|------|-------------|
|
||||
| `pre-edit` | Context before file edits |
|
||||
| `post-edit` | Record edit outcomes |
|
||||
| `pre-command` | Risk assessment |
|
||||
| `post-command` | Command metrics |
|
||||
| `pre-task` | Task start + agent suggestions |
|
||||
| `post-task` | Task completion learning |
|
||||
|
||||
#### Session Hooks (4)
|
||||
| Hook | Description |
|
||||
|------|-------------|
|
||||
| `session-start` | Start/restore session |
|
||||
| `session-end` | Persist state |
|
||||
| `session-restore` | Restore previous |
|
||||
| `notify` | Cross-agent notifications |
|
||||
|
||||
#### Intelligence Hooks (5)
|
||||
| Hook | Description |
|
||||
|------|-------------|
|
||||
| `route` | Optimal agent routing |
|
||||
| `explain` | Routing decisions |
|
||||
| `pretrain` | Bootstrap intelligence |
|
||||
| `build-agents` | Generate configs |
|
||||
| `transfer` | Pattern transfer |
|
||||
|
||||
#### Coverage Hooks (3)
|
||||
| Hook | Description |
|
||||
|------|-------------|
|
||||
| `coverage-route` | Coverage-based routing |
|
||||
| `coverage-suggest` | Improvement suggestions |
|
||||
| `coverage-gaps` | Gap analysis |
|
||||
|
||||
### 12 Background Workers
|
||||
| Worker | Priority | Purpose |
|
||||
|--------|----------|---------|
|
||||
| `ultralearn` | normal | Deep knowledge |
|
||||
| `optimize` | high | Performance |
|
||||
| `consolidate` | low | Memory consolidation |
|
||||
| `predict` | normal | Predictive preload |
|
||||
| `audit` | critical | Security |
|
||||
| `map` | normal | Codebase mapping |
|
||||
| `preload` | low | Resource preload |
|
||||
| `deepdive` | normal | Deep analysis |
|
||||
| `document` | normal | Auto-docs |
|
||||
| `refactor` | normal | Suggestions |
|
||||
| `benchmark` | normal | Benchmarking |
|
||||
| `testgaps` | normal | Coverage gaps |
|
||||
|
||||
---
|
||||
|
||||
## Memory & Intelligence
|
||||
|
||||
### RuVector Intelligence System
|
||||
- **SONA**: Self-Optimizing Neural Architecture (<0.05ms)
|
||||
- **MoE**: Mixture of Experts routing
|
||||
- **HNSW**: 150x-12,500x faster search
|
||||
- **EWC++**: Prevents catastrophic forgetting
|
||||
- **Flash Attention**: 2.49x-7.47x speedup
|
||||
- **Int8 Quantization**: 3.92x memory reduction
|
||||
|
||||
### 4-Step Intelligence Pipeline
|
||||
1. **RETRIEVE** - HNSW pattern search
|
||||
2. **JUDGE** - Success/failure verdicts
|
||||
3. **DISTILL** - LoRA learning extraction
|
||||
4. **CONSOLIDATE** - EWC++ preservation
|
||||
|
||||
### Self-Learning Memory (ADR-049)
|
||||
|
||||
| Component | Status | Description |
|
||||
|-----------|--------|-------------|
|
||||
| **LearningBridge** | ✅ Enabled | Connects insights to SONA/ReasoningBank neural pipeline |
|
||||
| **MemoryGraph** | ✅ Enabled | PageRank knowledge graph + community detection |
|
||||
| **AgentMemoryScope** | ✅ Enabled | 3-scope agent memory (project/local/user) |
|
||||
|
||||
**LearningBridge** - Insights trigger learning trajectories. Confidence evolves: +0.03 on access, -0.005/hour decay. Consolidation runs the JUDGE/DISTILL/CONSOLIDATE pipeline.
|
||||
|
||||
**MemoryGraph** - Builds a knowledge graph from entry references. PageRank identifies influential insights. Communities group related knowledge. Graph-aware ranking blends vector + structural scores.
|
||||
|
||||
**AgentMemoryScope** - Maps Claude Code 3-scope directories:
|
||||
- `project`: `<gitRoot>/.claude/agent-memory/<agent>/`
|
||||
- `local`: `<gitRoot>/.claude/agent-memory-local/<agent>/`
|
||||
- `user`: `~/.claude/agent-memory/<agent>/`
|
||||
|
||||
High-confidence insights (>0.8) can transfer between agents.
|
||||
|
||||
### Memory Commands
|
||||
```bash
|
||||
# Store pattern
|
||||
npx @claude-flow/cli@latest memory store --key "name" --value "data" --namespace patterns
|
||||
|
||||
# Semantic search
|
||||
npx @claude-flow/cli@latest memory search --query "authentication"
|
||||
|
||||
# List entries
|
||||
npx @claude-flow/cli@latest memory list --namespace patterns
|
||||
|
||||
# Initialize database
|
||||
npx @claude-flow/cli@latest memory init --force
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Hive-Mind Consensus
|
||||
|
||||
### Queen Types
|
||||
| Type | Role |
|
||||
|------|------|
|
||||
| Strategic Queen | Long-term planning |
|
||||
| Tactical Queen | Execution coordination |
|
||||
| Adaptive Queen | Dynamic optimization |
|
||||
|
||||
### Worker Types (8)
|
||||
`researcher`, `coder`, `analyst`, `tester`, `architect`, `reviewer`, `optimizer`, `documenter`
|
||||
|
||||
### Consensus Mechanisms
|
||||
| Mechanism | Fault Tolerance | Use Case |
|
||||
|-----------|-----------------|----------|
|
||||
| `byzantine` | f < n/3 faulty | Adversarial |
|
||||
| `raft` | f < n/2 failed | Leader-based |
|
||||
| `gossip` | Eventually consistent | Large scale |
|
||||
| `crdt` | Conflict-free | Distributed |
|
||||
| `quorum` | Configurable | Flexible |
|
||||
|
||||
### Hive-Mind Commands
|
||||
```bash
|
||||
# Initialize
|
||||
npx @claude-flow/cli@latest hive-mind init --queen-type strategic
|
||||
|
||||
# Status
|
||||
npx @claude-flow/cli@latest hive-mind status
|
||||
|
||||
# Spawn workers
|
||||
npx @claude-flow/cli@latest hive-mind spawn --count 5 --type worker
|
||||
|
||||
# Consensus
|
||||
npx @claude-flow/cli@latest hive-mind consensus --propose "task"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance Targets
|
||||
|
||||
| Metric | Target | Status |
|
||||
|--------|--------|--------|
|
||||
| HNSW Search | 150x-12,500x faster | ✅ Implemented |
|
||||
| Memory Reduction | 50-75% | ✅ Implemented (3.92x) |
|
||||
| SONA Integration | Pattern learning | ✅ Implemented |
|
||||
| Flash Attention | 2.49x-7.47x | 🔄 In Progress |
|
||||
| MCP Response | <100ms | ✅ Achieved |
|
||||
| CLI Startup | <500ms | ✅ Achieved |
|
||||
| SONA Adaptation | <0.05ms | 🔄 In Progress |
|
||||
| Graph Build (1k) | <200ms | ✅ 2.78ms (71.9x headroom) |
|
||||
| PageRank (1k) | <100ms | ✅ 12.21ms (8.2x headroom) |
|
||||
| Insight Recording | <5ms/each | ✅ 0.12ms (41x headroom) |
|
||||
| Consolidation | <500ms | ✅ 0.26ms (1,955x headroom) |
|
||||
| Knowledge Transfer | <100ms | ✅ 1.25ms (80x headroom) |
|
||||
|
||||
---
|
||||
|
||||
## Integration Ecosystem
|
||||
|
||||
### Integrated Packages
|
||||
| Package | Version | Purpose |
|
||||
|---------|---------|---------|
|
||||
| agentic-flow | 3.0.0-alpha.1 | Core coordination + ReasoningBank + Router |
|
||||
| agentdb | 3.0.0-alpha.10 | Vector database + 8 controllers |
|
||||
| @ruvector/attention | 0.1.3 | Flash attention |
|
||||
| @ruvector/sona | 0.1.5 | Neural learning |
|
||||
|
||||
### Optional Integrations
|
||||
| Package | Command |
|
||||
|---------|---------|
|
||||
| ruv-swarm | `npx ruv-swarm mcp start` |
|
||||
| flow-nexus | `npx flow-nexus@latest mcp start` |
|
||||
| agentic-jujutsu | `npx agentic-jujutsu@latest` |
|
||||
|
||||
### MCP Server Setup
|
||||
```bash
|
||||
# Add Ruflo MCP
|
||||
claude mcp add ruflo -- npx -y ruflo@latest
|
||||
|
||||
# Optional servers
|
||||
claude mcp add ruv-swarm -- npx -y ruv-swarm mcp start
|
||||
claude mcp add flow-nexus -- npx -y flow-nexus@latest mcp start
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Essential Commands
|
||||
```bash
|
||||
# Setup
|
||||
npx ruflo@latest init --wizard
|
||||
npx ruflo@latest daemon start
|
||||
npx ruflo@latest doctor --fix
|
||||
|
||||
# Swarm
|
||||
npx ruflo@latest swarm init --topology hierarchical --max-agents 8
|
||||
npx ruflo@latest swarm status
|
||||
|
||||
# Agents
|
||||
npx ruflo@latest agent spawn -t coder
|
||||
npx ruflo@latest agent list
|
||||
|
||||
# Memory
|
||||
npx ruflo@latest memory search --query "patterns"
|
||||
|
||||
# Hooks
|
||||
npx ruflo@latest hooks pre-task --description "task"
|
||||
npx ruflo@latest hooks worker dispatch --trigger optimize
|
||||
```
|
||||
|
||||
### File Structure
|
||||
```
|
||||
.claude-flow/
|
||||
├── config.yaml # Runtime configuration
|
||||
├── CAPABILITIES.md # This file
|
||||
├── data/ # Memory storage
|
||||
├── logs/ # Operation logs
|
||||
├── sessions/ # Session state
|
||||
├── hooks/ # Custom hooks
|
||||
├── agents/ # Agent configs
|
||||
└── workflows/ # Workflow templates
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Full Documentation**: https://github.com/ruvnet/claude-flow
|
||||
**Issues**: https://github.com/ruvnet/claude-flow/issues
|
||||
@@ -0,0 +1,43 @@
|
||||
# RuFlo V3 Runtime Configuration
|
||||
# Generated: 2026-05-19T00:18:20.863Z
|
||||
|
||||
version: "3.0.0"
|
||||
|
||||
swarm:
|
||||
topology: hierarchical-mesh
|
||||
maxAgents: 15
|
||||
autoScale: true
|
||||
coordinationStrategy: consensus
|
||||
|
||||
memory:
|
||||
backend: hybrid
|
||||
enableHNSW: true
|
||||
persistPath: .claude-flow/data
|
||||
cacheSize: 100
|
||||
# ADR-049: Self-Learning Memory
|
||||
learningBridge:
|
||||
enabled: true
|
||||
sonaMode: balanced
|
||||
confidenceDecayRate: 0.005
|
||||
accessBoostAmount: 0.03
|
||||
consolidationThreshold: 10
|
||||
memoryGraph:
|
||||
enabled: true
|
||||
pageRankDamping: 0.85
|
||||
maxNodes: 5000
|
||||
similarityThreshold: 0.8
|
||||
agentScopes:
|
||||
enabled: true
|
||||
defaultScope: project
|
||||
|
||||
neural:
|
||||
enabled: true
|
||||
modelPath: .claude-flow/neural
|
||||
|
||||
hooks:
|
||||
enabled: true
|
||||
autoExecute: true
|
||||
|
||||
mcp:
|
||||
autoStart: false
|
||||
port: 3000
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"initialized": "2026-05-19T00:18:20.864Z",
|
||||
"routing": {
|
||||
"accuracy": 0,
|
||||
"decisions": 0
|
||||
},
|
||||
"patterns": {
|
||||
"shortTerm": 0,
|
||||
"longTerm": 0,
|
||||
"quality": 0
|
||||
},
|
||||
"sessions": {
|
||||
"total": 0,
|
||||
"current": null
|
||||
},
|
||||
"_note": "Intelligence grows as you use Ruflo"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"timestamp": "2026-05-19T00:18:20.864Z",
|
||||
"processes": {
|
||||
"agentic_flow": 0,
|
||||
"mcp_server": 0,
|
||||
"estimated_agents": 0
|
||||
},
|
||||
"swarm": {
|
||||
"active": false,
|
||||
"agent_count": 0,
|
||||
"coordination_active": false
|
||||
},
|
||||
"integration": {
|
||||
"agentic_flow_active": false,
|
||||
"mcp_active": false
|
||||
},
|
||||
"_initialized": true
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"version": "3.0.0",
|
||||
"initialized": "2026-05-19T00:18:20.864Z",
|
||||
"domains": {
|
||||
"completed": 0,
|
||||
"total": 5,
|
||||
"status": "INITIALIZING"
|
||||
},
|
||||
"ddd": {
|
||||
"progress": 0,
|
||||
"modules": 0,
|
||||
"totalFiles": 0,
|
||||
"totalLines": 0
|
||||
},
|
||||
"swarm": {
|
||||
"activeAgents": 0,
|
||||
"maxAgents": 15,
|
||||
"topology": "hierarchical-mesh"
|
||||
},
|
||||
"learning": {
|
||||
"status": "READY",
|
||||
"patternsLearned": 0,
|
||||
"sessionsCompleted": 0
|
||||
},
|
||||
"_note": "Metrics will update as you use Ruflo. Run: npx ruflo@latest daemon start"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"initialized": "2026-05-19T00:18:20.864Z",
|
||||
"status": "PENDING",
|
||||
"cvesFixed": 0,
|
||||
"totalCves": 3,
|
||||
"lastScan": null,
|
||||
"_note": "Run: npx @claude-flow/cli@latest security scan"
|
||||
}
|
||||
@@ -4,6 +4,12 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag (e.g. v0.36.2)'
|
||||
required: true
|
||||
default: 'v0.36.2'
|
||||
|
||||
env:
|
||||
APK_OUT: target/release/apk/ferrous-solitaire.apk
|
||||
@@ -42,7 +48,12 @@ jobs:
|
||||
|
||||
- name: Get tag name
|
||||
id: tag
|
||||
run: echo "name=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
||||
run: |
|
||||
if [ -n "${{ github.event.inputs.tag }}" ]; then
|
||||
echo "name=${{ github.event.inputs.tag }}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "name=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Decode release keystore
|
||||
run: echo "${{ secrets.RELEASE_KEYSTORE_B64 }}" | base64 -d > release.jks
|
||||
|
||||
@@ -60,19 +60,17 @@ jobs:
|
||||
curl -sL https://github.com/kubernetes-sigs/kustomize/releases/download/kustomize%2Fv5.4.3/kustomize_v5.4.3_linux_amd64.tar.gz | tar xz
|
||||
sudo mv kustomize /usr/local/bin/kustomize
|
||||
|
||||
- name: Pin image tag in deploy manifests
|
||||
run: |
|
||||
cd deploy
|
||||
kustomize edit set image solitaire-server=${{ env.IMAGE }}:${{ steps.meta.outputs.sha }}
|
||||
|
||||
- name: Commit and push updated kustomization
|
||||
- name: Pin image tag and push to deploy branch
|
||||
run: |
|
||||
git config user.email "ci@gitea.local"
|
||||
git config user.name "Gitea CI"
|
||||
# Switch to the deploy branch, creating it from the current HEAD if absent.
|
||||
git fetch origin deploy 2>/dev/null && git checkout deploy || git checkout -b deploy
|
||||
# Update the pinned image tag.
|
||||
cd deploy
|
||||
kustomize edit set image solitaire-server=${{ env.IMAGE }}:${{ steps.meta.outputs.sha }}
|
||||
cd ..
|
||||
git add deploy/kustomization.yaml
|
||||
git diff --cached --quiet && exit 0 # nothing to commit — skip push
|
||||
git diff --cached --quiet && exit 0
|
||||
git commit -m "chore(deploy): bump image to ${{ steps.meta.outputs.sha }} [skip ci]"
|
||||
for i in 1 2 3; do
|
||||
git pull --rebase origin master && git push && break
|
||||
sleep 5
|
||||
done
|
||||
git push origin deploy
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
data/
|
||||
.claude/
|
||||
|
||||
# ruflo runtime state
|
||||
agentdb.rvf
|
||||
agentdb.rvf.lock
|
||||
|
||||
# IDE project files
|
||||
.idea/
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"ruflo": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"ruflo@latest",
|
||||
"mcp",
|
||||
"start"
|
||||
],
|
||||
"env": {
|
||||
"npm_config_update_notifier": "false",
|
||||
"CLAUDE_FLOW_MODE": "v3",
|
||||
"CLAUDE_FLOW_HOOKS_ENABLED": "true",
|
||||
"CLAUDE_FLOW_TOPOLOGY": "hierarchical-mesh",
|
||||
"CLAUDE_FLOW_MAX_AGENTS": "15",
|
||||
"CLAUDE_FLOW_MEMORY_BACKEND": "hybrid"
|
||||
},
|
||||
"autoStart": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -355,7 +355,7 @@ Must always be handled explicitly:
|
||||
* The gesture/navigation bar at the bottom (≈132px physical on common
|
||||
devices) is inside the Bevy viewport; use `SafeAreaInsets.bottom` to
|
||||
avoid placing interactive elements in that zone
|
||||
* `HUD_BAND_HEIGHT` is 128px on Android (two-row wrap) vs 64px on desktop;
|
||||
* `HUD_BAND_HEIGHT` is 112px on Android vs 64px on desktop;
|
||||
layout constants are `#[cfg(target_os = "android")]` gated
|
||||
* JNI calls must use `attach_current_thread_permanently` — not
|
||||
`attach_current_thread` — to avoid detach-on-drop panics
|
||||
|
||||
@@ -7,7 +7,7 @@ spec:
|
||||
project: default
|
||||
source:
|
||||
repoURL: https://git.aleshym.co/funman300/Ferrous-Solitaire.git
|
||||
targetRevision: master
|
||||
targetRevision: deploy
|
||||
path: deploy
|
||||
destination:
|
||||
server: https://kubernetes.default.svc
|
||||
|
||||
@@ -20,4 +20,4 @@ resources:
|
||||
images:
|
||||
- name: solitaire-server
|
||||
newName: git.aleshym.co/funman300/solitaire-server
|
||||
newTag: eb6c93fb
|
||||
newTag: da601beb
|
||||
|
||||
@@ -5,7 +5,7 @@ use crate::deck::{deal_klondike, Deck};
|
||||
use crate::error::MoveError;
|
||||
use crate::pile::{Pile, PileType};
|
||||
use crate::rules::{can_place_on_foundation, can_place_on_tableau, is_valid_tableau_sequence};
|
||||
use crate::scoring::{compute_time_bonus as scoring_time_bonus, score_move, score_undo as scoring_undo};
|
||||
use crate::scoring::{compute_time_bonus as scoring_time_bonus, score_flip, score_move, score_recycle, score_undo as scoring_undo};
|
||||
|
||||
const MAX_UNDO_STACK: usize = 64;
|
||||
|
||||
@@ -247,6 +247,13 @@ impl GameState {
|
||||
stock.cards.push(card);
|
||||
}
|
||||
self.recycle_count = self.recycle_count.saturating_add(1);
|
||||
if self.mode != GameMode::Zen {
|
||||
let penalty = score_recycle(
|
||||
self.recycle_count,
|
||||
self.draw_mode == DrawMode::DrawThree,
|
||||
);
|
||||
self.score = (self.score + penalty).max(0);
|
||||
}
|
||||
self.move_count = self.move_count.saturating_add(1);
|
||||
return Ok(());
|
||||
}
|
||||
@@ -308,6 +315,11 @@ impl GameState {
|
||||
|
||||
match &to {
|
||||
PileType::Foundation(_) => {
|
||||
if matches!(&from, PileType::Foundation(_)) {
|
||||
return Err(MoveError::RuleViolation(
|
||||
"cannot move between foundation slots".into(),
|
||||
));
|
||||
}
|
||||
if count != 1 {
|
||||
return Err(MoveError::RuleViolation(
|
||||
"only one card can move to foundation at a time".into(),
|
||||
@@ -331,6 +343,11 @@ impl GameState {
|
||||
));
|
||||
}
|
||||
}
|
||||
if matches!(&from, PileType::Waste) && count != 1 {
|
||||
return Err(MoveError::RuleViolation(
|
||||
"only the top waste card may be moved".into(),
|
||||
));
|
||||
}
|
||||
let dest = self.piles.get(&to).ok_or(MoveError::InvalidDestination)?;
|
||||
if !can_place_on_tableau(&bottom_card, dest) {
|
||||
return Err(MoveError::RuleViolation("invalid tableau placement".into()));
|
||||
@@ -367,7 +384,8 @@ impl GameState {
|
||||
.cards
|
||||
.split_off(move_start);
|
||||
|
||||
// Flip the newly exposed top card of the source pile
|
||||
// Flip the newly exposed top card of the source pile; award +5 per Windows scoring.
|
||||
let mut flipped = false;
|
||||
if let Some(top) = self.piles
|
||||
.get_mut(&from)
|
||||
.ok_or(MoveError::InvalidSource)?
|
||||
@@ -376,11 +394,13 @@ impl GameState {
|
||||
&& !top.face_up
|
||||
{
|
||||
top.face_up = true;
|
||||
flipped = true;
|
||||
}
|
||||
|
||||
self.piles.get_mut(&to).ok_or(MoveError::InvalidDestination)?.cards.append(&mut moved);
|
||||
|
||||
self.score = (self.score + score_delta).max(0);
|
||||
let flip_bonus = if flipped && self.mode != GameMode::Zen { score_flip() } else { 0 };
|
||||
self.score = (self.score + score_delta + flip_bonus).max(0);
|
||||
self.move_count = self.move_count.saturating_add(1);
|
||||
|
||||
self.is_won = self.check_win();
|
||||
@@ -416,23 +436,40 @@ impl GameState {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns `true` when all four foundation slots each contain 13 cards.
|
||||
/// Returns `true` when all four foundation slots each contain a valid A→K
|
||||
/// sequence of a single suit.
|
||||
///
|
||||
/// Counting 13 cards is not sufficient — a corrupt save could produce 13
|
||||
/// arbitrary cards per pile and permanently lock the game via `GameAlreadyWon`.
|
||||
pub fn check_win(&self) -> bool {
|
||||
(0..4_u8).all(|slot| {
|
||||
self.piles
|
||||
.get(&PileType::Foundation(slot))
|
||||
.is_some_and(|p| p.cards.len() == 13)
|
||||
(0..4_u8).all(|slot| self.is_valid_foundation_pile(slot))
|
||||
}
|
||||
|
||||
fn is_valid_foundation_pile(&self, slot: u8) -> bool {
|
||||
let Some(pile) = self.piles.get(&PileType::Foundation(slot)) else {
|
||||
return false;
|
||||
};
|
||||
if pile.cards.len() != 13 {
|
||||
return false;
|
||||
}
|
||||
let suit = pile.cards[0].suit;
|
||||
pile.cards.iter().enumerate().all(|(i, card)| {
|
||||
card.suit == suit && card.rank.value() == (i as u8 + 1)
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns `true` when stock and waste are empty and all tableau cards are face-up.
|
||||
/// At that point the game can be completed without further player input.
|
||||
pub fn check_auto_complete(&self) -> bool {
|
||||
// Stock must be empty; waste may still have cards (they are resolved
|
||||
// by draw() calls inside next_auto_complete_move / auto_complete_step).
|
||||
// All three conditions must hold: stock empty, waste empty, and all
|
||||
// tableau cards face-up. Requiring waste empty avoids the deadlock
|
||||
// where the waste top cannot reach a foundation directly.
|
||||
if self.piles.get(&PileType::Stock).is_none_or(|p| !p.cards.is_empty()) {
|
||||
return false;
|
||||
}
|
||||
if self.piles.get(&PileType::Waste).is_none_or(|p| !p.cards.is_empty()) {
|
||||
return false;
|
||||
}
|
||||
(0..7).all(|i| {
|
||||
self.piles
|
||||
.get(&PileType::Tableau(i))
|
||||
@@ -535,11 +572,10 @@ impl GameState {
|
||||
/// # Precondition
|
||||
///
|
||||
/// This function is only called when `is_auto_completable` is `true`.
|
||||
/// Auto-completability requires the waste pile to be empty, as enforced by
|
||||
/// [`check_auto_complete`](Self::check_auto_complete) — it returns `false`
|
||||
/// whenever `piles[Waste]` is non-empty. Therefore, skipping the waste pile
|
||||
/// in this scan is intentional and correct: by the time this function is
|
||||
/// reached, there are guaranteed to be no cards there to move.
|
||||
/// Auto-completability requires both stock and waste to be empty, as
|
||||
/// enforced by [`check_auto_complete`](Self::check_auto_complete). The
|
||||
/// waste-pile check in this function is therefore a safety net only; under
|
||||
/// normal operation the waste is guaranteed empty when this is reached.
|
||||
pub fn next_auto_complete_move(&self) -> Option<(PileType, PileType)> {
|
||||
if !self.is_auto_completable || self.is_won {
|
||||
return None;
|
||||
@@ -1121,10 +1157,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_complete_true_when_stock_empty_waste_has_cards() {
|
||||
// Waste no longer blocks auto-complete — draw() drains it during
|
||||
// auto-complete steps. Only stock-not-empty and face-down tableau
|
||||
// cards block the flag.
|
||||
fn auto_complete_blocked_when_waste_has_cards() {
|
||||
// Waste must also be empty for auto-complete to engage. A non-empty
|
||||
// waste pile — even with all tableau cards face-up and stock empty —
|
||||
// must return false to prevent a deadlock where the waste top cannot
|
||||
// reach a foundation directly.
|
||||
let mut g = new_game();
|
||||
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||
g.piles.get_mut(&PileType::Waste).unwrap().cards.push(Card {
|
||||
@@ -1138,7 +1175,7 @@ mod tests {
|
||||
c.face_up = true;
|
||||
}
|
||||
}
|
||||
assert!(g.check_auto_complete());
|
||||
assert!(!g.check_auto_complete());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1395,12 +1432,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn take_from_foundation_allowed_by_default() {
|
||||
let mut g = setup_take_from_foundation_game();
|
||||
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);
|
||||
fn take_from_foundation_enabled_by_default() {
|
||||
let g = setup_take_from_foundation_game();
|
||||
assert!(g.take_from_foundation, "take_from_foundation is on by default (standard Klondike rule)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1507,6 +1541,126 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Flip bonus (+5) ---
|
||||
|
||||
#[test]
|
||||
fn flip_bonus_awarded_when_face_down_card_exposed() {
|
||||
let mut g = new_game();
|
||||
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
for i in 0..7 { g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); }
|
||||
// Tableau(0): hidden Ace under a face-up 5♠
|
||||
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards = vec![
|
||||
Card { id: 1, suit: Suit::Hearts, rank: Rank::Ace, face_up: false },
|
||||
Card { id: 2, suit: Suit::Spades, rank: Rank::Five, face_up: true },
|
||||
];
|
||||
// Tableau(1): 6♥ — 5♠ can land here
|
||||
g.piles.get_mut(&PileType::Tableau(1)).unwrap().cards = vec![
|
||||
Card { id: 3, suit: Suit::Hearts, rank: Rank::Six, face_up: true },
|
||||
];
|
||||
let score_before = g.score;
|
||||
g.move_cards(PileType::Tableau(0), PileType::Tableau(1), 1).unwrap();
|
||||
assert_eq!(g.score, score_before + 5, "flip bonus must be +5 when a face-down card is exposed");
|
||||
assert!(g.piles[&PileType::Tableau(0)].cards[0].face_up, "exposed card must now be face-up");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flip_bonus_not_awarded_when_source_pile_empties() {
|
||||
let mut g = new_game();
|
||||
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
for i in 0..7 { g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); }
|
||||
// Only a King in Tableau(0); moving it leaves pile empty — nothing to flip
|
||||
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards = vec![
|
||||
Card { id: 1, suit: Suit::Spades, rank: Rank::King, face_up: true },
|
||||
];
|
||||
let score_before = g.score;
|
||||
g.move_cards(PileType::Tableau(0), PileType::Tableau(1), 1).unwrap();
|
||||
assert_eq!(g.score, score_before, "no flip bonus when source pile becomes empty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flip_bonus_suppressed_in_zen_mode() {
|
||||
let mut g = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::Zen);
|
||||
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
for i in 0..7 { g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); }
|
||||
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards = vec![
|
||||
Card { id: 1, suit: Suit::Hearts, rank: Rank::Ace, face_up: false },
|
||||
Card { id: 2, suit: Suit::Spades, rank: Rank::Five, face_up: true },
|
||||
];
|
||||
g.piles.get_mut(&PileType::Tableau(1)).unwrap().cards = vec![
|
||||
Card { id: 3, suit: Suit::Hearts, rank: Rank::Six, face_up: true },
|
||||
];
|
||||
g.move_cards(PileType::Tableau(0), PileType::Tableau(1), 1).unwrap();
|
||||
assert_eq!(g.score, 0, "zen mode must suppress flip bonus");
|
||||
}
|
||||
|
||||
// --- Recycle penalty ---
|
||||
|
||||
#[test]
|
||||
fn recycle_penalty_draw1_first_pass_free() {
|
||||
let mut g = new_game(); // DrawOne
|
||||
g.score = 200;
|
||||
while !g.piles[&PileType::Stock].cards.is_empty() { g.draw().unwrap(); }
|
||||
g.draw().unwrap(); // first recycle — free
|
||||
assert_eq!(g.recycle_count, 1);
|
||||
assert_eq!(g.score, 200, "first recycle in Draw-1 must be free");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recycle_penalty_draw1_second_pass_costs_100() {
|
||||
let mut g = new_game(); // DrawOne
|
||||
g.score = 200;
|
||||
// First recycle (free)
|
||||
while !g.piles[&PileType::Stock].cards.is_empty() { g.draw().unwrap(); }
|
||||
g.draw().unwrap();
|
||||
// Second recycle (-100)
|
||||
while !g.piles[&PileType::Stock].cards.is_empty() { g.draw().unwrap(); }
|
||||
g.draw().unwrap();
|
||||
assert_eq!(g.recycle_count, 2);
|
||||
assert_eq!(g.score, 100, "second recycle in Draw-1 must cost -100");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recycle_penalty_draw3_three_passes_free() {
|
||||
let mut g = GameState::new(42, DrawMode::DrawThree);
|
||||
g.score = 200;
|
||||
for _ in 0..3 {
|
||||
while !g.piles[&PileType::Stock].cards.is_empty() { g.draw().unwrap(); }
|
||||
g.draw().unwrap();
|
||||
}
|
||||
assert_eq!(g.recycle_count, 3);
|
||||
assert_eq!(g.score, 200, "first 3 recycles in Draw-3 must be free");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recycle_penalty_draw3_fourth_pass_costs_20() {
|
||||
let mut g = GameState::new(42, DrawMode::DrawThree);
|
||||
g.score = 200;
|
||||
for _ in 0..3 {
|
||||
while !g.piles[&PileType::Stock].cards.is_empty() { g.draw().unwrap(); }
|
||||
g.draw().unwrap();
|
||||
}
|
||||
// Fourth recycle (-20)
|
||||
while !g.piles[&PileType::Stock].cards.is_empty() { g.draw().unwrap(); }
|
||||
g.draw().unwrap();
|
||||
assert_eq!(g.recycle_count, 4);
|
||||
assert_eq!(g.score, 180, "fourth recycle in Draw-3 must cost -20");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recycle_penalty_suppressed_in_zen_mode() {
|
||||
let mut g = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::Zen);
|
||||
// Two recycles — second would normally cost -100 in classic mode
|
||||
for _ in 0..2 {
|
||||
while !g.piles[&PileType::Stock].cards.is_empty() { g.draw().unwrap(); }
|
||||
g.draw().unwrap();
|
||||
}
|
||||
assert_eq!(g.recycle_count, 2);
|
||||
assert_eq!(g.score, 0, "zen mode must suppress recycle penalty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn possible_instructions_waste_top_included() {
|
||||
let mut g = new_game();
|
||||
@@ -1525,4 +1679,58 @@ mod tests {
|
||||
"King on waste must be moveable to an empty tableau column"
|
||||
);
|
||||
}
|
||||
|
||||
// --- P2: waste multi-card move must be rejected ---
|
||||
|
||||
#[test]
|
||||
fn waste_multi_card_move_returns_rule_violation() {
|
||||
let mut g = new_game();
|
||||
g.piles.get_mut(&PileType::Waste).unwrap().cards = vec![
|
||||
Card { id: 1, suit: Suit::Hearts, rank: Rank::Ace, face_up: true },
|
||||
Card { id: 2, suit: Suit::Spades, rank: Rank::King, face_up: true },
|
||||
];
|
||||
for i in 0..7 { g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); }
|
||||
let result = g.move_cards(PileType::Waste, PileType::Tableau(0), 2);
|
||||
assert!(matches!(result, Err(MoveError::RuleViolation(_))),
|
||||
"moving 2 cards from waste must be rejected");
|
||||
}
|
||||
|
||||
// --- P3: foundation-to-foundation move must be rejected ---
|
||||
|
||||
#[test]
|
||||
fn foundation_to_foundation_move_returns_rule_violation() {
|
||||
let mut g = new_game();
|
||||
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
for i in 0..7 { g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); }
|
||||
// Place Ace of Clubs on Foundation(0), leave Foundation(1) empty.
|
||||
g.piles.get_mut(&PileType::Foundation(0)).unwrap().cards = vec![
|
||||
Card { id: 1, suit: Suit::Clubs, rank: Rank::Ace, face_up: true },
|
||||
];
|
||||
// Attempting to move Ace from Foundation(0) to Foundation(1) must fail.
|
||||
let result = g.move_cards(PileType::Foundation(0), PileType::Foundation(1), 1);
|
||||
assert!(matches!(result, Err(MoveError::RuleViolation(_))),
|
||||
"moving between foundation slots must be rejected");
|
||||
}
|
||||
|
||||
// --- P4: undo must not retain points from the undone move ---
|
||||
|
||||
#[test]
|
||||
fn undo_does_not_retain_score_from_undone_move() {
|
||||
let mut g = new_game();
|
||||
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
for i in 0..7 { g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); }
|
||||
// Place an Ace on Tableau(0) — moving it to Foundation earns +10.
|
||||
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards = vec![
|
||||
Card { id: 1, suit: Suit::Clubs, rank: Rank::Ace, face_up: true },
|
||||
];
|
||||
assert_eq!(g.score, 0);
|
||||
g.move_cards(PileType::Tableau(0), PileType::Foundation(0), 1).unwrap();
|
||||
assert_eq!(g.score, 10, "moving Ace to foundation earns +10");
|
||||
// Undo must roll back to snapshot.score (0) minus the penalty, not keep the +10.
|
||||
g.undo().unwrap();
|
||||
// snapshot.score was 0, so result is max(0, 0 - 15) = 0
|
||||
assert_eq!(g.score, 0, "undo must not retain points from the undone move");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,19 @@ use crate::pile::PileType;
|
||||
/// Windows XP Standard scoring:
|
||||
/// - +10 for any card reaching a foundation pile
|
||||
/// - +5 for a waste → tableau move
|
||||
/// - -15 for a foundation → tableau (take-from-foundation) move
|
||||
/// - 0 for all other moves
|
||||
///
|
||||
/// Note: the +5 flip bonus for exposing a face-down tableau card is applied
|
||||
/// separately in `game_state::move_cards` because it depends on post-move state.
|
||||
pub fn score_move(from: &PileType, to: &PileType) -> i32 {
|
||||
match to {
|
||||
PileType::Foundation(_) => 10,
|
||||
PileType::Tableau(_) => {
|
||||
if matches!(from, PileType::Waste) { 5 } else { 0 }
|
||||
}
|
||||
PileType::Tableau(_) => match from {
|
||||
PileType::Waste => 5,
|
||||
PileType::Foundation(_) => -15,
|
||||
_ => 0,
|
||||
},
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
@@ -21,6 +27,21 @@ pub fn score_undo() -> i32 {
|
||||
-15
|
||||
}
|
||||
|
||||
/// Score bonus awarded when a face-down tableau card is flipped face-up: +5.
|
||||
pub fn score_flip() -> i32 {
|
||||
5
|
||||
}
|
||||
|
||||
/// Score penalty for recycling the waste pile back to stock.
|
||||
///
|
||||
/// Windows standard: the first N recycles are free (N=1 for Draw-1, N=3 for Draw-3).
|
||||
/// Subsequent recycles cost -100 (Draw-1) or -20 (Draw-3).
|
||||
/// `recycle_count` is the new total count **after** this recycle.
|
||||
pub fn score_recycle(recycle_count: u32, is_draw_three: bool) -> i32 {
|
||||
let (free, penalty) = if is_draw_three { (3_u32, -20_i32) } else { (1_u32, -100_i32) };
|
||||
if recycle_count > free { penalty } else { 0 }
|
||||
}
|
||||
|
||||
/// Time bonus added to the score on a win: `700_000 / elapsed_seconds`.
|
||||
/// Returns 0 when `elapsed_seconds` is 0 to avoid division by zero.
|
||||
pub fn compute_time_bonus(elapsed_seconds: u64) -> i32 {
|
||||
@@ -71,13 +92,12 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_waste_to_tableau_scores_zero() {
|
||||
// Foundation → Tableau is impossible in practice but must score 0.
|
||||
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);
|
||||
fn foundation_to_tableau_penalises_fifteen() {
|
||||
// Moving a card back off a foundation (take_from_foundation rule) costs -15.
|
||||
assert_eq!(score_move(&PileType::Foundation(0), &PileType::Tableau(0)), -15);
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn move_to_stock_or_waste_scores_zero() {
|
||||
// These destinations are illegal moves in practice, but the function
|
||||
@@ -92,4 +112,29 @@ mod tests {
|
||||
let bonus = compute_time_bonus(1);
|
||||
assert!(bonus >= 0, "time bonus must be non-negative after u64→i32 cast");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flip_bonus_is_five() {
|
||||
assert_eq!(score_flip(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recycle_draw1_first_pass_free() {
|
||||
assert_eq!(score_recycle(1, false), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recycle_draw1_second_pass_penalised() {
|
||||
assert_eq!(score_recycle(2, false), -100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recycle_draw3_third_pass_free() {
|
||||
assert_eq!(score_recycle(3, true), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recycle_draw3_fourth_pass_penalised() {
|
||||
assert_eq!(score_recycle(4, true), -20);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,9 +298,16 @@ impl SolverState {
|
||||
}
|
||||
}
|
||||
|
||||
/// True when every foundation slot has 13 cards.
|
||||
/// True when every foundation slot holds a complete Ace-through-King sequence.
|
||||
fn is_won(&self) -> bool {
|
||||
self.foundation.iter().all(|f| f.len() == 13)
|
||||
self.foundation.iter().all(|pile| {
|
||||
pile.len() == 13
|
||||
&& pile[0].rank == crate::card::Rank::Ace
|
||||
&& pile.windows(2).all(|w| {
|
||||
w[0].suit == w[1].suit
|
||||
&& w[1].rank.value() == w[0].rank.value() + 1
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the foundation slot that already claims `suit`, or the
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
///
|
||||
/// Tokens are serialised to JSON, encrypted with AES-256/GCM/NoPadding using a
|
||||
/// device-bound key from the Android Keystore, and written atomically to
|
||||
/// `{data_dir}/auth_tokens.bin` as `[12-byte IV][ciphertext+GCM-tag]`.
|
||||
/// `{data_dir}/ferrous_solitaire/auth_tokens.bin` as `[12-byte IV][ciphertext+GCM-tag]`.
|
||||
///
|
||||
/// The file stores a `HashMap<String, TokenBlob>` (keyed by username) so that
|
||||
/// multiple accounts can coexist without silently overwriting each other.
|
||||
///
|
||||
/// The Keystore key survives app restarts but is destroyed on uninstall (or if
|
||||
/// the user changes biometric/lock credentials, in which case decryption fails
|
||||
@@ -15,6 +18,7 @@ use jni::{
|
||||
JNIEnv, JavaVM,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::auth_tokens::TokenError;
|
||||
@@ -280,21 +284,30 @@ fn decrypt_gcm(
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn token_file_path() -> Option<PathBuf> {
|
||||
crate::platform::data_dir()
|
||||
.map(|d| d.join(crate::APP_DIR_NAME).join("auth_tokens.bin"))
|
||||
}
|
||||
|
||||
/// Path where the token file lived before the APP_DIR_NAME subdirectory was
|
||||
/// introduced. Used only during the one-time migration in `read_map`.
|
||||
fn legacy_token_file_path() -> Option<PathBuf> {
|
||||
crate::platform::data_dir().map(|d| d.join("auth_tokens.bin"))
|
||||
}
|
||||
|
||||
fn read_file_bytes() -> Result<Vec<u8>, TokenError> {
|
||||
let path = token_file_path()
|
||||
.ok_or_else(|| TokenError::KeychainUnavailable("no data dir".into()))?;
|
||||
fn read_file_bytes_from(path: &PathBuf) -> Result<Vec<u8>, TokenError> {
|
||||
if !path.exists() {
|
||||
return Err(TokenError::NotFound(String::new()));
|
||||
}
|
||||
std::fs::read(&path).map_err(|e| TokenError::Keyring(format!("read auth_tokens.bin: {e}")))
|
||||
std::fs::read(path).map_err(|e| TokenError::Keyring(format!("read auth_tokens.bin: {e}")))
|
||||
}
|
||||
|
||||
fn write_file_bytes(data: &[u8]) -> Result<(), TokenError> {
|
||||
let path = token_file_path()
|
||||
.ok_or_else(|| TokenError::KeychainUnavailable("no data dir".into()))?;
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| TokenError::Keyring(format!("create dir: {e}")))?;
|
||||
}
|
||||
let tmp = path.with_extension("bin.tmp");
|
||||
std::fs::write(&tmp, data)
|
||||
.map_err(|e| TokenError::Keyring(format!("write auth_tokens.bin.tmp: {e}")))?;
|
||||
@@ -302,29 +315,88 @@ fn write_file_bytes(data: &[u8]) -> Result<(), TokenError> {
|
||||
.map_err(|e| TokenError::Keyring(format!("rename auth_tokens: {e}")))
|
||||
}
|
||||
|
||||
fn load_blob(username: &str) -> Result<TokenBlob, TokenError> {
|
||||
let data = read_file_bytes().map_err(|e| match e {
|
||||
TokenError::NotFound(_) => TokenError::NotFound(username.to_string()),
|
||||
other => other,
|
||||
})?;
|
||||
/// Decrypt raw bytes from the file and deserialise as `HashMap<String, TokenBlob>`.
|
||||
///
|
||||
/// Migration strategy:
|
||||
/// 1. If the new-path file exists, read and decrypt it.
|
||||
/// - Try to deserialise as `HashMap<String, TokenBlob>`.
|
||||
/// - On parse failure (old single-blob format), try `TokenBlob` and convert.
|
||||
/// 2. If the new-path file does NOT exist but the legacy-path file does, migrate:
|
||||
/// - Read and decrypt the legacy file.
|
||||
/// - Deserialise as `TokenBlob` (the only format the legacy path ever used).
|
||||
/// - Write the result to the new path as a single-entry map.
|
||||
/// - Delete the legacy file (best-effort; leave it if removal fails).
|
||||
/// 3. If neither file exists, return an empty map.
|
||||
fn read_map() -> Result<HashMap<String, TokenBlob>, TokenError> {
|
||||
let new_path = token_file_path()
|
||||
.ok_or_else(|| TokenError::KeychainUnavailable("no data dir".into()))?;
|
||||
let legacy_path = legacy_token_file_path();
|
||||
|
||||
if data.len() < 12 {
|
||||
return Err(TokenError::Keyring("auth_tokens.bin corrupt (too short)".into()));
|
||||
// --- 1. New path exists ---
|
||||
if new_path.exists() {
|
||||
let data = read_file_bytes_from(&new_path).map_err(|e| match e {
|
||||
TokenError::NotFound(_) => TokenError::NotFound(String::new()),
|
||||
other => other,
|
||||
})?;
|
||||
if data.len() < 12 {
|
||||
return Err(TokenError::Keyring("auth_tokens.bin corrupt (too short)".into()));
|
||||
}
|
||||
let plaintext = with_jvm(|env| {
|
||||
let key = load_or_create_key(env)?;
|
||||
decrypt_gcm(env, &key, &data)
|
||||
})?;
|
||||
// Try the current multi-user format first.
|
||||
if let Ok(map) = serde_json::from_slice::<HashMap<String, TokenBlob>>(&plaintext) {
|
||||
return Ok(map);
|
||||
}
|
||||
// Fall back: old single-blob format written by an earlier binary.
|
||||
if let Ok(blob) = serde_json::from_slice::<TokenBlob>(&plaintext) {
|
||||
let mut map = HashMap::new();
|
||||
map.insert(blob.username.clone(), blob);
|
||||
return Ok(map);
|
||||
}
|
||||
return Err(TokenError::Keyring("auth_tokens.bin unrecognised format".into()));
|
||||
}
|
||||
|
||||
let plaintext = with_jvm(|env| {
|
||||
// --- 2. Legacy path migration ---
|
||||
if let Some(ref lpath) = legacy_path {
|
||||
if lpath.exists() {
|
||||
let data = read_file_bytes_from(lpath).map_err(|e| match e {
|
||||
TokenError::NotFound(_) => TokenError::NotFound(String::new()),
|
||||
other => other,
|
||||
})?;
|
||||
if data.len() >= 12 {
|
||||
let plaintext = with_jvm(|env| {
|
||||
let key = load_or_create_key(env)?;
|
||||
decrypt_gcm(env, &key, &data)
|
||||
})?;
|
||||
if let Ok(blob) = serde_json::from_slice::<TokenBlob>(&plaintext) {
|
||||
let mut map = HashMap::new();
|
||||
map.insert(blob.username.clone(), blob);
|
||||
// Write to the new location, then remove the legacy file.
|
||||
if write_map_inner(&map).is_ok() {
|
||||
let _ = std::fs::remove_file(lpath);
|
||||
}
|
||||
return Ok(map);
|
||||
}
|
||||
}
|
||||
// Legacy file corrupt or unrecognised — treat as empty.
|
||||
}
|
||||
}
|
||||
|
||||
// --- 3. No file found ---
|
||||
Ok(HashMap::new())
|
||||
}
|
||||
|
||||
/// Serialise and encrypt a map, then write it atomically.
|
||||
fn write_map_inner(map: &HashMap<String, TokenBlob>) -> Result<(), TokenError> {
|
||||
let plaintext = serde_json::to_vec(map)
|
||||
.map_err(|e| TokenError::Keyring(format!("JSON encode: {e}")))?;
|
||||
let encrypted = with_jvm(|env| {
|
||||
let key = load_or_create_key(env)?;
|
||||
decrypt_gcm(env, &key, &data)
|
||||
encrypt_gcm(env, &key, &plaintext)
|
||||
})?;
|
||||
|
||||
let blob: TokenBlob = serde_json::from_slice(&plaintext)
|
||||
.map_err(|e| TokenError::Keyring(format!("JSON decode: {e}")))?;
|
||||
|
||||
if blob.username != username {
|
||||
return Err(TokenError::NotFound(username.to_string()));
|
||||
}
|
||||
|
||||
Ok(blob)
|
||||
write_file_bytes(&encrypted)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -333,77 +405,106 @@ fn load_blob(username: &str) -> Result<TokenBlob, TokenError> {
|
||||
|
||||
/// Encrypt and store `access_token` and `refresh_token` for `username`.
|
||||
///
|
||||
/// Overwrites any previously stored tokens.
|
||||
/// If tokens already exist for other usernames they are preserved.
|
||||
/// Any previously stored tokens for `username` are silently replaced.
|
||||
pub fn store_tokens(
|
||||
username: &str,
|
||||
access_token: &str,
|
||||
refresh_token: &str,
|
||||
) -> Result<(), TokenError> {
|
||||
let blob = TokenBlob {
|
||||
username: username.to_string(),
|
||||
access_token: access_token.to_string(),
|
||||
refresh_token: refresh_token.to_string(),
|
||||
let mut map = match read_map() {
|
||||
Ok(m) => m,
|
||||
// If the file is missing or corrupt, start with an empty map so we
|
||||
// do not block a fresh login.
|
||||
Err(TokenError::NotFound(_)) => HashMap::new(),
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
let plaintext = serde_json::to_vec(&blob)
|
||||
.map_err(|e| TokenError::Keyring(format!("JSON encode: {e}")))?;
|
||||
|
||||
let encrypted = with_jvm(|env| {
|
||||
let key = load_or_create_key(env)?;
|
||||
encrypt_gcm(env, &key, &plaintext)
|
||||
})?;
|
||||
map.insert(
|
||||
username.to_string(),
|
||||
TokenBlob {
|
||||
username: username.to_string(),
|
||||
access_token: access_token.to_string(),
|
||||
refresh_token: refresh_token.to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
write_file_bytes(&encrypted)
|
||||
write_map_inner(&map)
|
||||
}
|
||||
|
||||
/// Return the stored access token for `username`.
|
||||
///
|
||||
/// Returns [`TokenError::NotFound`] if no token has been stored yet.
|
||||
/// Returns [`TokenError::NotFound`] if no token has been stored for this username.
|
||||
pub fn load_access_token(username: &str) -> Result<String, TokenError> {
|
||||
load_blob(username).map(|b| b.access_token)
|
||||
let mut map = read_map()?;
|
||||
map.remove(username)
|
||||
.map(|b| b.access_token)
|
||||
.ok_or_else(|| TokenError::NotFound(username.to_string()))
|
||||
}
|
||||
|
||||
/// Return the stored refresh token for `username`.
|
||||
///
|
||||
/// Returns [`TokenError::NotFound`] if no token has been stored yet.
|
||||
/// Returns [`TokenError::NotFound`] if no token has been stored for this username.
|
||||
pub fn load_refresh_token(username: &str) -> Result<String, TokenError> {
|
||||
load_blob(username).map(|b| b.refresh_token)
|
||||
let mut map = read_map()?;
|
||||
map.remove(username)
|
||||
.map(|b| b.refresh_token)
|
||||
.ok_or_else(|| TokenError::NotFound(username.to_string()))
|
||||
}
|
||||
|
||||
/// Delete stored tokens and remove the Keystore key for `username`.
|
||||
/// Delete stored tokens for `username`.
|
||||
///
|
||||
/// If other usernames have stored tokens they are left untouched.
|
||||
/// When this is the last entry in the map the Keystore key is also removed so
|
||||
/// a future re-login generates a fresh key.
|
||||
///
|
||||
/// Missing file or missing Keystore entry are silently ignored.
|
||||
pub fn delete_tokens(_username: &str) -> Result<(), TokenError> {
|
||||
if let Some(path) = token_file_path() {
|
||||
if path.exists() {
|
||||
std::fs::remove_file(&path)
|
||||
.map_err(|e| TokenError::Keyring(format!("delete auth_tokens.bin: {e}")))?;
|
||||
pub fn delete_tokens(username: &str) -> Result<(), TokenError> {
|
||||
let mut map = match read_map() {
|
||||
Ok(m) => m,
|
||||
Err(TokenError::NotFound(_)) => return Ok(()), // nothing to delete
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
|
||||
map.remove(username);
|
||||
|
||||
if map.is_empty() {
|
||||
// No more users — remove the file and the Keystore key.
|
||||
if let Some(path) = token_file_path() {
|
||||
if path.exists() {
|
||||
std::fs::remove_file(&path)
|
||||
.map_err(|e| TokenError::Keyring(format!("delete auth_tokens.bin: {e}")))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the Keystore key so a future re-login generates a fresh key.
|
||||
with_jvm(|env| {
|
||||
let ks_class = env.find_class("java/security/KeyStore")?;
|
||||
let ks_type = JValueOwned::from(env.new_string("AndroidKeyStore")?);
|
||||
let ks = env
|
||||
.call_static_method(
|
||||
&ks_class,
|
||||
"getInstance",
|
||||
"(Ljava/lang/String;)Ljava/security/KeyStore;",
|
||||
&[ks_type.borrow()],
|
||||
// Remove the Keystore key so a future re-login generates a fresh key.
|
||||
with_jvm(|env| {
|
||||
let ks_class = env.find_class("java/security/KeyStore")?;
|
||||
let ks_type = JValueOwned::from(env.new_string("AndroidKeyStore")?);
|
||||
let ks = env
|
||||
.call_static_method(
|
||||
&ks_class,
|
||||
"getInstance",
|
||||
"(Ljava/lang/String;)Ljava/security/KeyStore;",
|
||||
&[ks_type.borrow()],
|
||||
)?
|
||||
.l()?;
|
||||
|
||||
let null = JObject::null();
|
||||
env.call_method(
|
||||
&ks,
|
||||
"load",
|
||||
"(Ljava/security/KeyStore$LoadStoreParameter;)V",
|
||||
&[JValue::Object(&null)],
|
||||
)?
|
||||
.l()?;
|
||||
.v()?;
|
||||
|
||||
let null = JObject::null();
|
||||
env.call_method(
|
||||
&ks,
|
||||
"load",
|
||||
"(Ljava/security/KeyStore$LoadStoreParameter;)V",
|
||||
&[JValue::Object(&null)],
|
||||
)?
|
||||
.v()?;
|
||||
|
||||
let alias = JValueOwned::from(env.new_string(KEY_ALIAS)?);
|
||||
env.call_method(&ks, "deleteEntry", "(Ljava/lang/String;)V", &[alias.borrow()])?
|
||||
.v()
|
||||
})
|
||||
let alias = JValueOwned::from(env.new_string(KEY_ALIAS)?);
|
||||
env.call_method(&ks, "deleteEntry", "(Ljava/lang/String;)V", &[alias.borrow()])?
|
||||
.v()
|
||||
})
|
||||
} else {
|
||||
// Other users still exist — just rewrite the map without this user.
|
||||
write_map_inner(&map)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
|
||||
use crate::stats_plugin::{StatsResource, StatsUpdate};
|
||||
use crate::ui_modal::{
|
||||
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
||||
ScrimDismissible,
|
||||
ModalScrim, ScrimDismissible,
|
||||
};
|
||||
use crate::ui_theme::{
|
||||
ACCENT_PRIMARY, BORDER_SUBTLE, STATE_SUCCESS, TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY,
|
||||
@@ -162,93 +162,91 @@ fn evaluate_on_win(
|
||||
mut achievements: ResMut<AchievementsResource>,
|
||||
mut progress: ResMut<ProgressResource>,
|
||||
) {
|
||||
let Some(ev) = wins.read().last() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let ctx = AchievementContext {
|
||||
games_played: stats.0.games_played,
|
||||
games_won: stats.0.games_won,
|
||||
win_streak_current: stats.0.win_streak_current,
|
||||
best_single_score: stats.0.best_single_score,
|
||||
lifetime_score: stats.0.lifetime_score,
|
||||
draw_three_wins: stats.0.draw_three_wins,
|
||||
daily_challenge_streak: progress.0.daily_challenge_streak,
|
||||
last_win_score: ev.score,
|
||||
last_win_time_seconds: ev.time_seconds,
|
||||
last_win_used_undo: game.0.undo_count > 0,
|
||||
wall_clock_hour: Some(Local::now().hour()),
|
||||
last_win_recycle_count: game.0.recycle_count,
|
||||
last_win_is_zen: game.0.mode == solitaire_core::game_state::GameMode::Zen,
|
||||
};
|
||||
|
||||
let hits = check_achievements(&ctx);
|
||||
if hits.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let now = Utc::now();
|
||||
let mut achievements_changed = false;
|
||||
let mut progress_changed = false;
|
||||
|
||||
for def in hits {
|
||||
let Some(record) = achievements.0.iter_mut().find(|r| r.id == def.id) else {
|
||||
continue;
|
||||
for ev in wins.read() {
|
||||
let ctx = AchievementContext {
|
||||
games_played: stats.0.games_played,
|
||||
games_won: stats.0.games_won,
|
||||
win_streak_current: stats.0.win_streak_current,
|
||||
best_single_score: stats.0.best_single_score,
|
||||
lifetime_score: stats.0.lifetime_score,
|
||||
draw_three_wins: stats.0.draw_three_wins,
|
||||
daily_challenge_streak: progress.0.daily_challenge_streak,
|
||||
last_win_score: ev.score,
|
||||
last_win_time_seconds: ev.time_seconds,
|
||||
last_win_used_undo: game.0.undo_count > 0,
|
||||
wall_clock_hour: Some(Local::now().hour()),
|
||||
last_win_recycle_count: game.0.recycle_count,
|
||||
last_win_is_zen: game.0.mode == solitaire_core::game_state::GameMode::Zen,
|
||||
};
|
||||
if record.unlocked {
|
||||
|
||||
let hits = check_achievements(&ctx);
|
||||
if hits.is_empty() {
|
||||
continue;
|
||||
}
|
||||
record.unlock(now);
|
||||
achievements_changed = true;
|
||||
|
||||
// Grant the reward on first unlock.
|
||||
if !record.reward_granted {
|
||||
if let Some(reward) = def.reward {
|
||||
match reward {
|
||||
Reward::CardBack(idx) => {
|
||||
if !progress.0.unlocked_card_backs.contains(&idx) {
|
||||
progress.0.unlocked_card_backs.push(idx);
|
||||
progress_changed = true;
|
||||
}
|
||||
}
|
||||
Reward::Background(idx) => {
|
||||
if !progress.0.unlocked_backgrounds.contains(&idx) {
|
||||
progress.0.unlocked_backgrounds.push(idx);
|
||||
progress_changed = true;
|
||||
}
|
||||
}
|
||||
Reward::BonusXp(amount) => {
|
||||
xp_awarded.write(XpAwardedEvent { amount });
|
||||
let prev_level = progress.0.add_xp(amount);
|
||||
if progress.0.leveled_up_from(prev_level) {
|
||||
levelups.write(LevelUpEvent {
|
||||
previous_level: prev_level,
|
||||
new_level: progress.0.level,
|
||||
total_xp: progress.0.total_xp,
|
||||
});
|
||||
}
|
||||
progress_changed = true;
|
||||
}
|
||||
Reward::Badge => {}
|
||||
}
|
||||
let now = Utc::now();
|
||||
let mut achievements_changed = false;
|
||||
let mut progress_changed = false;
|
||||
|
||||
for def in hits {
|
||||
let Some(record) = achievements.0.iter_mut().find(|r| r.id == def.id) else {
|
||||
continue;
|
||||
};
|
||||
if record.unlocked {
|
||||
continue;
|
||||
}
|
||||
record.reward_granted = true;
|
||||
record.unlock(now);
|
||||
achievements_changed = true;
|
||||
|
||||
// Grant the reward on first unlock.
|
||||
if !record.reward_granted {
|
||||
if let Some(reward) = def.reward {
|
||||
match reward {
|
||||
Reward::CardBack(idx) => {
|
||||
if !progress.0.unlocked_card_backs.contains(&idx) {
|
||||
progress.0.unlocked_card_backs.push(idx);
|
||||
progress_changed = true;
|
||||
}
|
||||
}
|
||||
Reward::Background(idx) => {
|
||||
if !progress.0.unlocked_backgrounds.contains(&idx) {
|
||||
progress.0.unlocked_backgrounds.push(idx);
|
||||
progress_changed = true;
|
||||
}
|
||||
}
|
||||
Reward::BonusXp(amount) => {
|
||||
xp_awarded.write(XpAwardedEvent { amount });
|
||||
let prev_level = progress.0.add_xp(amount);
|
||||
if progress.0.leveled_up_from(prev_level) {
|
||||
levelups.write(LevelUpEvent {
|
||||
previous_level: prev_level,
|
||||
new_level: progress.0.level,
|
||||
total_xp: progress.0.total_xp,
|
||||
});
|
||||
}
|
||||
progress_changed = true;
|
||||
}
|
||||
Reward::Badge => {}
|
||||
}
|
||||
}
|
||||
record.reward_granted = true;
|
||||
}
|
||||
|
||||
unlocks.write(AchievementUnlockedEvent(record.clone()));
|
||||
}
|
||||
|
||||
unlocks.write(AchievementUnlockedEvent(record.clone()));
|
||||
if achievements_changed
|
||||
&& let Some(target) = &path.0
|
||||
&& let Err(e) = save_achievements_to(target, &achievements.0) {
|
||||
warn!("failed to save achievements: {e}");
|
||||
}
|
||||
|
||||
if progress_changed
|
||||
&& let Some(target) = &progress_path.0
|
||||
&& let Err(e) = save_progress_to(target, &progress.0) {
|
||||
warn!("failed to save progress after reward: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
if achievements_changed
|
||||
&& let Some(target) = &path.0
|
||||
&& let Err(e) = save_achievements_to(target, &achievements.0) {
|
||||
warn!("failed to save achievements: {e}");
|
||||
}
|
||||
|
||||
if progress_changed
|
||||
&& let Some(target) = &progress_path.0
|
||||
&& let Err(e) = save_progress_to(target, &progress.0) {
|
||||
warn!("failed to save progress after reward: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Cinephile unlock observer.
|
||||
@@ -391,6 +389,7 @@ fn toggle_achievements_screen(
|
||||
achievements: Res<AchievementsResource>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
screens: Query<Entity, With<AchievementsScreen>>,
|
||||
other_modal_scrims: Query<(), (With<ModalScrim>, Without<AchievementsScreen>)>,
|
||||
) {
|
||||
let button_clicked = requests.read().count() > 0;
|
||||
if !keys.just_pressed(KeyCode::KeyA) && !button_clicked {
|
||||
@@ -398,7 +397,7 @@ fn toggle_achievements_screen(
|
||||
}
|
||||
if let Ok(entity) = screens.single() {
|
||||
commands.entity(entity).despawn();
|
||||
} else {
|
||||
} else if other_modal_scrims.is_empty() {
|
||||
spawn_achievements_screen(&mut commands, &achievements.0, font_res.as_deref());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,19 +45,29 @@ pub struct AnalyticsPlugin;
|
||||
impl Plugin for AnalyticsPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<AnalyticsResource>()
|
||||
.init_resource::<TokioRuntimeResource>()
|
||||
.add_systems(Startup, init_analytics)
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
react_to_settings_change,
|
||||
on_game_won,
|
||||
on_forfeit,
|
||||
on_new_game,
|
||||
on_achievement_unlocked,
|
||||
tick_flush_timer,
|
||||
),
|
||||
);
|
||||
|
||||
// Build the shared Tokio runtime; skip network flush systems if the OS
|
||||
// refuses to create threads (resource-limited / sandboxed environments).
|
||||
match TokioRuntimeResource::new() {
|
||||
Ok(rt) => {
|
||||
app.insert_resource(rt).add_systems(
|
||||
Update,
|
||||
(on_game_won, on_forfeit, tick_flush_timer),
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
bevy::log::warn!("analytics_plugin: Tokio runtime unavailable — analytics flush disabled: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,17 @@ const TIME_ATTACK_TOAST_SECS: f32 = 5.0;
|
||||
const CHALLENGE_TOAST_SECS: f32 = 3.0;
|
||||
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).
|
||||
///
|
||||
/// Sourced from `ui_theme::MOTION_CASCADE_STAGGER_SECS` so all motion timing
|
||||
@@ -247,6 +258,11 @@ fn advance_card_anims(
|
||||
anim.delay = (anim.delay - dt).max(0.0);
|
||||
continue;
|
||||
}
|
||||
if anim.duration <= 0.0 {
|
||||
transform.translation = anim.target;
|
||||
commands.entity(entity).remove::<CardAnim>();
|
||||
continue;
|
||||
}
|
||||
anim.elapsed += dt;
|
||||
let t = (anim.elapsed / anim.duration).min(1.0);
|
||||
// Curved interpolation using `MotionCurve::SmoothSnap` (cubic ease-out
|
||||
@@ -254,7 +270,11 @@ fn advance_card_anims(
|
||||
// shared `CardAnim` struct stays a simple linear-tween container — the
|
||||
// upgrade is one extra `sample_curve` call per advancing animation.
|
||||
let s = sample_curve(MotionCurve::SmoothSnap, t);
|
||||
transform.translation = anim.start.lerp(anim.target, s);
|
||||
let mut pos = anim.start.lerp(anim.target, s);
|
||||
// Elevate z during transit so the moving card always renders in front
|
||||
// of any card already resting at the destination position.
|
||||
pos.z = anim.target.z + CARD_ANIM_Z_LIFT;
|
||||
transform.translation = pos;
|
||||
if t >= 1.0 {
|
||||
transform.translation = anim.target;
|
||||
commands.entity(entity).remove::<CardAnim>();
|
||||
|
||||
@@ -13,6 +13,7 @@ use bevy::prelude::*;
|
||||
use crate::audio_plugin::{AudioState, SoundLibrary};
|
||||
use crate::events::{MoveRequestEvent, StateChangedEvent};
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::pause_plugin::PausedResource;
|
||||
use crate::resources::GameStateResource;
|
||||
|
||||
/// Volume amplitude used for the auto-complete activation chime.
|
||||
@@ -72,9 +73,14 @@ fn detect_auto_complete(
|
||||
if game.0.is_auto_completable && !state.active {
|
||||
state.active = true;
|
||||
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.
|
||||
@@ -106,11 +112,15 @@ fn drive_auto_complete(
|
||||
mut state: ResMut<AutoCompleteState>,
|
||||
game: Res<GameStateResource>,
|
||||
time: Res<Time>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
mut moves: MessageWriter<MoveRequestEvent>,
|
||||
) {
|
||||
if !state.active {
|
||||
return;
|
||||
}
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.cooldown -= time.delta_secs();
|
||||
if state.cooldown > 0.0 {
|
||||
|
||||
@@ -48,10 +48,21 @@ pub struct AvatarPlugin;
|
||||
impl Plugin for AvatarPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_message::<AvatarFetchEvent>()
|
||||
.init_resource::<TokioRuntimeResource>()
|
||||
.init_resource::<AvatarResource>()
|
||||
.init_resource::<PendingAvatarTask>()
|
||||
.add_systems(Update, (handle_avatar_fetch, poll_avatar_task));
|
||||
.add_systems(Update, poll_avatar_task);
|
||||
|
||||
// Build the shared Tokio runtime; skip avatar download if the OS
|
||||
// refuses to create threads (resource-limited / sandboxed environments).
|
||||
match TokioRuntimeResource::new() {
|
||||
Ok(rt) => {
|
||||
app.insert_resource(rt)
|
||||
.add_systems(Update, handle_avatar_fetch);
|
||||
}
|
||||
Err(e) => {
|
||||
bevy::log::warn!("avatar_plugin: Tokio runtime unavailable — avatar fetch disabled: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -142,6 +142,13 @@ impl Plugin for CardAnimationPlugin {
|
||||
update_frame_time_diagnostics,
|
||||
// Advance active 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.
|
||||
advance_animation_chains,
|
||||
// Interaction visuals (run after animation for final positions).
|
||||
|
||||
@@ -41,7 +41,9 @@ use crate::ui_theme::{
|
||||
};
|
||||
|
||||
/// Fraction of card height used as vertical offset between face-up tableau cards.
|
||||
pub const TABLEAU_FAN_FRAC: f32 = 0.25;
|
||||
/// Must match `layout::TABLEAU_FAN_FRAC` so the initial layout and the first
|
||||
/// dynamic update from `update_tableau_fan_frac` produce identical spacing.
|
||||
pub const TABLEAU_FAN_FRAC: f32 = 0.18;
|
||||
|
||||
/// Per-card vertical step for face-down tableau cards, as a fraction of
|
||||
/// card height. Smaller than [`TABLEAU_FAN_FRAC`] because face-down cards
|
||||
@@ -51,18 +53,22 @@ pub const TABLEAU_FAN_FRAC: f32 = 0.25;
|
||||
/// renderer creates a visible offset between the card face and where
|
||||
/// clicks land.
|
||||
///
|
||||
/// Matches `layout::TABLEAU_FACEDOWN_FAN_FRAC` (0.20). Both constants must
|
||||
/// Matches `layout::TABLEAU_FACEDOWN_FAN_FRAC` (0.14). Both constants must
|
||||
/// stay in sync; the layout constant drives the adaptive LayoutResource value
|
||||
/// used at runtime, while this one is the minimum floor used by
|
||||
/// `update_tableau_fan_frac` when computing proportional updates.
|
||||
pub const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.20;
|
||||
pub const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.14;
|
||||
|
||||
/// Fraction of card height used as a tiny offset between stacked cards in
|
||||
/// non-tableau piles, so stacking is visible. Public so other plugins
|
||||
/// (e.g. input_plugin's drag-rejection tween) can compute the resting
|
||||
/// `Transform.translation.z` for a card at a given stack index without
|
||||
/// drifting from the value used by [`card_positions`].
|
||||
pub const STACK_FAN_FRAC: f32 = 0.003;
|
||||
// Must exceed the highest child local-z of any card entity (0.02 for the
|
||||
// Android corner label) so every card's sprite covers all children of the
|
||||
// card below it. Raising from 0.003 → 0.025 fixes corner labels on
|
||||
// foundation piles bleeding through when a 2 sits on an Ace.
|
||||
pub const STACK_FAN_FRAC: f32 = 0.025;
|
||||
|
||||
/// Font size as a fraction of card width.
|
||||
const FONT_SIZE_FRAC: f32 = 0.28;
|
||||
@@ -178,8 +184,8 @@ pub struct CardLabel;
|
||||
/// readable at phone scale. Only exists when `CardImageSet` is present
|
||||
/// (the fallback solid-colour path uses a plain `CardLabel` instead).
|
||||
#[cfg(target_os = "android")]
|
||||
#[derive(Component, Debug, Clone, Copy)]
|
||||
struct AndroidCornerLabel;
|
||||
#[derive(Component, Debug, Clone)]
|
||||
struct AndroidCornerLabel(pub String);
|
||||
|
||||
/// Solid-colour background sprite behind [`AndroidCornerLabel`].
|
||||
///
|
||||
@@ -707,15 +713,20 @@ fn sync_cards(
|
||||
.map(|c| c.id)
|
||||
};
|
||||
|
||||
// Map card_id -> (Entity, current_translation, has_card_animation) for
|
||||
// in-place updates. The `has_card_animation` flag lets `update_card_entity`
|
||||
// skip the snap/slide path on cards that are already being driven by a
|
||||
// curve-based `CardAnimation` tween (e.g. the drag-rejection return tween
|
||||
// — see `input_plugin::end_drag`). Otherwise the StateChangedEvent that
|
||||
// accompanies a rejection would race the tween and the card would jump.
|
||||
let mut existing: HashMap<u32, (Entity, Vec3, bool)> = HashMap::new();
|
||||
// Map card_id -> (Entity, current_translation, anim_end) for in-place
|
||||
// updates. `anim_end` is `Some(end_xy)` when a curve-based `CardAnimation`
|
||||
// is currently driving the card (e.g. a drag-rejection return tween).
|
||||
//
|
||||
// In the position loop below we compare `anim_end` against the new game-
|
||||
// state target position to decide whether to honour or cancel the tween:
|
||||
// • end ≈ target → animation is still heading to the right place; let
|
||||
// it finish (skip the snap/slide path).
|
||||
// • end ≠ target → the game state has changed (e.g. a new game started
|
||||
// while the win-cascade was mid-flight); cancel the
|
||||
// stale `CardAnimation` and apply the new position.
|
||||
let mut existing: HashMap<u32, (Entity, Vec3, Option<Vec2>)> = HashMap::new();
|
||||
for (entity, marker, transform, anim) in entities.iter() {
|
||||
existing.insert(marker.card_id, (entity, transform.translation, anim.is_some()));
|
||||
existing.insert(marker.card_id, (entity, transform.translation, anim.map(|a| a.end)));
|
||||
}
|
||||
|
||||
let live_ids: HashSet<u32> = positions.iter().map(|(c, _, _)| c.id).collect();
|
||||
@@ -732,7 +743,19 @@ fn sync_cards(
|
||||
// behind the incoming top card during the draw slide animation.
|
||||
for (card, position, z) in positions {
|
||||
let entity = match existing.get(&card.id) {
|
||||
Some(&(entity, cur, has_anim)) => {
|
||||
Some(&(entity, cur, anim_end)) => {
|
||||
// If a CardAnimation is in flight, check whether its destination
|
||||
// still matches the game-state target. If the game moved the card
|
||||
// elsewhere (e.g. new game started during a win-cascade scatter),
|
||||
// cancel the stale tween so the card snaps/slides to its new home.
|
||||
let has_anim = match anim_end {
|
||||
Some(end_xy) if (end_xy - position).length() > 2.0 => {
|
||||
commands.entity(entity).remove::<CardAnimation>();
|
||||
false
|
||||
}
|
||||
Some(_) => true,
|
||||
None => false,
|
||||
};
|
||||
update_card_entity(
|
||||
&mut commands, entity, card, position, z, layout,
|
||||
slide_secs, back_colour, color_blind, high_contrast, cur, has_anim, card_images, selected_back, font_handle,
|
||||
@@ -1124,11 +1147,13 @@ fn add_android_corner_label(
|
||||
let bg_w = font_size * 2.0;
|
||||
let bg_h = font_size * 1.25;
|
||||
|
||||
// Solid background that hides the card art's small corner label.
|
||||
// Background covers the PNG's baked-in small corner text (top-left).
|
||||
// Classic PNG cards have a white face, so the background must be white too.
|
||||
// (CARD_FACE_COLOUR is the Terminal theme's dark face colour — wrong here.)
|
||||
parent.spawn((
|
||||
AndroidCornerBg,
|
||||
Sprite {
|
||||
color: CARD_FACE_COLOUR,
|
||||
color: Color::WHITE,
|
||||
custom_size: Some(Vec2::new(bg_w, bg_h)),
|
||||
..default()
|
||||
},
|
||||
@@ -1138,20 +1163,51 @@ fn add_android_corner_label(
|
||||
0.015,
|
||||
),
|
||||
));
|
||||
// Cover the matching rotated baked-in text at the bottom-right corner.
|
||||
parent.spawn((
|
||||
AndroidCornerBg,
|
||||
Sprite {
|
||||
color: Color::WHITE,
|
||||
custom_size: Some(Vec2::new(bg_w, bg_h)),
|
||||
..default()
|
||||
},
|
||||
Transform::from_xyz(
|
||||
card_size.x / 2.0 - inset - bg_w / 2.0,
|
||||
-card_size.y / 2.0 + inset + bg_h / 2.0,
|
||||
0.015,
|
||||
),
|
||||
));
|
||||
|
||||
// Large rank+suit text drawn on top of the background. FiraMono must be
|
||||
// wired here explicitly — the suit glyphs (U+2660–U+2666) are not in
|
||||
// Bevy's built-in font and render as a coloured rectangle without it.
|
||||
//
|
||||
// Classic PNG cards have a white face: red suits stay the same saturated
|
||||
// red, but black suits must use a dark colour (CARD_FACE_COLOUR ≈ #1a1a1a)
|
||||
// rather than the near-white BLACK_SUIT_COLOUR designed for the dark
|
||||
// Terminal theme background.
|
||||
let text_col = if card.suit.is_red() {
|
||||
if color_blind {
|
||||
RED_SUIT_COLOUR_CBM
|
||||
} else if high_contrast {
|
||||
RED_SUIT_COLOUR_HC
|
||||
} else {
|
||||
RED_SUIT_COLOUR
|
||||
}
|
||||
} else {
|
||||
CARD_FACE_COLOUR
|
||||
};
|
||||
let label_text = mobile_label_for(card);
|
||||
parent.spawn((
|
||||
AndroidCornerLabel,
|
||||
AndroidCornerLabel(label_text.clone()),
|
||||
CardLabel,
|
||||
Text2d::new(mobile_label_for(card)),
|
||||
Text2d::new(label_text),
|
||||
TextFont {
|
||||
font: font_handle.cloned().unwrap_or_default(),
|
||||
font_size,
|
||||
..default()
|
||||
},
|
||||
TextColor(text_colour(card, color_blind, high_contrast)),
|
||||
TextColor(text_col),
|
||||
Anchor::TOP_LEFT,
|
||||
Transform::from_xyz(
|
||||
-card_size.x / 2.0 + inset,
|
||||
@@ -2089,7 +2145,7 @@ fn resize_cards_in_place(
|
||||
fn resize_android_corner_labels(
|
||||
layout: Res<LayoutResource>,
|
||||
card_images: Option<Res<CardImageSet>>,
|
||||
mut text_query: Query<(&mut TextFont, &mut Transform), With<AndroidCornerLabel>>,
|
||||
mut text_query: Query<(&AndroidCornerLabel, &mut Text2d, &mut TextFont, &mut Transform)>,
|
||||
mut bg_query: Query<
|
||||
(&mut Sprite, &mut Transform),
|
||||
(With<AndroidCornerBg>, Without<AndroidCornerLabel>),
|
||||
@@ -2105,7 +2161,8 @@ fn resize_android_corner_labels(
|
||||
let text_x = -layout.0.card_size.x / 2.0 + inset;
|
||||
let text_y = layout.0.card_size.y / 2.0 - inset;
|
||||
|
||||
for (mut font, mut transform) in text_query.iter_mut() {
|
||||
for (label, mut text2d, mut font, mut transform) in text_query.iter_mut() {
|
||||
text2d.0 = label.0.clone();
|
||||
font.font_size = font_size;
|
||||
transform.translation.x = text_x;
|
||||
transform.translation.y = text_y;
|
||||
|
||||
@@ -382,8 +382,8 @@ fn update_drop_target_overlays(
|
||||
/// for everything else it is card-sized. Replicated here rather than
|
||||
/// imported because `pile_drop_rect` is private to `input_plugin` and
|
||||
/// this overlay is the only other consumer.
|
||||
fn drop_overlay_rect(pile: &PileType, layout: &Layout, game: &GameState) -> (Vec2, Vec2) {
|
||||
let centre = layout.pile_positions[pile];
|
||||
fn drop_overlay_rect(pile: &PileType, layout: &Layout, game: &GameState) -> Option<(Vec2, Vec2)> {
|
||||
let centre = layout.pile_positions.get(pile).copied()?;
|
||||
if matches!(pile, PileType::Tableau(_)) {
|
||||
let card_count = game.piles.get(pile).map_or(0, |p| p.cards.len());
|
||||
if card_count > 1 {
|
||||
@@ -393,13 +393,13 @@ fn drop_overlay_rect(pile: &PileType, layout: &Layout, game: &GameState) -> (Vec
|
||||
let bottom_edge = bottom_card_centre_y - layout.card_size.y / 2.0;
|
||||
let span_height = top_edge - bottom_edge;
|
||||
let new_centre_y = (top_edge + bottom_edge) / 2.0;
|
||||
return (
|
||||
return Some((
|
||||
Vec2::new(centre.x, new_centre_y),
|
||||
Vec2::new(layout.card_size.x, span_height),
|
||||
);
|
||||
));
|
||||
}
|
||||
}
|
||||
(centre, layout.card_size)
|
||||
Some((centre, layout.card_size))
|
||||
}
|
||||
|
||||
/// Spawns one overlay parent (fill) plus four edge sprites (outline) at
|
||||
@@ -410,7 +410,10 @@ fn spawn_drop_target_overlay(
|
||||
layout: &Layout,
|
||||
game: &GameState,
|
||||
) {
|
||||
let (centre, size) = drop_overlay_rect(pile, layout, game);
|
||||
let Some((centre, size)) = drop_overlay_rect(pile, layout, game) else {
|
||||
warn!("drop_overlay_rect: pile {pile:?} not in layout, skipping overlay");
|
||||
return;
|
||||
};
|
||||
let edge = DROP_TARGET_OUTLINE_PX;
|
||||
|
||||
commands
|
||||
|
||||
@@ -210,10 +210,15 @@ impl Plugin for FeedbackAnimPlugin {
|
||||
start_shake_anim.after(GameMutation),
|
||||
tick_shake_anim,
|
||||
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,
|
||||
start_deal_anim.after(GameMutation),
|
||||
start_foundation_flourish.after(GameMutation),
|
||||
tick_foundation_flourish,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,8 +31,15 @@ fn load_font(fonts: Option<ResMut<Assets<Font>>>, mut commands: Commands) {
|
||||
// Assets<Font>). FontPlugin in that context is a no-op — consumers
|
||||
// already query `Option<Res<FontResource>>` and degrade cleanly.
|
||||
let Some(mut fonts) = fonts else { return };
|
||||
let font = Font::try_from_bytes(BUNDLED_FONT_BYTES.to_vec())
|
||||
.expect("bundled FiraMono failed to parse — binary is corrupt");
|
||||
let font = match Font::try_from_bytes(BUNDLED_FONT_BYTES.to_vec()) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
// A corrupt embedded font is unusual but should not crash the
|
||||
// process — UI will render without glyphs rather than panicking.
|
||||
warn!("bundled FiraMono failed to parse ({e}); UI text may be invisible");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let handle = fonts.add(font);
|
||||
commands.insert_resource(FontResource(handle));
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ use crate::font_plugin::FontResource;
|
||||
use crate::resources::{DragState, GameStateResource, SyncStatusResource};
|
||||
use crate::ui_modal::{
|
||||
spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button,
|
||||
spawn_modal_header, ButtonVariant,
|
||||
spawn_modal_header, ButtonVariant, ModalScrim,
|
||||
};
|
||||
use crate::ui_theme;
|
||||
|
||||
@@ -202,6 +202,8 @@ impl Plugin for GamePlugin {
|
||||
.add_message::<FoundationCompletedEvent>()
|
||||
.add_message::<InfoToastEvent>()
|
||||
.add_message::<AppLifecycle>()
|
||||
// add_message is idempotent; SettingsPlugin also registers this.
|
||||
.add_message::<crate::settings_plugin::SettingsChangedEvent>()
|
||||
.add_systems(
|
||||
Update,
|
||||
poll_pending_new_game_seed.before(GameMutation),
|
||||
@@ -228,6 +230,7 @@ impl Plugin for GamePlugin {
|
||||
// GameMutation flow.
|
||||
.add_systems(Update, spawn_restore_prompt_if_pending)
|
||||
.add_systems(Update, handle_restore_prompt.before(GameMutation))
|
||||
.add_systems(Update, sync_settings_to_game.before(GameMutation))
|
||||
.init_resource::<AutoSaveTimer>()
|
||||
.add_systems(Update, tick_elapsed_time)
|
||||
.add_systems(Update, auto_save_game_state)
|
||||
@@ -235,6 +238,23 @@ impl Plugin for GamePlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// Forwards `take_from_foundation` from [`SettingsResource`] to the live
|
||||
/// [`GameStateResource`] every time [`SettingsChangedEvent`] fires.
|
||||
///
|
||||
/// This covers two cases that the new-game path misses:
|
||||
/// 1. The initial settings load at startup: saves on disk default to `false`
|
||||
/// but `Settings` defaults to `true`; the event fires once when the
|
||||
/// settings file is first read.
|
||||
/// 2. A user toggling the setting mid-session in the Settings panel.
|
||||
fn sync_settings_to_game(
|
||||
mut events: MessageReader<crate::settings_plugin::SettingsChangedEvent>,
|
||||
mut game: ResMut<GameStateResource>,
|
||||
) {
|
||||
for ev in events.read() {
|
||||
game.0.take_from_foundation = ev.0.take_from_foundation;
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure, testable helper. Updates `elapsed_seconds` and drains the
|
||||
/// fractional accumulator into whole-second ticks. No-op when `is_won`.
|
||||
pub fn advance_elapsed(
|
||||
@@ -411,6 +431,7 @@ fn handle_new_game(
|
||||
game_over_screens: Query<Entity, With<GameOverScreen>>,
|
||||
layout: Option<Res<crate::layout::LayoutResource>>,
|
||||
mut card_transforms: Query<&mut Transform, With<crate::card_plugin::CardEntity>>,
|
||||
scrims: Query<(), With<ModalScrim>>,
|
||||
) {
|
||||
for ev in new_game.read() {
|
||||
// If an active game is in progress, intercept and show a confirm dialog.
|
||||
@@ -420,8 +441,12 @@ fn handle_new_game(
|
||||
// duplicates) or if the event itself was already confirmed by the
|
||||
// player pressing Y on the modal — without the `confirmed` check the
|
||||
// modal would be respawned the frame after the despawn flushes.
|
||||
// Also skip if any other modal scrim is currently open (global guard).
|
||||
let confirm_already_open = !confirm_screens.is_empty();
|
||||
if needs_confirm && !confirm_already_open && !ev.confirmed {
|
||||
if !scrims.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Despawn any stale game-over overlay before showing confirm dialog.
|
||||
for entity in &game_over_screens {
|
||||
commands.entity(entity).despawn();
|
||||
@@ -556,10 +581,14 @@ fn spawn_restore_prompt_if_pending(
|
||||
splash: Query<(), With<crate::splash_plugin::SplashRoot>>,
|
||||
existing: Query<(), With<RestorePromptScreen>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
scrims: Query<(), With<ModalScrim>>,
|
||||
) {
|
||||
if pending.0.is_none() || !splash.is_empty() || !existing.is_empty() {
|
||||
return;
|
||||
}
|
||||
if !scrims.is_empty() {
|
||||
return;
|
||||
}
|
||||
spawn_modal(
|
||||
&mut commands,
|
||||
RestorePromptScreen,
|
||||
@@ -614,6 +643,7 @@ fn handle_restore_prompt(
|
||||
new_game_buttons: Query<&Interaction, (With<RestoreNewGameButton>, Changed<Interaction>)>,
|
||||
mut pending: ResMut<PendingRestoredGame>,
|
||||
mut game: ResMut<GameStateResource>,
|
||||
settings: Option<Res<crate::settings_plugin::SettingsResource>>,
|
||||
mut changed: MessageWriter<StateChangedEvent>,
|
||||
mut new_game: MessageWriter<NewGameRequestEvent>,
|
||||
mut launch_home_shown: Option<ResMut<crate::home_plugin::LaunchHomeShown>>,
|
||||
@@ -639,6 +669,10 @@ fn handle_restore_prompt(
|
||||
let resolved = if key_continue || click_continue {
|
||||
if let Some(restored) = pending.0.take() {
|
||||
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);
|
||||
}
|
||||
for entity in &screens {
|
||||
@@ -1011,9 +1045,7 @@ pub fn record_replay_on_win(
|
||||
/// previous heuristic incorrectly did (Quat hit this with 4 cards
|
||||
/// remaining and the game just sat there).
|
||||
pub fn has_legal_moves(game: &GameState) -> bool {
|
||||
use solitaire_core::card::Card;
|
||||
use solitaire_core::pile::PileType;
|
||||
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
||||
|
||||
// Drawing from a non-empty stock, and recycling a non-empty waste back to
|
||||
// stock, are always legal moves in standard Klondike (unlimited recycles).
|
||||
@@ -1024,40 +1056,14 @@ pub fn has_legal_moves(game: &GameState) -> bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Stock and waste exhausted — check whether any visible card can be placed.
|
||||
let mut sources: Vec<Card> = Vec::new();
|
||||
// Top waste card (waste is empty here, but included for completeness).
|
||||
if let Some(p) = game.piles.get(&PileType::Waste)
|
||||
&& let Some(top) = p.cards.last()
|
||||
{
|
||||
sources.push(top.clone());
|
||||
}
|
||||
// Any face-up card in a tableau column can be the base of a movable run.
|
||||
for i in 0..7_usize {
|
||||
if let Some(t) = game.piles.get(&PileType::Tableau(i)) {
|
||||
for card in t.cards.iter().filter(|c| c.face_up) {
|
||||
sources.push(card.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for card in &sources {
|
||||
for slot in 0..4_u8 {
|
||||
if let Some(dest) = game.piles.get(&PileType::Foundation(slot))
|
||||
&& can_place_on_foundation(card, dest)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for i in 0..7_usize {
|
||||
if let Some(dest) = game.piles.get(&PileType::Tableau(i))
|
||||
&& can_place_on_tableau(card, dest)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
// Stock and waste both exhausted — delegate to the authoritative move
|
||||
// enumeration in core, which validates tableau sequence structure and
|
||||
// foundation placement correctly. The previous hand-rolled loop only
|
||||
// checked can_place_on_tableau(card, dest) for individual face-up cards
|
||||
// without verifying that the cards above them form a valid alternating run,
|
||||
// causing false positives when a useful-looking card was buried under an
|
||||
// invalid sequence.
|
||||
!game.possible_instructions().is_empty()
|
||||
}
|
||||
|
||||
/// After each `StateChangedEvent`, check if the game has no legal moves.
|
||||
@@ -1075,6 +1081,7 @@ fn check_no_moves(
|
||||
mut already_fired: Local<bool>,
|
||||
game_over_screens: Query<Entity, With<GameOverScreen>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
scrims: Query<(), With<ModalScrim>>,
|
||||
) {
|
||||
// Reset the debounce flag on every state change so if something changes
|
||||
// we re-evaluate on the next state change.
|
||||
@@ -1106,8 +1113,9 @@ fn check_no_moves(
|
||||
let no_moves_msg = "No moves available \u{2014} press D to draw or N for a new game";
|
||||
toast.write(InfoToastEvent(no_moves_msg.to_string()));
|
||||
*already_fired = true;
|
||||
// Only spawn the overlay if one does not already exist.
|
||||
if game_over_screens.is_empty() {
|
||||
// Only spawn the overlay if one does not already exist, and no other
|
||||
// modal scrim is currently open (global ModalScrim guard).
|
||||
if game_over_screens.is_empty() && scrims.is_empty() {
|
||||
spawn_game_over_screen(&mut commands, game.0.score, font_res.as_deref());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ use crate::font_plugin::FontResource;
|
||||
use crate::hud_plugin::ANDROID_HINT_LABEL;
|
||||
use crate::ui_modal::{
|
||||
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
||||
ScrimDismissible,
|
||||
ModalScrim, ScrimDismissible,
|
||||
};
|
||||
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"))]
|
||||
@@ -67,6 +67,7 @@ fn toggle_help_screen(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
mut requests: MessageReader<HelpRequestEvent>,
|
||||
screens: Query<Entity, With<HelpScreen>>,
|
||||
other_modal_scrims: Query<(), (With<ModalScrim>, Without<HelpScreen>)>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
) {
|
||||
// Either F1 or a click on the HUD "Help" button (which fires
|
||||
@@ -77,7 +78,7 @@ fn toggle_help_screen(
|
||||
}
|
||||
if let Ok(entity) = screens.single() {
|
||||
commands.entity(entity).despawn();
|
||||
} else {
|
||||
} else if other_modal_scrims.is_empty() {
|
||||
spawn_help_screen(&mut commands, font_res.as_deref());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ use crate::layout::LayoutSystem;
|
||||
use crate::pause_plugin::PausedResource;
|
||||
use crate::resources::GameStateResource;
|
||||
#[cfg(target_os = "android")]
|
||||
use crate::resources::DragState;
|
||||
use crate::resources::{DragState, GameInputConsumedResource};
|
||||
use crate::selection_plugin::SelectionState;
|
||||
use crate::time_attack_plugin::TimeAttackResource;
|
||||
use crate::ui_focus::{FocusGroup, Focusable};
|
||||
@@ -140,6 +140,12 @@ pub struct HudColumn;
|
||||
#[derive(Component, Debug)]
|
||||
pub struct HudActionBar;
|
||||
|
||||
/// Marker on the text node inside each action-bar button (Android only).
|
||||
/// Used by `resize_action_bar_labels` to update font size on window resize.
|
||||
#[cfg(target_os = "android")]
|
||||
#[derive(Component, Debug)]
|
||||
struct ActionButtonLabel;
|
||||
|
||||
/// Marker on the circular profile-picture button anchored to the
|
||||
/// top-right of the HUD band. Pressing it opens the Profile overlay.
|
||||
/// Shows the server avatar image when loaded; falls back to the player's
|
||||
@@ -489,6 +495,11 @@ impl Plugin for HudPlugin {
|
||||
.after(TouchDragSet::AfterStartDrag)
|
||||
.in_set(TouchDragSet::BeforeEndDrag),
|
||||
);
|
||||
app.add_systems(
|
||||
Update,
|
||||
resize_action_bar_labels
|
||||
.run_if(resource_exists_and_changed::<crate::layout::LayoutResource>),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -843,11 +854,25 @@ fn handle_avatar_button(
|
||||
/// on its own visual edge.
|
||||
fn spawn_action_buttons(
|
||||
font_res: Option<Res<FontResource>>,
|
||||
windows: Query<&Window>,
|
||||
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 {
|
||||
font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(),
|
||||
font_size: TYPE_BODY,
|
||||
font_size: action_font_size,
|
||||
..default()
|
||||
};
|
||||
|
||||
@@ -870,12 +895,12 @@ fn spawn_action_buttons(
|
||||
);
|
||||
#[cfg(not(target_os = "android"))]
|
||||
let labels = (
|
||||
"Menu \u{25BE}",
|
||||
"Menu \u{2193}",
|
||||
"Undo",
|
||||
"Pause",
|
||||
"Help",
|
||||
"Hint",
|
||||
"Modes \u{25BE}",
|
||||
"Modes \u{2193}",
|
||||
"New Game",
|
||||
);
|
||||
|
||||
@@ -964,7 +989,7 @@ fn spawn_action_button<M: Component>(
|
||||
// centred with room to breathe. On desktop, keep the comfortable 48 dp
|
||||
// floor and 8 dp side padding.
|
||||
#[cfg(target_os = "android")]
|
||||
let (pad, min_w, min_h) = (UiRect::axes(Val::Px(4.0), Val::Px(4.0)), Val::Px(44.0), Val::Px(44.0));
|
||||
let (pad, min_w, min_h) = (UiRect::axes(Val::Px(4.0), Val::Px(4.0)), Val::Px(52.0), Val::Px(44.0));
|
||||
#[cfg(not(target_os = "android"))]
|
||||
let (pad, min_w, min_h) = (UiRect::axes(VAL_SPACE_2, VAL_SPACE_2), Val::Px(48.0), Val::Px(48.0));
|
||||
|
||||
@@ -992,6 +1017,9 @@ fn spawn_action_button<M: Component>(
|
||||
HighContrastBorder::with_default(BORDER_SUBTLE),
|
||||
))
|
||||
.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)));
|
||||
if let Some(key) = hotkey {
|
||||
// Hotkey hint rendered as a dim caption next to the label —
|
||||
@@ -2483,6 +2511,32 @@ fn restore_hud_on_modal(
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the action-bar glyph font size for a given logical window width.
|
||||
/// Scales linearly so glyphs remain legible at any phone density.
|
||||
#[cfg(target_os = "android")]
|
||||
fn action_bar_font_size(window_width: f32) -> f32 {
|
||||
// ~1/40 of the window width gives ~22 px on a 900 logical-px phone.
|
||||
// Clamped so it never goes too tiny on narrow viewports or too large
|
||||
// on landscape tablets.
|
||||
(window_width / 40.0).clamp(16.0, 30.0)
|
||||
}
|
||||
|
||||
/// Resizes the glyph text inside every [`ActionButtonLabel`] to match the
|
||||
/// current viewport width whenever [`LayoutResource`] changes (orientation
|
||||
/// change or window resize).
|
||||
#[cfg(target_os = "android")]
|
||||
fn resize_action_bar_labels(
|
||||
layout: Res<crate::layout::LayoutResource>,
|
||||
windows: Query<&Window>,
|
||||
mut labels: Query<&mut TextFont, With<ActionButtonLabel>>,
|
||||
) {
|
||||
let w = windows.iter().next().map_or(layout.0.card_size.x * 7.25, |win| win.width());
|
||||
let new_size = action_bar_font_size(w);
|
||||
for mut font in &mut labels {
|
||||
font.font_size = new_size;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
fn toggle_hud_on_tap(
|
||||
mut touch_events: MessageReader<bevy::input::touch::TouchInput>,
|
||||
@@ -2492,6 +2546,7 @@ fn toggle_hud_on_tap(
|
||||
mut tracker: ResMut<HudTapTracker>,
|
||||
mut hud_vis: ResMut<HudVisibility>,
|
||||
buttons: Query<&Interaction, With<ActionButton>>,
|
||||
mut game_consumed: ResMut<GameInputConsumedResource>,
|
||||
) {
|
||||
use bevy::input::touch::TouchPhase;
|
||||
if !scrims.is_empty() || paused.is_some_and(|p| p.0) {
|
||||
@@ -2502,6 +2557,7 @@ fn toggle_hud_on_tap(
|
||||
for _ in touch_events.read() {}
|
||||
tracker.start_pos = None;
|
||||
tracker.started_on_button = false;
|
||||
game_consumed.0 = false;
|
||||
return;
|
||||
}
|
||||
for event in touch_events.read() {
|
||||
@@ -2515,7 +2571,13 @@ fn toggle_hud_on_tap(
|
||||
buttons.iter().any(|i| *i != Interaction::None);
|
||||
}
|
||||
TouchPhase::Ended if drag.is_idle() => {
|
||||
let on_button = tracker.started_on_button;
|
||||
// Also treat taps where game logic consumed the touch (e.g.
|
||||
// drawing from stock) as "on button" so they don't toggle
|
||||
// the HUD. The flag is set on TouchPhase::Started by the
|
||||
// input system that consumed the tap and must be cleared here
|
||||
// regardless of whether we toggle.
|
||||
let on_button = tracker.started_on_button || game_consumed.0;
|
||||
game_consumed.0 = false;
|
||||
if let Some(start) = tracker.start_pos.take() {
|
||||
if !on_button && (event.position - start).length() < HUD_TAP_SLOP_PX {
|
||||
*hud_vis = match *hud_vis {
|
||||
@@ -2532,6 +2594,7 @@ fn toggle_hud_on_tap(
|
||||
TouchPhase::Canceled => {
|
||||
tracker.start_pos = None;
|
||||
tracker.started_on_button = false;
|
||||
game_consumed.0 = false;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,8 @@ use crate::game_plugin::{ConfirmNewGameScreen, GameMutation, RestorePromptScreen
|
||||
use crate::pause_plugin::PausedResource;
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
use crate::layout::{Layout, LayoutResource};
|
||||
use crate::resources::{DragState, GameStateResource, HintCycleIndex};
|
||||
use crate::replay_playback::ReplayPlaybackState;
|
||||
use crate::resources::{DragState, GameInputConsumedResource, GameStateResource, HintCycleIndex};
|
||||
use crate::selection_plugin::SelectionState;
|
||||
use crate::time_attack_plugin::TimeAttackResource;
|
||||
|
||||
@@ -95,6 +96,7 @@ impl Plugin for InputPlugin {
|
||||
app.init_resource::<HintCycleIndex>()
|
||||
.init_resource::<HintSolverConfig>()
|
||||
.init_resource::<crate::pending_hint::PendingHintTask>()
|
||||
.init_resource::<GameInputConsumedResource>()
|
||||
.add_message::<StartZenRequestEvent>()
|
||||
.add_message::<InfoToastEvent>()
|
||||
.add_message::<ForfeitRequestEvent>()
|
||||
@@ -174,11 +176,20 @@ fn handle_keyboard_core(
|
||||
mut zen_requests: MessageReader<StartZenRequestEvent>,
|
||||
confirm_screens: Query<(), With<ConfirmNewGameScreen>>,
|
||||
restore_prompts: Query<(), With<RestorePromptScreen>>,
|
||||
replay_state: Option<Res<ReplayPlaybackState>>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
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) {
|
||||
ev.undo.write(UndoRequestEvent);
|
||||
}
|
||||
@@ -501,6 +512,7 @@ fn handle_touch_stock_tap(
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
drag: Res<DragState>,
|
||||
mut draw: MessageWriter<DrawRequestEvent>,
|
||||
mut game_consumed: ResMut<GameInputConsumedResource>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
@@ -522,6 +534,7 @@ fn handle_touch_stock_tap(
|
||||
};
|
||||
if point_in_rect(world, stock_pos, layout.0.card_size) {
|
||||
draw.write(DrawRequestEvent);
|
||||
game_consumed.0 = true;
|
||||
break; // one draw per tap frame
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,13 +96,33 @@ const MAX_TABLEAU_CARDS: f32 = 13.0;
|
||||
/// below this band so the HUD doesn't bleed into the play surface.
|
||||
///
|
||||
/// Desktop: 64 px fits the score/moves/time + mode badge rows.
|
||||
/// Android: 80 px gives the same content rows comfortable clearance.
|
||||
/// (Previously 128 px when action buttons lived in the top band; those are
|
||||
/// now in the bottom bar so the larger reserve is no longer needed.)
|
||||
/// Android: 112 px — the HUD column has 4 flex tiers with 3 inter-tier
|
||||
/// gaps (4 px each) plus a SPACE_2 = 8 px top offset. With empty tiers
|
||||
/// still contributing gap height in Bevy's flex layout, the actual HUD
|
||||
/// 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"))]
|
||||
pub const HUD_BAND_HEIGHT: f32 = 64.0;
|
||||
#[cfg(target_os = "android")]
|
||||
pub const HUD_BAND_HEIGHT: f32 = 80.0;
|
||||
pub const HUD_BAND_HEIGHT: f32 = 112.0;
|
||||
|
||||
/// Height of the bottom action-bar (the row of ≡ ← || ? ! M + buttons).
|
||||
///
|
||||
/// The action bar sits *above* the OS gesture/navigation zone, so it is NOT
|
||||
/// covered by `safe_area_bottom`. `compute_layout` adds this constant to
|
||||
/// `safe_area_bottom` before computing the height-based card-size candidate
|
||||
/// and the available tableau height, ensuring the deepest fanned column
|
||||
/// never scrolls behind the button row.
|
||||
///
|
||||
/// Derivation (Android): `min_height 44 px` buttons
|
||||
/// + `padding.top 8 px` + `padding.bottom 8 px` outer bar padding = **60 px**.
|
||||
///
|
||||
/// Desktop: no persistent bottom bar, so 0.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
const BOTTOM_BAR_HEIGHT: f32 = 0.0;
|
||||
#[cfg(target_os = "android")]
|
||||
const BOTTOM_BAR_HEIGHT: f32 = 60.0;
|
||||
|
||||
/// Table background colour (dark green felt).
|
||||
pub const TABLE_COLOUR: [f32; 3] = [0.059, 0.322, 0.196];
|
||||
@@ -123,7 +143,7 @@ pub struct Layout {
|
||||
pub pile_positions: HashMap<PileType, Vec2>,
|
||||
/// Per-step vertical offset fraction for face-up tableau cards, as a
|
||||
/// fraction of `card_size.y`. On height-limited (desktop) windows this
|
||||
/// equals `TABLEAU_FAN_FRAC` (0.25); on width-limited (portrait phone)
|
||||
/// equals `TABLEAU_FAN_FRAC` (0.18); on width-limited (portrait phone)
|
||||
/// windows it expands to fill the available vertical space so the tableau
|
||||
/// stretches to the bottom of the screen. Card rendering (`card_plugin`)
|
||||
/// and hit testing (`input_plugin`) both read from this field so they
|
||||
@@ -187,9 +207,13 @@ 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
|
||||
// largest w that fits gives:
|
||||
// (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 height_denom = 0.5 + (1.0 + fan_factor + VERTICAL_GAP_FRAC) * CARD_ASPECT;
|
||||
let card_width_height_based = (window.y - safe_area_top - safe_area_bottom - band_h).max(0.0) / height_denom;
|
||||
let card_width_height_based = (window.y - safe_area_top - effective_safe_bottom - band_h).max(0.0) / height_denom;
|
||||
|
||||
let card_width = card_width_width_based.min(card_width_height_based);
|
||||
let card_height = card_width * CARD_ASPECT;
|
||||
@@ -238,7 +262,7 @@ pub fn compute_layout(window: Vec2, safe_area_top: f32, safe_area_bottom: f32, h
|
||||
//
|
||||
// avail = distance from the top of the first tableau card to the bottom
|
||||
// margin — i.e. the space available for 12 fan steps.
|
||||
let avail = (tableau_y - (-window.y / 2.0 + safe_area_bottom + h_gap) - card_height / 2.0).max(0.0);
|
||||
let avail = (tableau_y - (-window.y / 2.0 + effective_safe_bottom + h_gap) - card_height / 2.0).max(0.0);
|
||||
let ideal_fan_frac = if card_height > 0.0 {
|
||||
avail / ((MAX_TABLEAU_CARDS - 1.0) * card_height)
|
||||
} else {
|
||||
|
||||
@@ -21,7 +21,7 @@ use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
|
||||
use crate::sync_plugin::SyncProviderResource;
|
||||
use crate::ui_modal::{
|
||||
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
||||
ScrimDismissible,
|
||||
ModalScrim, ScrimDismissible,
|
||||
};
|
||||
use crate::ui_theme::{
|
||||
ACCENT_PRIMARY, BG_ELEVATED, BORDER_SUBTLE, RADIUS_SM, STATE_INFO,
|
||||
@@ -139,6 +139,7 @@ impl Plugin for LeaderboardPlugin {
|
||||
.init_resource::<DisplayNameBuffer>()
|
||||
.add_message::<ToggleLeaderboardRequestEvent>()
|
||||
.add_message::<WarningToastEvent>()
|
||||
.add_message::<InfoToastEvent>()
|
||||
// `MouseWheel` and `KeyboardInput` are emitted by Bevy's input
|
||||
// plugin under `DefaultPlugins`; register them explicitly so all
|
||||
// leaderboard systems run cleanly under `MinimalPlugins` in tests.
|
||||
@@ -714,6 +715,7 @@ fn data_cell(
|
||||
fn handle_set_display_name_button(
|
||||
button_q: Query<&Interaction, (Changed<Interaction>, With<SetDisplayNameButton>)>,
|
||||
existing: Query<(), With<DisplayNameModal>>,
|
||||
other_modal_scrims: Query<(), (With<ModalScrim>, Without<DisplayNameModal>)>,
|
||||
mut commands: Commands,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
@@ -725,6 +727,9 @@ fn handle_set_display_name_button(
|
||||
if !existing.is_empty() {
|
||||
return; // already open
|
||||
}
|
||||
if !other_modal_scrims.is_empty() {
|
||||
return; // Another modal is already visible.
|
||||
}
|
||||
buf.0 = settings
|
||||
.as_ref()
|
||||
.and_then(|s| s.0.leaderboard_display_name.clone())
|
||||
@@ -1159,9 +1164,23 @@ mod tests {
|
||||
.spawn(async { Err::<(), String>("network error".to_string()) });
|
||||
app.world_mut().resource_mut::<OptInTask>().0 = Some(failed_task);
|
||||
|
||||
// Allow the task to complete and be polled.
|
||||
for _ in 0..5 {
|
||||
// Pump until the task is polled or a deadline elapses. A fixed
|
||||
// update count is unreliable under parallel `cargo test --workspace`
|
||||
// load — the AsyncComputeTaskPool background threads can be starved
|
||||
// long enough that 5 updates finish before the task completes.
|
||||
// Mirrors the deadline-loop pattern used in sync_plugin tests.
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
loop {
|
||||
app.update();
|
||||
let msgs = app.world().resource::<Messages<WarningToastEvent>>();
|
||||
let mut cursor = msgs.get_cursor();
|
||||
if cursor.read(msgs).next().is_some() {
|
||||
break;
|
||||
}
|
||||
if std::time::Instant::now() >= deadline {
|
||||
break;
|
||||
}
|
||||
std::thread::yield_now();
|
||||
}
|
||||
|
||||
let msgs = app.world().resource::<Messages<WarningToastEvent>>();
|
||||
@@ -1183,8 +1202,19 @@ mod tests {
|
||||
.spawn(async { Err::<(), String>("network error".to_string()) });
|
||||
app.world_mut().resource_mut::<OptOutTask>().0 = Some(failed_task);
|
||||
|
||||
for _ in 0..5 {
|
||||
// Deadline-bounded pump — see opt_in_error_fires_warning_toast for rationale.
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
loop {
|
||||
app.update();
|
||||
let msgs = app.world().resource::<Messages<WarningToastEvent>>();
|
||||
let mut cursor = msgs.get_cursor();
|
||||
if cursor.read(msgs).next().is_some() {
|
||||
break;
|
||||
}
|
||||
if std::time::Instant::now() >= deadline {
|
||||
break;
|
||||
}
|
||||
std::thread::yield_now();
|
||||
}
|
||||
|
||||
let msgs = app.world().resource::<Messages<WarningToastEvent>>();
|
||||
@@ -1210,8 +1240,22 @@ mod tests {
|
||||
let ok_task = AsyncComputeTaskPool::get().spawn(async { Ok::<(), String>(()) });
|
||||
app.world_mut().resource_mut::<OptInTask>().0 = Some(ok_task);
|
||||
|
||||
for _ in 0..5 {
|
||||
// Deadline-bounded pump — see opt_in_error_fires_warning_toast for rationale.
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
loop {
|
||||
app.update();
|
||||
if app
|
||||
.world()
|
||||
.resource::<SettingsResource>()
|
||||
.0
|
||||
.leaderboard_opted_in
|
||||
{
|
||||
break;
|
||||
}
|
||||
if std::time::Instant::now() >= deadline {
|
||||
break;
|
||||
}
|
||||
std::thread::yield_now();
|
||||
}
|
||||
|
||||
assert!(
|
||||
@@ -1237,8 +1281,22 @@ mod tests {
|
||||
let ok_task = AsyncComputeTaskPool::get().spawn(async { Ok::<(), String>(()) });
|
||||
app.world_mut().resource_mut::<OptOutTask>().0 = Some(ok_task);
|
||||
|
||||
for _ in 0..5 {
|
||||
// Deadline-bounded pump — see opt_in_error_fires_warning_toast for rationale.
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
loop {
|
||||
app.update();
|
||||
if !app
|
||||
.world()
|
||||
.resource::<SettingsResource>()
|
||||
.0
|
||||
.leaderboard_opted_in
|
||||
{
|
||||
break;
|
||||
}
|
||||
if std::time::Instant::now() >= deadline {
|
||||
break;
|
||||
}
|
||||
std::thread::yield_now();
|
||||
}
|
||||
|
||||
assert!(
|
||||
|
||||
@@ -138,12 +138,13 @@ fn handle_open_dialog(
|
||||
mut requests: MessageReader<StartPlayBySeedRequestEvent>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
existing: Query<(), With<PlayBySeedScreen>>,
|
||||
other_scrims: Query<(), (With<crate::ui_modal::ModalScrim>, Without<PlayBySeedScreen>)>,
|
||||
) {
|
||||
if requests.read().count() == 0 {
|
||||
return;
|
||||
}
|
||||
// Guard against double-spawn (e.g. two events in one frame).
|
||||
if !existing.is_empty() {
|
||||
// Guard against double-spawn (e.g. two events in one frame) or stacking over another modal.
|
||||
if !existing.is_empty() || !other_scrims.is_empty() {
|
||||
return;
|
||||
}
|
||||
let font = font_res.as_deref();
|
||||
@@ -411,7 +412,11 @@ fn handle_confirm(
|
||||
new_game.write(NewGameRequestEvent {
|
||||
seed: Some(seed),
|
||||
mode: None,
|
||||
confirmed: false,
|
||||
// The player explicitly clicked Play (or pressed Enter) after typing
|
||||
// a seed — treat this as an affirmative confirmation so the
|
||||
// abandon-current-game dialog is not shown on top of the already-
|
||||
// dismissed seed dialog.
|
||||
confirmed: true,
|
||||
});
|
||||
|
||||
for entity in &screen {
|
||||
@@ -566,7 +571,9 @@ mod tests {
|
||||
assert_eq!(fired.len(), 1);
|
||||
assert_eq!(fired[0].seed, Some(42));
|
||||
assert_eq!(fired[0].mode, None);
|
||||
assert!(!fired[0].confirmed);
|
||||
// confirmed: true — the player explicitly clicked Play, so no
|
||||
// abandon-current-game dialog should appear.
|
||||
assert!(fired[0].confirmed);
|
||||
|
||||
// Dialog should be gone.
|
||||
assert!(!dialog_present(&mut app));
|
||||
|
||||
@@ -473,8 +473,11 @@ fn radial_open_on_long_press(
|
||||
mut state: ResMut<RightClickRadialState>,
|
||||
) {
|
||||
// Guard: only count while a touch is down, uncommitted, and radial is idle.
|
||||
let active_id = drag.active_touch_id;
|
||||
if active_id.is_none() || drag.committed || state.is_active() || paused.is_some_and(|p| p.0) {
|
||||
let Some(active_id) = drag.active_touch_id else {
|
||||
*hold_timer = 0.0;
|
||||
return;
|
||||
};
|
||||
if drag.committed || state.is_active() || paused.is_some_and(|p| p.0) {
|
||||
*hold_timer = 0.0;
|
||||
return;
|
||||
}
|
||||
@@ -487,7 +490,7 @@ fn radial_open_on_long_press(
|
||||
|
||||
// Resolve current touch world position.
|
||||
let Some(touches) = touches else { return };
|
||||
let Some(touch) = touches.iter().find(|t| t.id() == active_id.unwrap()) else {
|
||||
let Some(touch) = touches.iter().find(|t| t.id() == active_id) else {
|
||||
return;
|
||||
};
|
||||
let Some((camera, cam_xf)) = cameras.single().ok() else { return };
|
||||
|
||||
@@ -28,7 +28,7 @@ use chrono::Datelike;
|
||||
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::layout::LayoutResource;
|
||||
use crate::events::{DrawRequestEvent, MoveRequestEvent, UndoRequestEvent};
|
||||
use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent, UndoRequestEvent};
|
||||
use crate::replay_playback::{
|
||||
step_backwards_replay_playback, step_replay_playback, stop_replay_playback,
|
||||
toggle_pause_replay_playback, ReplayPlaybackState,
|
||||
@@ -476,6 +476,7 @@ impl Plugin for ReplayOverlayPlugin {
|
||||
.add_message::<MoveRequestEvent>()
|
||||
.add_message::<DrawRequestEvent>()
|
||||
.add_message::<UndoRequestEvent>()
|
||||
.add_message::<StateChangedEvent>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
@@ -1884,6 +1885,7 @@ fn handle_pause_keyboard(
|
||||
/// resets to 0 on key release so the next fresh press fires
|
||||
/// immediately. This matches the mockup's `[← →] scrub`
|
||||
/// terminology while keeping single-press = single-step semantics.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn handle_arrow_keyboard(
|
||||
keys: Option<Res<ButtonInput<KeyCode>>>,
|
||||
time: Res<Time>,
|
||||
@@ -1892,10 +1894,22 @@ fn handle_arrow_keyboard(
|
||||
mut moves_writer: MessageWriter<MoveRequestEvent>,
|
||||
mut draws_writer: MessageWriter<DrawRequestEvent>,
|
||||
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 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;
|
||||
// held repeats fire when the accumulator crosses the interval.
|
||||
if keys.just_pressed(KeyCode::ArrowRight) {
|
||||
@@ -1911,14 +1925,28 @@ fn handle_arrow_keyboard(
|
||||
hold.right_held_secs = 0.0;
|
||||
}
|
||||
|
||||
// Left (backwards step) — symmetric to the right path.
|
||||
// Left (backwards step) — gate on `back_pending` so at most one undo
|
||||
// is in-flight at a time. The cursor is only decremented inside
|
||||
// `step_backwards_replay_playback`, which also writes `UndoRequestEvent`.
|
||||
// `back_pending` is set after a successful step and cleared above when
|
||||
// `StateChangedEvent` confirms the undo was applied.
|
||||
if keys.just_pressed(KeyCode::ArrowLeft) {
|
||||
step_backwards_replay_playback(&mut state, &mut undo_writer);
|
||||
if !*back_pending {
|
||||
let fired = step_backwards_replay_playback(&mut state, &mut undo_writer);
|
||||
if fired {
|
||||
*back_pending = true;
|
||||
}
|
||||
}
|
||||
hold.left_held_secs = 0.0;
|
||||
} else if keys.pressed(KeyCode::ArrowLeft) {
|
||||
hold.left_held_secs += dt;
|
||||
if hold.left_held_secs >= SCRUB_REPEAT_INTERVAL_SECS {
|
||||
step_backwards_replay_playback(&mut state, &mut undo_writer);
|
||||
if !*back_pending {
|
||||
let fired = step_backwards_replay_playback(&mut state, &mut undo_writer);
|
||||
if fired {
|
||||
*back_pending = true;
|
||||
}
|
||||
}
|
||||
hold.left_held_secs = 0.0;
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -512,6 +512,7 @@ pub struct ReplayPlaybackPlugin;
|
||||
impl Plugin for ReplayPlaybackPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<ReplayPlaybackState>()
|
||||
.add_message::<StateChangedEvent>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
|
||||
@@ -114,6 +114,13 @@ pub struct HintCycleIndex(pub usize);
|
||||
#[derive(Resource, Debug, Clone, Default)]
|
||||
pub struct SettingsScrollPos(pub f32);
|
||||
|
||||
/// Set to `true` by an input system when a touch tap is consumed by game logic
|
||||
/// (e.g. drawing from stock). `toggle_hud_on_tap` checks this flag on
|
||||
/// `TouchPhase::Ended` and skips the HUD visibility toggle when set, then
|
||||
/// resets it to `false` so subsequent taps behave normally.
|
||||
#[derive(Resource, Debug, Clone, Default)]
|
||||
pub struct GameInputConsumedResource(pub bool);
|
||||
|
||||
/// Shared Tokio runtime used by all async-task closures that need HTTP I/O.
|
||||
///
|
||||
/// Bevy's `AsyncComputeTaskPool` uses `async-executor` (not Tokio), so spawned
|
||||
@@ -124,15 +131,18 @@ pub struct SettingsScrollPos(pub f32);
|
||||
#[derive(Resource, Clone)]
|
||||
pub struct TokioRuntimeResource(pub Arc<tokio::runtime::Runtime>);
|
||||
|
||||
impl Default for TokioRuntimeResource {
|
||||
fn default() -> Self {
|
||||
// Building the Tokio runtime is startup-time initialization; failure
|
||||
// here means the OS refused to create threads, which is unrecoverable.
|
||||
impl TokioRuntimeResource {
|
||||
/// Attempts to build the shared multi-threaded Tokio runtime.
|
||||
///
|
||||
/// Returns `Err` if the OS refuses to create worker threads (e.g. resource
|
||||
/// limits on Android). Callers should log the error and disable sync
|
||||
/// features rather than panicking.
|
||||
pub fn new() -> Result<Self, tokio::io::Error> {
|
||||
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(2)
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("failed to build shared Tokio runtime");
|
||||
Self(Arc::new(rt))
|
||||
.build()?;
|
||||
Ok(Self(Arc::new(rt)))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ pub struct SettingsChangedEvent(pub Settings);
|
||||
|
||||
/// Marker on the root Settings panel entity.
|
||||
#[derive(Component, Debug)]
|
||||
struct SettingsPanel;
|
||||
pub struct SettingsPanel;
|
||||
|
||||
/// Marks the `Text` node showing the live SFX volume value.
|
||||
#[derive(Component, Debug)]
|
||||
@@ -510,6 +510,7 @@ fn toggle_settings_screen(
|
||||
fn sync_settings_panel_visibility(
|
||||
screen: Res<SettingsScreen>,
|
||||
panels: Query<Entity, With<SettingsPanel>>,
|
||||
other_modal_scrims: Query<(), (With<ModalScrim>, Without<SettingsPanel>)>,
|
||||
scroll_nodes: Query<&ScrollPosition, With<SettingsScrollNode>>,
|
||||
mut scroll_pos: ResMut<SettingsScrollPos>,
|
||||
mut commands: Commands,
|
||||
@@ -525,7 +526,7 @@ fn sync_settings_panel_visibility(
|
||||
return;
|
||||
}
|
||||
if screen.0 {
|
||||
if panels.is_empty() {
|
||||
if panels.is_empty() && other_modal_scrims.is_empty() {
|
||||
let status_label = sync_status
|
||||
.map_or_else(|| "Status: local only".to_string(), |s| sync_status_label(&s.0));
|
||||
let unlocked_backs = progress
|
||||
@@ -1136,6 +1137,7 @@ fn handle_sync_buttons(
|
||||
mut configure_sync: MessageWriter<SyncConfigureRequestEvent>,
|
||||
mut logout_sync: MessageWriter<SyncLogoutRequestEvent>,
|
||||
mut delete_account: MessageWriter<DeleteAccountRequestEvent>,
|
||||
mut screen: ResMut<SettingsScreen>,
|
||||
) {
|
||||
for (interaction, button) in &interaction_query {
|
||||
if *interaction != Interaction::Pressed {
|
||||
@@ -1143,7 +1145,12 @@ fn handle_sync_buttons(
|
||||
}
|
||||
match button {
|
||||
SettingsButton::SyncNow => { manual_sync.write(ManualSyncRequestEvent); }
|
||||
SettingsButton::ConnectSync => { configure_sync.write(SyncConfigureRequestEvent); }
|
||||
SettingsButton::ConnectSync => {
|
||||
// Close settings before the sync-setup modal opens so the
|
||||
// guard in open_sync_setup_modal doesn't block on our own scrim.
|
||||
screen.0 = false;
|
||||
configure_sync.write(SyncConfigureRequestEvent);
|
||||
}
|
||||
SettingsButton::DisconnectSync => { logout_sync.write(SyncLogoutRequestEvent); }
|
||||
SettingsButton::DeleteAccount => { delete_account.write(DeleteAccountRequestEvent); }
|
||||
_ => {}
|
||||
|
||||
@@ -220,7 +220,13 @@ impl Plugin for StatsPlugin {
|
||||
)
|
||||
.add_systems(
|
||||
Update,
|
||||
handle_forfeit.before(GameMutation),
|
||||
// handle_forfeit must run before update_stats_on_new_game so
|
||||
// the NewGameRequestEvent it emits is not visible to
|
||||
// update_stats_on_new_game in the same frame — otherwise
|
||||
// record_abandoned() fires twice on every forfeit (#21).
|
||||
handle_forfeit
|
||||
.before(GameMutation)
|
||||
.before(update_stats_on_new_game),
|
||||
)
|
||||
.add_systems(Update, toggle_stats_screen.after(GameMutation))
|
||||
.add_systems(Update, handle_stats_close_button)
|
||||
|
||||
@@ -101,7 +101,6 @@ impl SyncPlugin {
|
||||
impl Plugin for SyncPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.insert_resource(SyncProviderResource(self.provider.clone()))
|
||||
.init_resource::<TokioRuntimeResource>()
|
||||
.init_resource::<SyncStatusResource>()
|
||||
.init_resource::<PullTaskResult>()
|
||||
.init_resource::<PullTask>()
|
||||
@@ -109,18 +108,30 @@ impl Plugin for SyncPlugin {
|
||||
.add_message::<ManualSyncRequestEvent>()
|
||||
.add_message::<SyncCompleteEvent>()
|
||||
.add_message::<SyncConfigureRequestEvent>()
|
||||
.add_message::<WarningToastEvent>()
|
||||
.add_systems(Startup, start_pull)
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
poll_pull_result,
|
||||
handle_manual_sync_request,
|
||||
push_replay_on_win,
|
||||
poll_replay_upload_result,
|
||||
),
|
||||
)
|
||||
.add_systems(Last, push_on_exit);
|
||||
.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(
|
||||
Update,
|
||||
(
|
||||
poll_pull_result,
|
||||
handle_manual_sync_request,
|
||||
push_replay_on_win,
|
||||
poll_replay_upload_result,
|
||||
),
|
||||
)
|
||||
.add_systems(Last, push_on_exit);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("sync: failed to create Tokio runtime — network sync disabled: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -52,10 +52,10 @@ use crate::events::{
|
||||
SyncLogoutRequestEvent,
|
||||
};
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::settings_plugin::{SettingsResource, SettingsScreen, SettingsStoragePath};
|
||||
use crate::settings_plugin::{SettingsPanel, SettingsResource, SettingsScreen, SettingsStoragePath};
|
||||
use crate::resources::TokioRuntimeResource;
|
||||
use crate::sync_plugin::SyncProviderResource;
|
||||
use crate::ui_modal::spawn_modal;
|
||||
use crate::ui_modal::{spawn_modal, ModalScrim};
|
||||
use crate::ui_theme::{
|
||||
ACCENT_PRIMARY, BG_ELEVATED, BG_ELEVATED_HI,
|
||||
BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, STATE_DANGER, TEXT_DISABLED,
|
||||
@@ -205,9 +205,14 @@ impl Plugin for SyncSetupPlugin {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Opens the sync-setup modal when `SyncConfigureRequestEvent` is received.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn open_sync_setup_modal(
|
||||
mut events: MessageReader<SyncConfigureRequestEvent>,
|
||||
existing: Query<(), With<SyncSetupScreen>>,
|
||||
// Exclude SettingsPanel: the Connect button closes settings in the same
|
||||
// frame it fires SyncConfigureRequestEvent, but Bevy despawns are deferred
|
||||
// so the settings scrim still exists in the world during this system.
|
||||
other_modal_scrims: Query<(), (With<ModalScrim>, Without<SyncSetupScreen>, Without<SettingsPanel>)>,
|
||||
mut commands: Commands,
|
||||
mut focused: ResMut<SyncFocusedField>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
@@ -219,6 +224,9 @@ fn open_sync_setup_modal(
|
||||
if !existing.is_empty() {
|
||||
return; // Already open.
|
||||
}
|
||||
if !other_modal_scrims.is_empty() {
|
||||
return; // Another modal is already visible.
|
||||
}
|
||||
*focused = SyncFocusedField::Url;
|
||||
spawn_sync_setup_modal(&mut commands, font_res.as_deref());
|
||||
}
|
||||
@@ -301,7 +309,7 @@ fn update_field_borders(
|
||||
fn handle_auth_button(
|
||||
login_q: Query<&Interaction, (Changed<Interaction>, With<SyncLoginButton>)>,
|
||||
register_q: Query<&Interaction, (Changed<Interaction>, With<SyncRegisterButton>)>,
|
||||
fields: Query<(&SyncFieldKind, &SyncFieldBuffer)>,
|
||||
mut fields: Query<(&SyncFieldKind, &mut SyncFieldBuffer)>,
|
||||
rt: Res<TokioRuntimeResource>,
|
||||
mut pending: ResMut<PendingAuthTask>,
|
||||
mut error_nodes: Query<(&mut Text, &mut TextColor), With<SyncAuthError>>,
|
||||
@@ -354,9 +362,10 @@ fn handle_auth_button(
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear error and show busy indicator.
|
||||
for (mut text, _) in &mut error_nodes {
|
||||
text.0 = "Connecting…".to_string();
|
||||
// Clear previous error and show busy indicator.
|
||||
for (mut text, mut color) in &mut error_nodes {
|
||||
text.0 = String::new();
|
||||
color.0 = TEXT_SECONDARY;
|
||||
}
|
||||
for mut vis in &mut busy_nodes {
|
||||
*vis = Visibility::Visible;
|
||||
@@ -387,6 +396,14 @@ fn handle_auth_button(
|
||||
pending.task = Some(task);
|
||||
pending.url = url;
|
||||
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.
|
||||
@@ -540,6 +557,7 @@ fn handle_logout(
|
||||
fn open_delete_confirm_modal(
|
||||
mut events: MessageReader<DeleteAccountRequestEvent>,
|
||||
existing: Query<(), With<DeleteConfirmScreen>>,
|
||||
other_modal_scrims: Query<(), (With<ModalScrim>, Without<DeleteConfirmScreen>)>,
|
||||
mut commands: Commands,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
) {
|
||||
@@ -550,6 +568,9 @@ fn open_delete_confirm_modal(
|
||||
if !existing.is_empty() {
|
||||
return;
|
||||
}
|
||||
if !other_modal_scrims.is_empty() {
|
||||
return; // Another modal is already visible.
|
||||
}
|
||||
spawn_delete_confirm_modal(&mut commands, font_res.as_deref());
|
||||
}
|
||||
|
||||
@@ -675,20 +696,31 @@ fn spawn_sync_setup_modal(commands: &mut Commands, font_res: Option<&FontResourc
|
||||
font_res,
|
||||
);
|
||||
|
||||
// Error / status line.
|
||||
// Error / status line — two distinct children so visibility and
|
||||
// text can be controlled independently.
|
||||
body.spawn(Node {
|
||||
min_height: Val::Px(18.0),
|
||||
flex_direction: FlexDirection::Row,
|
||||
align_items: AlignItems::Center,
|
||||
column_gap: VAL_SPACE_2,
|
||||
..default()
|
||||
})
|
||||
.with_children(|row| {
|
||||
// Busy indicator: shown while the auth task is in flight.
|
||||
row.spawn((
|
||||
SyncAuthError,
|
||||
SyncBusyOverlay,
|
||||
Text::new(String::new()),
|
||||
Text::new("…"),
|
||||
make_font(font_res, TYPE_CAPTION),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
Visibility::Hidden,
|
||||
));
|
||||
// Error / status text: always laid out, empty when idle.
|
||||
row.spawn((
|
||||
SyncAuthError,
|
||||
Text::new(String::new()),
|
||||
make_font(font_res, TYPE_CAPTION),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
});
|
||||
|
||||
// Tab hint — desktop only; no Tab key on Android.
|
||||
|
||||
@@ -182,12 +182,16 @@ fn sync_card_image_set_with_active_theme(
|
||||
mut events: MessageReader<AssetEvent<CardTheme>>,
|
||||
active: Option<Res<ActiveTheme>>,
|
||||
themes: Res<Assets<CardTheme>>,
|
||||
asset_server: Option<Res<AssetServer>>,
|
||||
mut card_image_set: Option<ResMut<CardImageSet>>,
|
||||
mut state_events: MessageWriter<StateChangedEvent>,
|
||||
) {
|
||||
let Some(active) = active else { return };
|
||||
let active_id = active.0.id();
|
||||
|
||||
let mut should_sync = false;
|
||||
|
||||
// Consume asset events — covers the normal first-load path.
|
||||
for ev in events.read() {
|
||||
let id = match ev {
|
||||
AssetEvent::LoadedWithDependencies { id }
|
||||
@@ -198,6 +202,22 @@ fn sync_card_image_set_with_active_theme(
|
||||
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 {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -172,14 +172,15 @@ fn advance_time_attack(
|
||||
paused: Option<Res<crate::pause_plugin::PausedResource>>,
|
||||
path: Option<Res<TimeAttackSessionPath>>,
|
||||
home_screens: Query<(), With<crate::home_plugin::HomeScreen>>,
|
||||
win_overlays: Query<(), With<crate::win_summary_plugin::WinSummaryOverlay>>,
|
||||
) {
|
||||
if !session.active {
|
||||
return;
|
||||
}
|
||||
// Mirrors `tick_elapsed_time`: pause while the launch / mode-picker
|
||||
// Home modal is up so the countdown doesn't burn while the player
|
||||
// is choosing what to play next.
|
||||
if paused.is_some_and(|p| p.0) || !home_screens.is_empty() {
|
||||
// Pause the countdown while Home, the Pause overlay, or the Win Summary
|
||||
// overlay is visible — the player should not lose time while reading results
|
||||
// or navigating menus.
|
||||
if paused.is_some_and(|p| p.0) || !home_screens.is_empty() || !win_overlays.is_empty() {
|
||||
return;
|
||||
}
|
||||
session.remaining_secs -= time.delta_secs();
|
||||
|
||||
@@ -24,6 +24,7 @@ use crate::progress_plugin::ProgressResource;
|
||||
use crate::resources::GameStateResource;
|
||||
use crate::settings_plugin::SettingsResource;
|
||||
use crate::stats_plugin::{StatsResource, StatsUpdate};
|
||||
use crate::ui_modal::ModalScrim;
|
||||
use crate::ui_theme::{
|
||||
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,
|
||||
@@ -507,7 +508,7 @@ fn collect_session_achievements(
|
||||
) {
|
||||
// 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.
|
||||
if new_games.read().last().is_some() {
|
||||
if new_games.read().next().is_some() {
|
||||
session.names.clear();
|
||||
}
|
||||
for ev in unlocks.read() {
|
||||
@@ -538,6 +539,7 @@ fn spawn_win_summary_after_delay(
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
time: Res<Time>,
|
||||
overlays: Query<Entity, With<WinSummaryOverlay>>,
|
||||
other_scrims: Query<(), (With<ModalScrim>, Without<WinSummaryOverlay>)>,
|
||||
mut delay: Local<Option<f32>>,
|
||||
) {
|
||||
// Process new win events.
|
||||
@@ -568,8 +570,8 @@ fn spawn_win_summary_after_delay(
|
||||
*remaining -= time.delta_secs();
|
||||
if *remaining <= 0.0 {
|
||||
*delay = None;
|
||||
// Only spawn if there is no overlay already.
|
||||
if overlays.is_empty() {
|
||||
// Only spawn if no overlay of any kind is already visible.
|
||||
if overlays.is_empty() && other_scrims.is_empty() {
|
||||
// Drain any XpAwardedEvents that arrived this frame but were
|
||||
// not yet consumed by `cache_win_data` (which may run later in
|
||||
// the same schedule). Accumulating here ensures the modal
|
||||
@@ -757,6 +759,7 @@ fn spawn_overlay(
|
||||
commands
|
||||
.spawn((
|
||||
WinSummaryOverlay,
|
||||
ModalScrim,
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: Val::Percent(0.0),
|
||||
@@ -769,6 +772,7 @@ fn spawn_overlay(
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(SCRIM),
|
||||
GlobalZIndex(Z_WIN_CASCADE),
|
||||
ZIndex(Z_WIN_CASCADE),
|
||||
))
|
||||
.with_children(|root| {
|
||||
|
||||
@@ -341,8 +341,6 @@ 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).
|
||||
const AVATAR_MAX_BYTES: usize = 1024 * 1024;
|
||||
|
||||
@@ -361,23 +359,15 @@ pub async fn upload_avatar(
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let ext = if mime.contains("jpeg") || mime.contains("jpg") {
|
||||
"jpg"
|
||||
} else if mime.contains("png") {
|
||||
"png"
|
||||
} else if mime.contains("webp") {
|
||||
"webp"
|
||||
} else if mime.contains("gif") {
|
||||
"gif"
|
||||
} else {
|
||||
return Err(AppError::BadRequest(
|
||||
let ext = match mime.as_str() {
|
||||
"image/jpeg" | "image/jpg" => "jpg",
|
||||
"image/png" => "png",
|
||||
"image/webp" => "webp",
|
||||
"image/gif" => "gif",
|
||||
_ => return Err(AppError::BadRequest(
|
||||
"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 {
|
||||
return Err(AppError::BadRequest("avatar must be ≤ 1 MB".into()));
|
||||
}
|
||||
@@ -390,7 +380,10 @@ pub async fn upload_avatar(
|
||||
// Write to a temp file then atomically rename so concurrent readers never
|
||||
// see a partially-written avatar.
|
||||
std::fs::write(&tmp_path, &body).map_err(|e| AppError::Internal(e.to_string()))?;
|
||||
std::fs::rename(&tmp_path, &path).map_err(|e| AppError::Internal(e.to_string()))?;
|
||||
if let Err(e) = std::fs::rename(&tmp_path, &path) {
|
||||
let _ = std::fs::remove_file(&tmp_path);
|
||||
return Err(AppError::Internal(e.to_string()));
|
||||
}
|
||||
// Remove stale files with other extensions after the atomic rename.
|
||||
for old_ext in &["jpg", "png", "webp", "gif"] {
|
||||
if *old_ext != ext {
|
||||
|
||||
@@ -146,7 +146,6 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
|
||||
.route("/api/account", delete(auth::delete_account))
|
||||
.route("/api/me", get(auth::get_me))
|
||||
.route("/api/me/avatar", put(auth::upload_avatar))
|
||||
.nest_service("/avatars", ServeDir::new("avatars"))
|
||||
.layer(axum_middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
middleware::require_auth,
|
||||
@@ -198,7 +197,8 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
|
||||
.route("/api/daily-challenge", get(challenge::daily_challenge))
|
||||
.route("/api/replays/recent", get(replays::recent))
|
||||
.route("/api/replays/{id}", get(replays::get_by_id))
|
||||
.route("/health", get(health));
|
||||
.route("/health", get(health))
|
||||
.nest_service("/avatars", ServeDir::new("avatars"));
|
||||
|
||||
// Replay web UI: a single HTML page served at `/replays/:id` plus a
|
||||
// ServeDir for the static assets (`web/index.html`, `web/replay.css`,
|
||||
|
||||
@@ -35,7 +35,7 @@ use crate::{error::AppError, middleware::AuthenticatedUser, AppState};
|
||||
/// the desktop client's transitive dependencies.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ReplayHeader {
|
||||
seed: i64,
|
||||
seed: u64,
|
||||
draw_mode: String,
|
||||
mode: String,
|
||||
time_seconds: i64,
|
||||
@@ -94,6 +94,9 @@ pub async fn upload(
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let received_at = Utc::now().to_rfc3339();
|
||||
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!(
|
||||
r#"INSERT INTO replays (
|
||||
@@ -102,7 +105,7 @@ pub async fn upload(
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#,
|
||||
id,
|
||||
user.user_id,
|
||||
header.seed,
|
||||
seed_i64,
|
||||
header.draw_mode,
|
||||
header.mode,
|
||||
header.time_seconds,
|
||||
@@ -116,7 +119,7 @@ pub async fn upload(
|
||||
|
||||
// Update leaderboard best score/time for opted-in users when this replay
|
||||
// beats their existing best. Only classic mode counts for the leaderboard.
|
||||
if header.mode == "classic" {
|
||||
if header.mode == "Classic" {
|
||||
sqlx::query!(
|
||||
r#"UPDATE leaderboard
|
||||
SET best_score = ?,
|
||||
|
||||
@@ -230,6 +230,68 @@ main {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── Resume overlay ──────────────────────────────────────────────────── */
|
||||
|
||||
#resume-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(21, 21, 21, 0.92);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
#resume-overlay.hidden { display: none; }
|
||||
|
||||
.resume-card {
|
||||
background: var(--panel);
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
border-radius: 16px;
|
||||
padding: 40px 48px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.6);
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.resume-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.resume-detail {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.resume-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.resume-actions button {
|
||||
padding: 12px 24px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.resume-actions button.secondary {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255,255,255,0.2);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.resume-actions button.secondary:hover {
|
||||
background: rgba(255,255,255,0.05);
|
||||
}
|
||||
|
||||
/* ── Win overlay ─────────────────────────────────────────────────────── */
|
||||
|
||||
#win-overlay {
|
||||
@@ -293,6 +355,67 @@ main {
|
||||
animation: illegal-shake 320ms ease;
|
||||
}
|
||||
|
||||
/* ── No-moves banner ─────────────────────────────────────────────────── */
|
||||
|
||||
#no-moves-banner {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 900;
|
||||
animation: slide-up 240ms ease;
|
||||
}
|
||||
|
||||
#no-moves-banner.hidden { display: none; }
|
||||
|
||||
.no-moves-card {
|
||||
background: var(--panel);
|
||||
border: 1px solid rgba(255,255,255,0.15);
|
||||
border-radius: 12px;
|
||||
padding: 20px 32px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.7);
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.no-moves-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.no-moves-detail {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.no-moves-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.no-moves-actions button.secondary {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255,255,255,0.2);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.no-moves-actions button.secondary:hover {
|
||||
background: rgba(255,255,255,0.05);
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from { opacity: 0; transform: translateX(-50%) translateY(12px); }
|
||||
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
}
|
||||
|
||||
/* ── Foundation slot suit hints ──────────────────────────────────────────── */
|
||||
|
||||
.slot-hint {
|
||||
|
||||
@@ -56,6 +56,17 @@
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="resume-overlay" class="hidden">
|
||||
<div class="resume-card">
|
||||
<div class="resume-title">Resume Game?</div>
|
||||
<p class="resume-detail">You have an unfinished game saved. Would you like to continue where you left off?</p>
|
||||
<div class="resume-actions">
|
||||
<button id="btn-resume">↩ Resume</button>
|
||||
<button id="btn-resume-new" class="secondary">↺ New Game</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="win-overlay" class="hidden">
|
||||
<div class="win-card">
|
||||
<div class="win-title">You Won!</div>
|
||||
@@ -66,6 +77,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="no-moves-banner" class="hidden">
|
||||
<div class="no-moves-card">
|
||||
<div class="no-moves-title">No Moves Available</div>
|
||||
<p class="no-moves-detail">No legal moves remain. Undo to go back or start a new game.</p>
|
||||
<div class="no-moves-actions">
|
||||
<button id="btn-no-moves-undo">↩ Undo</button>
|
||||
<button id="btn-no-moves-new" class="secondary">↺ New Game</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/web/game.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+108
-14
@@ -69,6 +69,34 @@ function preloadTheme(theme) {
|
||||
preloadTheme("classic");
|
||||
preloadTheme("dark");
|
||||
|
||||
// ── Persistence ──────────────────────────────────────────────────────────────
|
||||
const LS_SAVE_KEY = "fs_game_save";
|
||||
|
||||
function saveState() {
|
||||
if (!game) return;
|
||||
try {
|
||||
const gameState = game.serialize();
|
||||
if (typeof gameState !== "string") return;
|
||||
localStorage.setItem(LS_SAVE_KEY, JSON.stringify({ gameState, elapsedSecs, drawThree }));
|
||||
} catch (e) {
|
||||
// localStorage may be unavailable (private browsing quota, etc.) — never block gameplay.
|
||||
console.warn("fs: save failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
function clearSave() {
|
||||
try { localStorage.removeItem(LS_SAVE_KEY); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function loadSave() {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_SAVE_KEY);
|
||||
if (!raw) return null;
|
||||
const save = JSON.parse(raw);
|
||||
return save?.gameState ? save : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// ── State ────────────────────────────────────────────────────────────────────
|
||||
let game = null;
|
||||
let snap = null; // last rendered GameSnapshot
|
||||
@@ -108,11 +136,12 @@ const btnBoardUndo = document.getElementById("btn-board-undo");
|
||||
const btnNew = document.getElementById("btn-new");
|
||||
const chkDraw3 = document.getElementById("chk-draw3");
|
||||
const btnTheme = document.getElementById("btn-theme");
|
||||
const winOverlay = document.getElementById("win-overlay");
|
||||
const winScore = document.getElementById("win-score");
|
||||
const winMoves = document.getElementById("win-moves");
|
||||
const winTime = document.getElementById("win-time");
|
||||
const btnWinNew = document.getElementById("btn-win-new");
|
||||
const winOverlay = document.getElementById("win-overlay");
|
||||
const winScore = document.getElementById("win-score");
|
||||
const winMoves = document.getElementById("win-moves");
|
||||
const winTime = document.getElementById("win-time");
|
||||
const btnWinNew = document.getElementById("btn-win-new");
|
||||
const noMovesBanner = document.getElementById("no-moves-banner");
|
||||
|
||||
// ── Scale to fit ─────────────────────────────────────────────────────────────
|
||||
// Scales #card-area to fill #board without overflowing either dimension.
|
||||
@@ -138,16 +167,72 @@ async function bootstrap() {
|
||||
await init();
|
||||
syncThemeButton();
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const urlSeed = params.has("seed") ? Number(params.get("seed")) : randomSeed();
|
||||
drawThree = params.has("draw3");
|
||||
chkDraw3.checked = drawThree;
|
||||
|
||||
buildSlots();
|
||||
scaleBoard();
|
||||
window.addEventListener("resize", scaleBoard);
|
||||
startGame(urlSeed);
|
||||
attachHandlers();
|
||||
|
||||
const saved = loadSave();
|
||||
if (saved) {
|
||||
showResumeDialog(saved);
|
||||
} else {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const urlSeed = params.has("seed") ? Number(params.get("seed")) : randomSeed();
|
||||
drawThree = params.has("draw3");
|
||||
chkDraw3.checked = drawThree;
|
||||
startGame(urlSeed);
|
||||
}
|
||||
}
|
||||
|
||||
function showResumeDialog(saved) {
|
||||
const overlay = document.getElementById("resume-overlay");
|
||||
if (overlay) overlay.classList.remove("hidden");
|
||||
|
||||
document.getElementById("btn-resume").onclick = () => {
|
||||
if (overlay) overlay.classList.add("hidden");
|
||||
resumeGame(saved);
|
||||
};
|
||||
document.getElementById("btn-resume-new").onclick = () => {
|
||||
clearSave();
|
||||
if (overlay) overlay.classList.add("hidden");
|
||||
drawThree = false;
|
||||
chkDraw3.checked = false;
|
||||
startGame(randomSeed());
|
||||
};
|
||||
}
|
||||
|
||||
function resumeGame(saved) {
|
||||
let restored;
|
||||
try {
|
||||
restored = SolitaireGame.from_saved(saved.gameState);
|
||||
} catch (e) {
|
||||
console.warn("fs: restore failed, starting new game", e);
|
||||
clearSave();
|
||||
startGame(randomSeed());
|
||||
return;
|
||||
}
|
||||
|
||||
game = restored;
|
||||
drawThree = !!saved.drawThree;
|
||||
elapsedSecs = saved.elapsedSecs || 0;
|
||||
chkDraw3.checked = drawThree;
|
||||
|
||||
const displaySeed = Math.round(game.seed());
|
||||
hudSeed.textContent = `seed ${displaySeed}`;
|
||||
winOverlay.classList.add("hidden");
|
||||
cardEls.clear();
|
||||
board.querySelectorAll(".card, .recycle-label").forEach(el => el.remove());
|
||||
|
||||
const url = new URL(window.location);
|
||||
url.searchParams.set("seed", displaySeed);
|
||||
if (drawThree) url.searchParams.set("draw3", "");
|
||||
else url.searchParams.delete("draw3");
|
||||
history.replaceState(null, "", url);
|
||||
|
||||
const s = game.state();
|
||||
snap = s;
|
||||
render(s);
|
||||
if (!s.is_won) startTimer();
|
||||
}
|
||||
|
||||
function randomSeed() {
|
||||
@@ -300,12 +385,19 @@ function render(s) {
|
||||
board.querySelectorAll(".card.drop-target").forEach(e => e.classList.remove("drop-target"));
|
||||
|
||||
if (s.is_auto_completable && !s.is_won && !acTimer) {
|
||||
stopTimer(); // freeze elapsed time at the moment the player's last move completes
|
||||
acTimer = setInterval(doAutoCompleteStep, 380);
|
||||
}
|
||||
if (s.is_won) {
|
||||
clearSave();
|
||||
stopTimer();
|
||||
if (acTimer) { clearInterval(acTimer); acTimer = null; }
|
||||
if (noMovesBanner) noMovesBanner.classList.add("hidden");
|
||||
showWin(s);
|
||||
} else {
|
||||
saveState();
|
||||
const noMoves = !s.has_moves && !s.is_auto_completable;
|
||||
if (noMovesBanner) noMovesBanner.classList.toggle("hidden", !noMoves);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,12 +435,12 @@ async function submitReplay(s) {
|
||||
const payload = {
|
||||
schema_version: 1,
|
||||
seed: Math.round(game.seed()),
|
||||
draw_mode: drawThree ? "draw_three" : "draw_one",
|
||||
mode: "classic",
|
||||
draw_mode: drawThree ? "DrawThree" : "DrawOne",
|
||||
mode: "Classic",
|
||||
time_seconds: elapsedSecs,
|
||||
final_score: s.score,
|
||||
move_count: s.move_count,
|
||||
recorded_at: new Date().toISOString(),
|
||||
recorded_at: new Date().toISOString().slice(0, 10),
|
||||
moves: [],
|
||||
};
|
||||
try {
|
||||
@@ -391,6 +483,8 @@ function attachHandlers() {
|
||||
btnUndo.addEventListener("click", doUndo);
|
||||
btnBoardUndo.addEventListener("click", doUndo);
|
||||
btnNew.addEventListener("click", () => startGame(randomSeed()));
|
||||
document.getElementById("btn-no-moves-undo")?.addEventListener("click", doUndo);
|
||||
document.getElementById("btn-no-moves-new")?.addEventListener("click", () => startGame(randomSeed()));
|
||||
btnWinNew.addEventListener("click", () => startGame(randomSeed()));
|
||||
chkDraw3.addEventListener("change", () => {
|
||||
drawThree = chkDraw3.checked;
|
||||
|
||||
@@ -40,20 +40,32 @@ export class ReplayPlayer {
|
||||
}
|
||||
/**
|
||||
* Snapshot the current `GameState` as a JS object (see `StateSnapshot`).
|
||||
*
|
||||
* Throws a JS string exception on serialisation failure (should never
|
||||
* occur in practice — `StateSnapshot` contains only primitive types).
|
||||
* @returns {any}
|
||||
*/
|
||||
state() {
|
||||
const ret = wasm.replayplayer_state(this.__wbg_ptr);
|
||||
return ret;
|
||||
if (ret[2]) {
|
||||
throw takeFromExternrefTable0(ret[1]);
|
||||
}
|
||||
return takeFromExternrefTable0(ret[0]);
|
||||
}
|
||||
/**
|
||||
* Apply the next move; returns the post-step snapshot, or `null`
|
||||
* once the move list is exhausted.
|
||||
*
|
||||
* Returns `null` (not an exception) when the replay is finished.
|
||||
* Throws a JS string exception on serialisation failure.
|
||||
* @returns {any}
|
||||
*/
|
||||
step() {
|
||||
const ret = wasm.replayplayer_step(this.__wbg_ptr);
|
||||
return ret;
|
||||
if (ret[2]) {
|
||||
throw takeFromExternrefTable0(ret[1]);
|
||||
}
|
||||
return takeFromExternrefTable0(ret[0]);
|
||||
}
|
||||
/**
|
||||
* 0-indexed position of the next move to apply.
|
||||
@@ -157,11 +169,16 @@ export class SolitaireGame {
|
||||
}
|
||||
/**
|
||||
* Full pile snapshot as a JS object.
|
||||
*
|
||||
* Throws a JS string exception on serialisation failure.
|
||||
* @returns {any}
|
||||
*/
|
||||
state() {
|
||||
const ret = wasm.solitairegame_state(this.__wbg_ptr);
|
||||
return ret;
|
||||
if (ret[2]) {
|
||||
throw takeFromExternrefTable0(ret[1]);
|
||||
}
|
||||
return takeFromExternrefTable0(ret[0]);
|
||||
}
|
||||
/**
|
||||
* Undo the last move. Returns `{ok, error?, snapshot?}`.
|
||||
@@ -180,6 +197,13 @@ function __wbg_get_imports() {
|
||||
const ret = Error(getStringFromWasm0(arg0, arg1));
|
||||
return ret;
|
||||
},
|
||||
__wbg_String_8564e559799eccda: function(arg0, arg1) {
|
||||
const ret = String(arg1);
|
||||
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
|
||||
},
|
||||
__wbg___wbindgen_throw_9c75d47bf9e7731e: function(arg0, arg1) {
|
||||
throw new Error(getStringFromWasm0(arg0, arg1));
|
||||
},
|
||||
|
||||
Binary file not shown.
+89
-21
@@ -108,31 +108,48 @@ fn merge_stats(
|
||||
let merged_games_won = local.games_won.max(remote.games_won);
|
||||
let merged_games_played = local.games_played.max(remote.games_played);
|
||||
|
||||
// Recompute average time from the merged totals. If no wins yet, keep 0.
|
||||
// Carry the average time from whichever side contributed merged_games_won.
|
||||
// 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 {
|
||||
0
|
||||
} else if local.games_won >= remote.games_won {
|
||||
local.avg_time_seconds
|
||||
} else {
|
||||
// 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
|
||||
remote.avg_time_seconds
|
||||
};
|
||||
|
||||
// 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 {
|
||||
games_played: merged_games_played,
|
||||
games_won: merged_games_won,
|
||||
games_lost: local.games_lost.max(remote.games_lost),
|
||||
games_lost: merged_games_lost,
|
||||
win_streak_current: local.win_streak_current.max(remote.win_streak_current),
|
||||
win_streak_best: local.win_streak_best.max(remote.win_streak_best),
|
||||
avg_time_seconds,
|
||||
fastest_win_seconds: local.fastest_win_seconds.min(remote.fastest_win_seconds),
|
||||
lifetime_score: local.lifetime_score.max(remote.lifetime_score),
|
||||
best_single_score: local.best_single_score.max(remote.best_single_score),
|
||||
draw_one_wins: local.draw_one_wins.max(remote.draw_one_wins),
|
||||
draw_three_wins: local.draw_three_wins.max(remote.draw_three_wins),
|
||||
// Take per-mode win counts from whichever side contributed `games_won`
|
||||
// (the side with the higher total). Independent max() calls can push
|
||||
// draw_one_wins + draw_three_wins above games_won when the two sides
|
||||
// have complementary win histories (e.g. local has 20 draw-one wins,
|
||||
// remote has 20 draw-three wins, each with games_won = 20).
|
||||
draw_one_wins: if local.games_won >= remote.games_won {
|
||||
local.draw_one_wins
|
||||
} else {
|
||||
remote.draw_one_wins
|
||||
},
|
||||
draw_three_wins: if local.games_won >= remote.games_won {
|
||||
local.draw_three_wins
|
||||
} else {
|
||||
remote.draw_three_wins
|
||||
},
|
||||
// Per-mode bests. Bests take max; fastest times take a *zero-aware*
|
||||
// min — see [`min_ignore_zero`] for the rationale (0 means "no win
|
||||
// yet" for these fields, unlike the lifetime `fastest_win_seconds`
|
||||
@@ -454,14 +471,28 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stats_games_lost_takes_max() {
|
||||
fn stats_games_lost_derived_from_played_minus_won() {
|
||||
// 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();
|
||||
local.stats.games_played = 20;
|
||||
local.stats.games_won = 8;
|
||||
local.stats.games_lost = 12;
|
||||
let mut remote = default_payload();
|
||||
remote.stats.games_lost = 8;
|
||||
remote.stats.games_played = 15;
|
||||
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);
|
||||
assert_eq!(merged.stats.games_lost, 12);
|
||||
assert_eq!(merged.stats.games_played, 20);
|
||||
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]
|
||||
@@ -487,26 +518,63 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stats_draw_mode_wins_take_max() {
|
||||
fn stats_draw_mode_wins_taken_from_winning_side() {
|
||||
// Both sides have equal games_won (default 0), so local is chosen (>=).
|
||||
// Per-mode counts come entirely from that one side — no cross-side max.
|
||||
let mut local = default_payload();
|
||||
local.stats.games_won = 25;
|
||||
local.stats.draw_one_wins = 20;
|
||||
local.stats.draw_three_wins = 5;
|
||||
let mut remote = default_payload();
|
||||
remote.stats.games_won = 15;
|
||||
remote.stats.draw_one_wins = 15;
|
||||
remote.stats.draw_three_wins = 8;
|
||||
|
||||
// local has more wins, so local's per-mode counts are used.
|
||||
let (merged, _) = merge(&local, &remote);
|
||||
assert_eq!(merged.stats.games_won, 25);
|
||||
assert_eq!(merged.stats.draw_one_wins, 20);
|
||||
assert_eq!(merged.stats.draw_three_wins, 8);
|
||||
assert_eq!(merged.stats.draw_three_wins, 5);
|
||||
assert!(
|
||||
merged.stats.draw_one_wins + merged.stats.draw_three_wins
|
||||
<= merged.stats.games_won,
|
||||
"draw-mode win counts must not exceed total wins"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_stats_draw_mode_wins_do_not_exceed_total() {
|
||||
// local: 20 draw-one wins, 0 draw-three, games_won = 20
|
||||
// remote: 0 draw-one wins, 20 draw-three, games_won = 20
|
||||
// Without the fix, independent max() calls yield draw_one=20, draw_three=20,
|
||||
// games_won=20 — the breakdown sums to 40, double the actual total.
|
||||
let mut local = default_payload();
|
||||
local.stats.games_won = 20;
|
||||
local.stats.draw_one_wins = 20;
|
||||
local.stats.draw_three_wins = 0;
|
||||
|
||||
let mut remote = default_payload();
|
||||
remote.stats.games_won = 20;
|
||||
remote.stats.draw_one_wins = 0;
|
||||
remote.stats.draw_three_wins = 20;
|
||||
|
||||
let (merged, _) = merge(&local, &remote);
|
||||
assert!(
|
||||
merged.stats.draw_one_wins + merged.stats.draw_three_wins <= merged.stats.games_won,
|
||||
"draw-mode win counts must not exceed total wins after merge: \
|
||||
draw_one={}, draw_three={}, games_won={}",
|
||||
merged.stats.draw_one_wins,
|
||||
merged.stats.draw_three_wins,
|
||||
merged.stats.games_won,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stats_avg_time_recomputed_from_merged_totals() {
|
||||
// local: 4 wins averaging 100s each (total = 400s)
|
||||
// remote: 6 wins averaging 200s each (total = 1200s)
|
||||
// merged_games_won = max(4, 6) = 6
|
||||
// best_total = max(400, 1200) = 1200
|
||||
// avg = 1200 / 6 = 200
|
||||
// local: 4 wins averaging 100s each
|
||||
// remote: 6 wins averaging 200s each
|
||||
// merged_games_won = max(4, 6) = 6 → remote contributed the wins
|
||||
// avg_time_seconds must be remote's 200s, not a blend of totals
|
||||
let mut local = default_payload();
|
||||
local.stats.games_won = 4;
|
||||
local.stats.avg_time_seconds = 100;
|
||||
|
||||
@@ -241,6 +241,8 @@ pub struct GameSnapshot {
|
||||
pub move_count: u32,
|
||||
pub is_won: bool,
|
||||
pub is_auto_completable: bool,
|
||||
/// `false` when stock, waste, and all pile-to-pile moves are exhausted.
|
||||
pub has_moves: bool,
|
||||
pub undo_count: u32,
|
||||
/// Number of snapshots currently on the undo stack; 0 means undo is unavailable.
|
||||
pub undo_stack_len: usize,
|
||||
@@ -279,11 +281,17 @@ impl SolitaireGame {
|
||||
.map(|p| p.cards.iter().map(CardSnapshot::from).collect())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let has_moves = {
|
||||
let stock_empty = self.game.piles.get(&PileType::Stock).is_none_or(|p| p.cards.is_empty());
|
||||
let waste_empty = self.game.piles.get(&PileType::Waste).is_none_or(|p| p.cards.is_empty());
|
||||
!stock_empty || !waste_empty || !self.game.possible_instructions().is_empty()
|
||||
};
|
||||
GameSnapshot {
|
||||
score: self.game.score,
|
||||
move_count: self.game.move_count,
|
||||
is_won: self.game.is_won,
|
||||
is_auto_completable: self.game.is_auto_completable,
|
||||
has_moves,
|
||||
undo_count: self.game.undo_count,
|
||||
undo_stack_len: self.game.undo_stack_len(),
|
||||
stock: cards(PileType::Stock),
|
||||
@@ -422,6 +430,30 @@ impl SolitaireGame {
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialise the full game state as a JSON string for `localStorage`.
|
||||
///
|
||||
/// Use [`SolitaireGame::from_saved`] to restore it. The returned string is
|
||||
/// opaque — callers should treat it as a blob and store/restore it verbatim.
|
||||
pub fn serialize(&self) -> Result<String, JsValue> {
|
||||
serde_json::to_string(&self.game)
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))
|
||||
}
|
||||
|
||||
/// Restore a game from a JSON string previously produced by [`SolitaireGame::serialize`].
|
||||
///
|
||||
/// Returns an error string if the JSON is malformed or describes a state
|
||||
/// that can't be deserialised (e.g. from a future schema version).
|
||||
pub fn from_saved(json: &str) -> Result<SolitaireGame, JsValue> {
|
||||
serde_json::from_str::<GameState>(json)
|
||||
.map(|mut game| {
|
||||
// Older saves serialised with take_from_foundation=false (the core default).
|
||||
// The web client has no settings layer, so enforce the standard rule here.
|
||||
game.take_from_foundation = true;
|
||||
SolitaireGame { game }
|
||||
})
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))
|
||||
}
|
||||
|
||||
/// Apply one auto-complete move (only valid when `is_auto_completable`).
|
||||
///
|
||||
/// If no card can go directly to a foundation this step, advances the
|
||||
|
||||
Reference in New Issue
Block a user