Compare commits

..

6 Commits

Author SHA1 Message Date
funman300 40768f3b0a feat(engine): scale action-bar glyph font size dynamically on Android
Android Release / build-apk (push) Successful in 4m15s
The bottom bar's 7 icon buttons (≡ ← || ? ! M +) used TYPE_BODY = 14 px,
a fixed size that is too small on phone screens.

New behaviour:
- `action_bar_font_size(window_width)` returns `(window_width / 40).clamp(16, 30)`,
  giving ~22 px on a 900 logical-px phone and ~16 px on narrow viewports.
- `ActionButtonLabel` marker added to each button's text node (Android only).
- `spawn_action_buttons` reads `Query<&Window>` at startup to apply the
  correct initial size before the first frame renders.
- `resize_action_bar_labels` system re-runs whenever `LayoutResource`
  changes (window resize / orientation change) to keep glyphs in sync.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 14:45:49 -07:00
funman300 2186f55913 fix(engine): fix classic-card corner label colours and HUD-band overlap
Android Release / build-apk (push) Successful in 4m0s
card_plugin: AndroidCornerLabel used CARD_FACE_COLOUR (dark ~#1a1a1a) as
the background and BLACK_SUIT_COLOUR (near-white) for clubs/spades text —
both designed for the Terminal theme. On classic PNG cards (white face),
this produced an ugly dark box with invisible black-suit text. Switch the
corner-label background to Color::WHITE and black-suit text to
CARD_FACE_COLOUR (dark ink on white), matching traditional card printing.

layout: HUD_BAND_HEIGHT on Android raised 80 → 112 px. The HUD column has
4 flex tiers plus 3 inter-tier gaps (4 px each) and a SPACE_2 = 8 px top
offset. With empty tiers still occupying gap height in Bevy's flex layout,
the actual rendered HUD could reach ~80 px, overlapping the top card row
by up to one text line. 112 px provides ~28 px clearance in the common
case (Tiers 1 + 3 visible) and remains workable even when Tier 1 wraps.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 14:34:04 -07:00
funman300 e0f369d322 fix(engine): raise STACK_FAN_FRAC above corner label z to fix foundation pile bleed-through
Android Release / build-apk (push) Successful in 4m37s
Android corner label children sit at local z=0.02; with STACK_FAN_FRAC=0.003
the card below's label (world z=1.02) rendered above the card on top's sprite
(world z=1.003), causing overlapping rank/suit text on foundation piles.
Raising STACK_FAN_FRAC to 0.025 ensures every card sprite covers all children
of the card below it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 14:01:10 -07:00
Gitea CI ea98774ccb chore(deploy): bump image to ea9dd848 [skip ci] 2026-05-19 20:44:38 +00:00
funman300 ea9dd848fd fix(multi): resolve 14 bugs from second comprehensive review
Build and Deploy / build-and-push (push) Successful in 4m2s
Core (solitaire_core):
- fix(scoring): apply -15 penalty for Foundation→Tableau moves when
  take_from_foundation is enabled; update test
- fix(solver): is_won() validates full Ace→King suit sequence, not
  just card count — prevents hint system from emitting invalid paths

Engine — animation / layout:
- fix(animation): guard CardAnim advance against duration=0 to prevent
  NaN-poisoned Transform (analogous to CardAnimation's instant-snap path)
- fix(card_plugin): align TABLEAU_FAN_FRAC (0.25→0.18) and
  TABLEAU_FACEDOWN_FAN_FRAC (0.20→0.14) with layout.rs so the initial
  layout and first dynamic update produce identical fan spacing
- fix(layout): update tableau_fan_frac doc comment from 0.25→0.18

Engine — ECS / modal guards:
- fix(auto_complete): drive_auto_complete now checks PausedResource so
  cooldown does not tick while paused (prevents instant-move on unpause)
- fix(play_by_seed): handle_open_dialog checks global ModalScrim guard
  to prevent stacking over an existing modal
- fix(win_summary): spawn_win_summary_after_delay checks global
  ModalScrim guard; collect_session_achievements uses .next() not
  .last() to avoid draining the new_games stream

Engine — message registration:
- fix(leaderboard): register InfoToastEvent in LeaderboardPlugin::build
  so opt-in/opt-out toasts work under MinimalPlugins
- fix(replay_playback): register StateChangedEvent in
  ReplayPlaybackPlugin::build to prevent panic when used standalone

Security:
- fix(sync_setup): zero password SyncFieldBuffer immediately after
  spawning auth task — credential must not linger in ECS components

Server:
- fix(auth): replace MIME contains-chain with exact match for avatar
  upload; removes illusory starts_with guard and dead ALLOWED_IMAGE_TYPES

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 13:40:32 -07:00
funman300 a328059933 fix(ci): add workflow_dispatch trigger to android-release workflow
Tag-push events are not reliably processed by the self-hosted Gitea
runner. workflow_dispatch with a tag input allows manual triggering
via the Gitea UI or API as a fallback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 13:25:12 -07:00
16 changed files with 161 additions and 49 deletions
+12 -1
View File
@@ -4,6 +4,12 @@ on:
push: push:
tags: tags:
- 'v*' - 'v*'
workflow_dispatch:
inputs:
tag:
description: 'Release tag (e.g. v0.36.2)'
required: true
default: 'v0.36.2'
env: env:
APK_OUT: target/release/apk/ferrous-solitaire.apk APK_OUT: target/release/apk/ferrous-solitaire.apk
@@ -42,7 +48,12 @@ jobs:
- name: Get tag name - name: Get tag name
id: tag 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 - name: Decode release keystore
run: echo "${{ secrets.RELEASE_KEYSTORE_B64 }}" | base64 -d > release.jks run: echo "${{ secrets.RELEASE_KEYSTORE_B64 }}" | base64 -d > release.jks
+1 -1
View File
@@ -355,7 +355,7 @@ Must always be handled explicitly:
* The gesture/navigation bar at the bottom (≈132px physical on common * The gesture/navigation bar at the bottom (≈132px physical on common
devices) is inside the Bevy viewport; use `SafeAreaInsets.bottom` to devices) is inside the Bevy viewport; use `SafeAreaInsets.bottom` to
avoid placing interactive elements in that zone avoid placing interactive elements in that zone
* `HUD_BAND_HEIGHT` is 128px on Android (two-row wrap) vs 64px on desktop; * `HUD_BAND_HEIGHT` is 112px on Android vs 64px on desktop;
layout constants are `#[cfg(target_os = "android")]` gated layout constants are `#[cfg(target_os = "android")]` gated
* JNI calls must use `attach_current_thread_permanently` — not * JNI calls must use `attach_current_thread_permanently` — not
`attach_current_thread` — to avoid detach-on-drop panics `attach_current_thread` — to avoid detach-on-drop panics
+1 -1
View File
@@ -20,4 +20,4 @@ resources:
images: images:
- name: solitaire-server - name: solitaire-server
newName: git.aleshym.co/funman300/solitaire-server newName: git.aleshym.co/funman300/solitaire-server
newTag: 7840ef9e newTag: ea9dd848
+9 -8
View File
@@ -9,9 +9,11 @@ use crate::pile::PileType;
pub fn score_move(from: &PileType, to: &PileType) -> i32 { pub fn score_move(from: &PileType, to: &PileType) -> i32 {
match to { match to {
PileType::Foundation(_) => 10, PileType::Foundation(_) => 10,
PileType::Tableau(_) => { PileType::Tableau(_) => match from {
if matches!(from, PileType::Waste) { 5 } else { 0 } PileType::Waste => 5,
} PileType::Foundation(_) => -15,
_ => 0,
},
_ => 0, _ => 0,
} }
} }
@@ -71,13 +73,12 @@ mod tests {
} }
#[test] #[test]
fn non_waste_to_tableau_scores_zero() { fn foundation_to_tableau_penalises_fifteen() {
// Foundation → Tableau is impossible in practice but must score 0. // Moving a card back off a foundation (take_from_foundation rule) costs -15.
assert_eq!(score_move(&PileType::Foundation(0), &PileType::Tableau(0)), 0); assert_eq!(score_move(&PileType::Foundation(0), &PileType::Tableau(0)), -15);
// Tableau → Tableau (restack) scores 0.
assert_eq!(score_move(&PileType::Tableau(1), &PileType::Tableau(2)), 0);
} }
#[test] #[test]
fn move_to_stock_or_waste_scores_zero() { fn move_to_stock_or_waste_scores_zero() {
// These destinations are illegal moves in practice, but the function // These destinations are illegal moves in practice, but the function
+9 -2
View File
@@ -298,9 +298,16 @@ impl SolverState {
} }
} }
/// True when every foundation slot has 13 cards. /// True when every foundation slot holds a complete Ace-through-King sequence.
fn is_won(&self) -> bool { fn is_won(&self) -> bool {
self.foundation.iter().all(|f| f.len() == 13) self.foundation.iter().all(|pile| {
pile.len() == 13
&& pile[0].rank == crate::card::Rank::Ace
&& pile.windows(2).all(|w| {
w[0].suit == w[1].suit
&& w[1].rank.value() == w[0].rank.value() + 1
})
})
} }
/// Returns the foundation slot that already claims `suit`, or the /// Returns the foundation slot that already claims `suit`, or the
+5
View File
@@ -258,6 +258,11 @@ fn advance_card_anims(
anim.delay = (anim.delay - dt).max(0.0); anim.delay = (anim.delay - dt).max(0.0);
continue; continue;
} }
if anim.duration <= 0.0 {
transform.translation = anim.target;
commands.entity(entity).remove::<CardAnim>();
continue;
}
anim.elapsed += dt; anim.elapsed += dt;
let t = (anim.elapsed / anim.duration).min(1.0); let t = (anim.elapsed / anim.duration).min(1.0);
// Curved interpolation using `MotionCurve::SmoothSnap` (cubic ease-out // Curved interpolation using `MotionCurve::SmoothSnap` (cubic ease-out
@@ -13,6 +13,7 @@ use bevy::prelude::*;
use crate::audio_plugin::{AudioState, SoundLibrary}; use crate::audio_plugin::{AudioState, SoundLibrary};
use crate::events::{MoveRequestEvent, StateChangedEvent}; use crate::events::{MoveRequestEvent, StateChangedEvent};
use crate::game_plugin::GameMutation; use crate::game_plugin::GameMutation;
use crate::pause_plugin::PausedResource;
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
/// Volume amplitude used for the auto-complete activation chime. /// Volume amplitude used for the auto-complete activation chime.
@@ -111,11 +112,15 @@ fn drive_auto_complete(
mut state: ResMut<AutoCompleteState>, mut state: ResMut<AutoCompleteState>,
game: Res<GameStateResource>, game: Res<GameStateResource>,
time: Res<Time>, time: Res<Time>,
paused: Option<Res<PausedResource>>,
mut moves: MessageWriter<MoveRequestEvent>, mut moves: MessageWriter<MoveRequestEvent>,
) { ) {
if !state.active { if !state.active {
return; return;
} }
if paused.is_some_and(|p| p.0) {
return;
}
state.cooldown -= time.delta_secs(); state.cooldown -= time.delta_secs();
if state.cooldown > 0.0 { if state.cooldown > 0.0 {
+31 -7
View File
@@ -41,7 +41,9 @@ use crate::ui_theme::{
}; };
/// Fraction of card height used as vertical offset between face-up tableau cards. /// Fraction of card height used as vertical offset between face-up tableau cards.
pub const TABLEAU_FAN_FRAC: f32 = 0.25; /// Must match `layout::TABLEAU_FAN_FRAC` so the initial layout and the first
/// dynamic update from `update_tableau_fan_frac` produce identical spacing.
pub const TABLEAU_FAN_FRAC: f32 = 0.18;
/// Per-card vertical step for face-down tableau cards, as a fraction of /// Per-card vertical step for face-down tableau cards, as a fraction of
/// card height. Smaller than [`TABLEAU_FAN_FRAC`] because face-down cards /// card height. Smaller than [`TABLEAU_FAN_FRAC`] because face-down cards
@@ -51,18 +53,22 @@ pub const TABLEAU_FAN_FRAC: f32 = 0.25;
/// renderer creates a visible offset between the card face and where /// renderer creates a visible offset between the card face and where
/// clicks land. /// clicks land.
/// ///
/// Matches `layout::TABLEAU_FACEDOWN_FAN_FRAC` (0.20). Both constants must /// Matches `layout::TABLEAU_FACEDOWN_FAN_FRAC` (0.14). Both constants must
/// stay in sync; the layout constant drives the adaptive LayoutResource value /// stay in sync; the layout constant drives the adaptive LayoutResource value
/// used at runtime, while this one is the minimum floor used by /// used at runtime, while this one is the minimum floor used by
/// `update_tableau_fan_frac` when computing proportional updates. /// `update_tableau_fan_frac` when computing proportional updates.
pub const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.20; pub const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.14;
/// Fraction of card height used as a tiny offset between stacked cards in /// Fraction of card height used as a tiny offset between stacked cards in
/// non-tableau piles, so stacking is visible. Public so other plugins /// non-tableau piles, so stacking is visible. Public so other plugins
/// (e.g. input_plugin's drag-rejection tween) can compute the resting /// (e.g. input_plugin's drag-rejection tween) can compute the resting
/// `Transform.translation.z` for a card at a given stack index without /// `Transform.translation.z` for a card at a given stack index without
/// drifting from the value used by [`card_positions`]. /// drifting from the value used by [`card_positions`].
pub const STACK_FAN_FRAC: f32 = 0.003; // Must exceed the highest child local-z of any card entity (0.02 for the
// Android corner label) so every card's sprite covers all children of the
// card below it. Raising from 0.003 → 0.025 fixes corner labels on
// foundation piles bleeding through when a 2 sits on an Ace.
pub const STACK_FAN_FRAC: f32 = 0.025;
/// Font size as a fraction of card width. /// Font size as a fraction of card width.
const FONT_SIZE_FRAC: f32 = 0.28; const FONT_SIZE_FRAC: f32 = 0.28;
@@ -1141,11 +1147,13 @@ fn add_android_corner_label(
let bg_w = font_size * 2.0; let bg_w = font_size * 2.0;
let bg_h = font_size * 1.25; let bg_h = font_size * 1.25;
// Solid background that hides the card art's small corner label. // Background covers the PNG's baked-in small corner text.
// Classic PNG cards have a white face, so the background must be white too.
// (CARD_FACE_COLOUR is the Terminal theme's dark face colour — wrong here.)
parent.spawn(( parent.spawn((
AndroidCornerBg, AndroidCornerBg,
Sprite { Sprite {
color: CARD_FACE_COLOUR, color: Color::WHITE,
custom_size: Some(Vec2::new(bg_w, bg_h)), custom_size: Some(Vec2::new(bg_w, bg_h)),
..default() ..default()
}, },
@@ -1159,6 +1167,22 @@ fn add_android_corner_label(
// Large rank+suit text drawn on top of the background. FiraMono must be // Large rank+suit text drawn on top of the background. FiraMono must be
// wired here explicitly — the suit glyphs (U+2660U+2666) are not in // wired here explicitly — the suit glyphs (U+2660U+2666) are not in
// Bevy's built-in font and render as a coloured rectangle without it. // Bevy's built-in font and render as a coloured rectangle without it.
//
// Classic PNG cards have a white face: red suits stay the same saturated
// red, but black suits must use a dark colour (CARD_FACE_COLOUR ≈ #1a1a1a)
// rather than the near-white BLACK_SUIT_COLOUR designed for the dark
// Terminal theme background.
let text_col = if card.suit.is_red() {
if color_blind {
RED_SUIT_COLOUR_CBM
} else if high_contrast {
RED_SUIT_COLOUR_HC
} else {
RED_SUIT_COLOUR
}
} else {
CARD_FACE_COLOUR
};
let label_text = mobile_label_for(card); let label_text = mobile_label_for(card);
parent.spawn(( parent.spawn((
AndroidCornerLabel(label_text.clone()), AndroidCornerLabel(label_text.clone()),
@@ -1169,7 +1193,7 @@ fn add_android_corner_label(
font_size, font_size,
..default() ..default()
}, },
TextColor(text_colour(card, color_blind, high_contrast)), TextColor(text_col),
Anchor::TOP_LEFT, Anchor::TOP_LEFT,
Transform::from_xyz( Transform::from_xyz(
-card_size.x / 2.0 + inset, -card_size.x / 2.0 + inset,
+55 -1
View File
@@ -140,6 +140,12 @@ pub struct HudColumn;
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct HudActionBar; pub struct HudActionBar;
/// Marker on the text node inside each action-bar button (Android only).
/// Used by `resize_action_bar_labels` to update font size on window resize.
#[cfg(target_os = "android")]
#[derive(Component, Debug)]
struct ActionButtonLabel;
/// Marker on the circular profile-picture button anchored to the /// Marker on the circular profile-picture button anchored to the
/// top-right of the HUD band. Pressing it opens the Profile overlay. /// top-right of the HUD band. Pressing it opens the Profile overlay.
/// Shows the server avatar image when loaded; falls back to the player's /// Shows the server avatar image when loaded; falls back to the player's
@@ -489,6 +495,11 @@ impl Plugin for HudPlugin {
.after(TouchDragSet::AfterStartDrag) .after(TouchDragSet::AfterStartDrag)
.in_set(TouchDragSet::BeforeEndDrag), .in_set(TouchDragSet::BeforeEndDrag),
); );
app.add_systems(
Update,
resize_action_bar_labels
.run_if(resource_exists_and_changed::<crate::layout::LayoutResource>),
);
} }
} }
} }
@@ -843,11 +854,25 @@ fn handle_avatar_button(
/// on its own visual edge. /// on its own visual edge.
fn spawn_action_buttons( fn spawn_action_buttons(
font_res: Option<Res<FontResource>>, font_res: Option<Res<FontResource>>,
windows: Query<&Window>,
mut commands: Commands, mut commands: Commands,
) { ) {
// On Android the glyph labels must scale with the viewport so they remain
// legible on any screen density. Use the window width at startup; the
// resize_action_bar_labels system keeps this current on window changes.
#[cfg(target_os = "android")]
let action_font_size = {
let w = windows.iter().next().map_or(900.0, |win| win.width());
action_bar_font_size(w)
};
#[cfg(not(target_os = "android"))]
let action_font_size = TYPE_BODY;
#[cfg(not(target_os = "android"))]
let _windows = windows;
let font = TextFont { let font = TextFont {
font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(), font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(),
font_size: TYPE_BODY, font_size: action_font_size,
..default() ..default()
}; };
@@ -992,6 +1017,9 @@ fn spawn_action_button<M: Component>(
HighContrastBorder::with_default(BORDER_SUBTLE), HighContrastBorder::with_default(BORDER_SUBTLE),
)) ))
.with_children(|b| { .with_children(|b| {
#[cfg(target_os = "android")]
b.spawn((ActionButtonLabel, Text::new(label), font.clone(), TextColor(text_color)));
#[cfg(not(target_os = "android"))]
b.spawn((Text::new(label), font.clone(), TextColor(text_color))); b.spawn((Text::new(label), font.clone(), TextColor(text_color)));
if let Some(key) = hotkey { if let Some(key) = hotkey {
// Hotkey hint rendered as a dim caption next to the label — // Hotkey hint rendered as a dim caption next to the label —
@@ -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")] #[cfg(target_os = "android")]
fn toggle_hud_on_tap( fn toggle_hud_on_tap(
mut touch_events: MessageReader<bevy::input::touch::TouchInput>, mut touch_events: MessageReader<bevy::input::touch::TouchInput>,
+8 -5
View File
@@ -96,13 +96,16 @@ const MAX_TABLEAU_CARDS: f32 = 13.0;
/// below this band so the HUD doesn't bleed into the play surface. /// below this band so the HUD doesn't bleed into the play surface.
/// ///
/// Desktop: 64 px fits the score/moves/time + mode badge rows. /// Desktop: 64 px fits the score/moves/time + mode badge rows.
/// Android: 80 px gives the same content rows comfortable clearance. /// Android: 112 px — the HUD column has 4 flex tiers with 3 inter-tier
/// (Previously 128 px when action buttons lived in the top band; those are /// gaps (4 px each) plus a SPACE_2 = 8 px top offset. With empty tiers
/// now in the bottom bar so the larger reserve is no longer needed.) /// 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"))] #[cfg(not(target_os = "android"))]
pub const HUD_BAND_HEIGHT: f32 = 64.0; pub const HUD_BAND_HEIGHT: f32 = 64.0;
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
pub const HUD_BAND_HEIGHT: f32 = 80.0; pub const HUD_BAND_HEIGHT: f32 = 112.0;
/// Table background colour (dark green felt). /// Table background colour (dark green felt).
pub const TABLE_COLOUR: [f32; 3] = [0.059, 0.322, 0.196]; pub const TABLE_COLOUR: [f32; 3] = [0.059, 0.322, 0.196];
@@ -123,7 +126,7 @@ pub struct Layout {
pub pile_positions: HashMap<PileType, Vec2>, pub pile_positions: HashMap<PileType, Vec2>,
/// Per-step vertical offset fraction for face-up tableau cards, as a /// Per-step vertical offset fraction for face-up tableau cards, as a
/// fraction of `card_size.y`. On height-limited (desktop) windows this /// fraction of `card_size.y`. On height-limited (desktop) windows this
/// equals `TABLEAU_FAN_FRAC` (0.25); on width-limited (portrait phone) /// equals `TABLEAU_FAN_FRAC` (0.18); on width-limited (portrait phone)
/// windows it expands to fill the available vertical space so the tableau /// windows it expands to fill the available vertical space so the tableau
/// stretches to the bottom of the screen. Card rendering (`card_plugin`) /// stretches to the bottom of the screen. Card rendering (`card_plugin`)
/// and hit testing (`input_plugin`) both read from this field so they /// and hit testing (`input_plugin`) both read from this field so they
@@ -139,6 +139,7 @@ impl Plugin for LeaderboardPlugin {
.init_resource::<DisplayNameBuffer>() .init_resource::<DisplayNameBuffer>()
.add_message::<ToggleLeaderboardRequestEvent>() .add_message::<ToggleLeaderboardRequestEvent>()
.add_message::<WarningToastEvent>() .add_message::<WarningToastEvent>()
.add_message::<InfoToastEvent>()
// `MouseWheel` and `KeyboardInput` are emitted by Bevy's input // `MouseWheel` and `KeyboardInput` are emitted by Bevy's input
// plugin under `DefaultPlugins`; register them explicitly so all // plugin under `DefaultPlugins`; register them explicitly so all
// leaderboard systems run cleanly under `MinimalPlugins` in tests. // leaderboard systems run cleanly under `MinimalPlugins` in tests.
+3 -2
View File
@@ -138,12 +138,13 @@ fn handle_open_dialog(
mut requests: MessageReader<StartPlayBySeedRequestEvent>, mut requests: MessageReader<StartPlayBySeedRequestEvent>,
font_res: Option<Res<FontResource>>, font_res: Option<Res<FontResource>>,
existing: Query<(), With<PlayBySeedScreen>>, existing: Query<(), With<PlayBySeedScreen>>,
other_scrims: Query<(), (With<crate::ui_modal::ModalScrim>, Without<PlayBySeedScreen>)>,
) { ) {
if requests.read().count() == 0 { if requests.read().count() == 0 {
return; return;
} }
// Guard against double-spawn (e.g. two events in one frame). // Guard against double-spawn (e.g. two events in one frame) or stacking over another modal.
if !existing.is_empty() { if !existing.is_empty() || !other_scrims.is_empty() {
return; return;
} }
let font = font_res.as_deref(); let font = font_res.as_deref();
+1
View File
@@ -512,6 +512,7 @@ pub struct ReplayPlaybackPlugin;
impl Plugin for ReplayPlaybackPlugin { impl Plugin for ReplayPlaybackPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.init_resource::<ReplayPlaybackState>() app.init_resource::<ReplayPlaybackState>()
.add_message::<StateChangedEvent>()
.add_systems( .add_systems(
Update, Update,
( (
+9 -1
View File
@@ -305,7 +305,7 @@ fn update_field_borders(
fn handle_auth_button( fn handle_auth_button(
login_q: Query<&Interaction, (Changed<Interaction>, With<SyncLoginButton>)>, login_q: Query<&Interaction, (Changed<Interaction>, With<SyncLoginButton>)>,
register_q: Query<&Interaction, (Changed<Interaction>, With<SyncRegisterButton>)>, register_q: Query<&Interaction, (Changed<Interaction>, With<SyncRegisterButton>)>,
fields: Query<(&SyncFieldKind, &SyncFieldBuffer)>, mut fields: Query<(&SyncFieldKind, &mut SyncFieldBuffer)>,
rt: Res<TokioRuntimeResource>, rt: Res<TokioRuntimeResource>,
mut pending: ResMut<PendingAuthTask>, mut pending: ResMut<PendingAuthTask>,
mut error_nodes: Query<(&mut Text, &mut TextColor), With<SyncAuthError>>, mut error_nodes: Query<(&mut Text, &mut TextColor), With<SyncAuthError>>,
@@ -392,6 +392,14 @@ fn handle_auth_button(
pending.task = Some(task); pending.task = Some(task);
pending.url = url; pending.url = url;
pending.username = username; pending.username = username;
// Zero the password buffer immediately — it must not linger in ECS
// components after the credential has been handed off to the async task.
for (kind, mut buf) in &mut fields {
if *kind == SyncFieldKind::Password {
buf.0.clear();
}
}
} }
/// Polls the in-flight auth task. On success updates settings + provider. /// Polls the in-flight auth task. On success updates settings + provider.
+4 -3
View File
@@ -508,7 +508,7 @@ fn collect_session_achievements(
) { ) {
// Reset on any new-game request (including mode switches via Z/X/C/T) so // Reset on any new-game request (including mode switches via Z/X/C/T) so
// achievements from the previous session are not carried into the next one. // achievements from the previous session are not carried into the next one.
if new_games.read().last().is_some() { if new_games.read().next().is_some() {
session.names.clear(); session.names.clear();
} }
for ev in unlocks.read() { for ev in unlocks.read() {
@@ -539,6 +539,7 @@ fn spawn_win_summary_after_delay(
settings: Option<Res<SettingsResource>>, settings: Option<Res<SettingsResource>>,
time: Res<Time>, time: Res<Time>,
overlays: Query<Entity, With<WinSummaryOverlay>>, overlays: Query<Entity, With<WinSummaryOverlay>>,
other_scrims: Query<(), (With<ModalScrim>, Without<WinSummaryOverlay>)>,
mut delay: Local<Option<f32>>, mut delay: Local<Option<f32>>,
) { ) {
// Process new win events. // Process new win events.
@@ -569,8 +570,8 @@ fn spawn_win_summary_after_delay(
*remaining -= time.delta_secs(); *remaining -= time.delta_secs();
if *remaining <= 0.0 { if *remaining <= 0.0 {
*delay = None; *delay = None;
// Only spawn if there is no overlay already. // Only spawn if no overlay of any kind is already visible.
if overlays.is_empty() { if overlays.is_empty() && other_scrims.is_empty() {
// Drain any XpAwardedEvents that arrived this frame but were // Drain any XpAwardedEvents that arrived this frame but were
// not yet consumed by `cache_win_data` (which may run later in // not yet consumed by `cache_win_data` (which may run later in
// the same schedule). Accumulating here ensures the modal // the same schedule). Accumulating here ensures the modal
+7 -17
View File
@@ -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). /// Maximum avatar upload size in bytes (1 MB).
const AVATAR_MAX_BYTES: usize = 1024 * 1024; const AVATAR_MAX_BYTES: usize = 1024 * 1024;
@@ -361,23 +359,15 @@ pub async fn upload_avatar(
.and_then(|v| v.to_str().ok()) .and_then(|v| v.to_str().ok())
.unwrap_or("") .unwrap_or("")
.to_string(); .to_string();
let ext = if mime.contains("jpeg") || mime.contains("jpg") { let ext = match mime.as_str() {
"jpg" "image/jpeg" | "image/jpg" => "jpg",
} else if mime.contains("png") { "image/png" => "png",
"png" "image/webp" => "webp",
} else if mime.contains("webp") { "image/gif" => "gif",
"webp" _ => return Err(AppError::BadRequest(
} else if mime.contains("gif") {
"gif"
} else {
return Err(AppError::BadRequest(
"avatar must be image/jpeg, image/png, image/webp, or image/gif".into(), "avatar must be image/jpeg, image/png, image/webp, or image/gif".into(),
)); )),
}; };
if !ALLOWED_IMAGE_TYPES.iter().any(|t| mime.starts_with(t)) {
return Err(AppError::BadRequest("unsupported image type".into()));
}
if body.len() > AVATAR_MAX_BYTES { if body.len() > AVATAR_MAX_BYTES {
return Err(AppError::BadRequest("avatar must be ≤ 1 MB".into())); return Err(AppError::BadRequest("avatar must be ≤ 1 MB".into()));
} }