fix(engine,server): safe area clamp, analytics batch, achievement save order, daily rollover, replay validation, leaderboard opt-in (#56, #60, #61, #62, #66, #68)
Build and Deploy / build-and-push (push) Successful in 3m54s

- #66: Clamp safe-area insets to 25% of window height with warn!() on excess
- #68: Move fire_flush outside per-event loop in analytics (batch flush once)
- #56: Persist progress before marking reward_granted to prevent XP loss on crash
- #60: Add DateRolloverTimer + check_date_rollover system for midnight seed refresh
- #62: Add validate_header() in replay upload with mode/draw_mode allowlists
- #61: Restore two-query leaderboard opt-in check (SELECT then UPDATE); original
       queries already in .sqlx cache; EXISTS variant would require sqlx prepare

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
funman300
2026-05-28 13:07:22 -07:00
parent 8cb4c9808e
commit 6e407a3ea7
104 changed files with 6356 additions and 3092 deletions
+220 -50
View File
@@ -30,7 +30,9 @@ fn suit_color(suit: u8) -> [u8; 4] {
}
fn rank_str(rank: u8) -> &'static str {
["A","2","3","4","5","6","7","8","9","10","J","Q","K"][rank as usize]
[
"A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K",
][rank as usize]
}
// ---------------------------------------------------------------------------
@@ -86,13 +88,15 @@ impl Canvas {
}
fn set(&mut self, x: i32, y: i32, c: [u8; 4]) {
if x < 0 || y < 0 || x >= W as i32 || y >= H as i32 { return; }
if x < 0 || y < 0 || x >= W as i32 || y >= H as i32 {
return;
}
let i = (y as u32 * W + x as u32) as usize * 4;
let a = c[3] as f32 / 255.0;
if a >= 0.99 {
self.data[i..i + 4].copy_from_slice(&c);
} else if a > 0.01 {
self.data[i] = (self.data[i] as f32 * (1.0 - a) + c[0] as f32 * a) as u8;
self.data[i] = (self.data[i] as f32 * (1.0 - a) + c[0] as f32 * a) as u8;
self.data[i + 1] = (self.data[i + 1] as f32 * (1.0 - a) + c[1] as f32 * a) as u8;
self.data[i + 2] = (self.data[i + 2] as f32 * (1.0 - a) + c[2] as f32 * a) as u8;
self.data[i + 3] = 255;
@@ -172,27 +176,36 @@ fn draw_heart(cv: &mut Canvas, cx: f32, cy: f32, sz: f32, c: [u8; 4]) {
let oy = cy - sz * 0.04;
cv.circle(cx - sz * 0.22, oy, r, c);
cv.circle(cx + sz * 0.22, oy, r, c);
cv.triangle([
(cx - sz * 0.52, oy + r * 0.4),
(cx + sz * 0.52, oy + r * 0.4),
(cx, cy + sz * 0.52),
], c);
cv.triangle(
[
(cx - sz * 0.52, oy + r * 0.4),
(cx + sz * 0.52, oy + r * 0.4),
(cx, cy + sz * 0.52),
],
c,
);
}
fn draw_spade(cv: &mut Canvas, cx: f32, cy: f32, sz: f32, c: [u8; 4]) {
cv.triangle([
(cx, cy - sz * 0.52),
(cx - sz * 0.52, cy + sz * 0.1),
(cx + sz * 0.52, cy + sz * 0.1),
], c);
cv.triangle(
[
(cx, cy - sz * 0.52),
(cx - sz * 0.52, cy + sz * 0.1),
(cx + sz * 0.52, cy + sz * 0.1),
],
c,
);
cv.circle(cx - sz * 0.22, cy + sz * 0.06, sz * 0.3, c);
cv.circle(cx + sz * 0.22, cy + sz * 0.06, sz * 0.3, c);
// stem + base
cv.triangle([
(cx, cy + sz * 0.12),
(cx - sz * 0.13, cy + sz * 0.5),
(cx + sz * 0.13, cy + sz * 0.5),
], c);
cv.triangle(
[
(cx, cy + sz * 0.12),
(cx - sz * 0.13, cy + sz * 0.5),
(cx + sz * 0.13, cy + sz * 0.5),
],
c,
);
cv.fill_rect(
(cx - sz * 0.26) as i32,
(cy + sz * 0.43) as i32,
@@ -231,7 +244,15 @@ fn draw_club(cv: &mut Canvas, cx: f32, cy: f32, sz: f32, c: [u8; 4]) {
// Text rendering via ab_glyph
// ---------------------------------------------------------------------------
fn draw_text(cv: &mut Canvas, font: &FontRef<'_>, text: &str, px: f32, left: f32, top: f32, c: [u8; 4]) {
fn draw_text(
cv: &mut Canvas,
font: &FontRef<'_>,
text: &str,
px: f32,
left: f32,
top: f32,
c: [u8; 4],
) {
let scale = PxScale::from(px);
let baseline = top + font.as_scaled(scale).ascent();
let mut x = left;
@@ -278,12 +299,63 @@ fn pip_positions(rank: u8) -> &'static [(f32, f32)] {
1 => &[(0.5, 0.2), (0.5, 0.8)],
2 => &[(0.5, 0.12), (0.5, 0.5), (0.5, 0.88)],
3 => &[(0.25, 0.18), (0.75, 0.18), (0.25, 0.82), (0.75, 0.82)],
4 => &[(0.25, 0.18), (0.75, 0.18), (0.5, 0.5), (0.25, 0.82), (0.75, 0.82)],
5 => &[(0.25, 0.12), (0.75, 0.12), (0.25, 0.5), (0.75, 0.5), (0.25, 0.88), (0.75, 0.88)],
6 => &[(0.25, 0.1), (0.75, 0.1), (0.5, 0.31), (0.25, 0.5), (0.75, 0.5), (0.25, 0.9), (0.75, 0.9)],
7 => &[(0.25, 0.1), (0.75, 0.1), (0.5, 0.28), (0.25, 0.48), (0.75, 0.48), (0.5, 0.70), (0.25, 0.9), (0.75, 0.9)],
8 => &[(0.25, 0.1), (0.75, 0.1), (0.25, 0.35), (0.75, 0.35), (0.5, 0.5), (0.25, 0.65), (0.75, 0.65), (0.25, 0.9), (0.75, 0.9)],
9 => &[(0.25, 0.09), (0.75, 0.09), (0.5, 0.27), (0.25, 0.44), (0.75, 0.44), (0.25, 0.56), (0.75, 0.56), (0.5, 0.73), (0.25, 0.91), (0.75, 0.91)],
4 => &[
(0.25, 0.18),
(0.75, 0.18),
(0.5, 0.5),
(0.25, 0.82),
(0.75, 0.82),
],
5 => &[
(0.25, 0.12),
(0.75, 0.12),
(0.25, 0.5),
(0.75, 0.5),
(0.25, 0.88),
(0.75, 0.88),
],
6 => &[
(0.25, 0.1),
(0.75, 0.1),
(0.5, 0.31),
(0.25, 0.5),
(0.75, 0.5),
(0.25, 0.9),
(0.75, 0.9),
],
7 => &[
(0.25, 0.1),
(0.75, 0.1),
(0.5, 0.28),
(0.25, 0.48),
(0.75, 0.48),
(0.5, 0.70),
(0.25, 0.9),
(0.75, 0.9),
],
8 => &[
(0.25, 0.1),
(0.75, 0.1),
(0.25, 0.35),
(0.75, 0.35),
(0.5, 0.5),
(0.25, 0.65),
(0.75, 0.65),
(0.25, 0.9),
(0.75, 0.9),
],
9 => &[
(0.25, 0.09),
(0.75, 0.09),
(0.5, 0.27),
(0.25, 0.44),
(0.75, 0.44),
(0.25, 0.56),
(0.75, 0.56),
(0.5, 0.73),
(0.25, 0.91),
(0.75, 0.91),
],
_ => &[],
}
}
@@ -327,14 +399,28 @@ fn make_card_face(font: &FontRef<'_>, rank: u8, suit: u8) -> Canvas {
let tl_x = 6.0f32;
let tl_y = 5.0f32;
draw_text(&mut cv, font, rank_s, rank_px, tl_x, tl_y, sc);
draw_suit(&mut cv, tl_x + suit_sz * 0.62, tl_y + rh + 2.0 + suit_sz * 0.75, suit_sz, suit, sc);
draw_suit(
&mut cv,
tl_x + suit_sz * 0.62,
tl_y + rh + 2.0 + suit_sz * 0.75,
suit_sz,
suit,
sc,
);
// Bottom-right corner (right-aligned rank, suit above it)
let br_rx = W as f32 - 6.0;
let br_by = H as f32 - 5.0;
let br_ty = br_by - corner_h;
draw_text(&mut cv, font, rank_s, rank_px, br_rx - rw, br_ty, sc);
draw_suit(&mut cv, br_rx - suit_sz * 0.62, br_ty + rh + 2.0 + suit_sz * 0.75, suit_sz, suit, sc);
draw_suit(
&mut cv,
br_rx - suit_sz * 0.62,
br_ty + rh + 2.0 + suit_sz * 0.75,
suit_sz,
suit,
sc,
);
// Center content
if rank >= 10 {
@@ -346,7 +432,14 @@ fn make_card_face(font: &FontRef<'_>, rank: u8, suit: u8) -> Canvas {
let big_y = H as f32 * 0.28;
draw_text(&mut cv, font, rank_s, big_px, big_x, big_y, sc);
let sym_sz = 22.0f32;
draw_suit(&mut cv, W as f32 * 0.5, big_y + big_h + sym_sz * 1.0, sym_sz, suit, sc);
draw_suit(
&mut cv,
W as f32 * 0.5,
big_y + big_h + sym_sz * 1.0,
sym_sz,
suit,
sc,
);
} else {
// Pip cards
let pip_sz = if rank == 0 {
@@ -375,15 +468,17 @@ fn save_card_png(path: &Path, cv: &Canvas) {
}
fn save_png_wh(path: &Path, data: &[u8], w: u32, h: u32) {
let file = File::create(path)
.unwrap_or_else(|e| panic!("cannot create {}: {e}", path.display()));
let file =
File::create(path).unwrap_or_else(|e| panic!("cannot create {}: {e}", path.display()));
let mut bw = BufWriter::new(file);
let mut enc = png::Encoder::new(&mut bw, w, h);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut writer = enc.write_header()
let mut writer = enc
.write_header()
.unwrap_or_else(|e| panic!("png header error for {}: {e}", path.display()));
writer.write_image_data(data)
writer
.write_image_data(data)
.unwrap_or_else(|e| panic!("png data error for {}: {e}", path.display()));
}
@@ -401,8 +496,18 @@ fn make_back_0() -> Canvas {
// 2-pixel border
let bw = 4i32;
for x in 0..W as i32 { for t in 0..bw { cv.set(x, t, LIGHT); cv.set(x, H as i32 - 1 - t, LIGHT); } }
for y in 0..H as i32 { for t in 0..bw { cv.set(t, y, LIGHT); cv.set(W as i32 - 1 - t, y, LIGHT); } }
for x in 0..W as i32 {
for t in 0..bw {
cv.set(x, t, LIGHT);
cv.set(x, H as i32 - 1 - t, LIGHT);
}
}
for y in 0..H as i32 {
for t in 0..bw {
cv.set(t, y, LIGHT);
cv.set(W as i32 - 1 - t, y, LIGHT);
}
}
// Diamond grid: row/col spacing
let gx = 18.0f32;
@@ -455,8 +560,18 @@ fn make_back_1() -> Canvas {
// 4-pixel border
let bw = 4i32;
for x in 0..W as i32 { for t in 0..bw { cv.set(x, t, BORDER); cv.set(x, H as i32 - 1 - t, BORDER); } }
for y in 0..H as i32 { for t in 0..bw { cv.set(t, y, BORDER); cv.set(W as i32 - 1 - t, y, BORDER); } }
for x in 0..W as i32 {
for t in 0..bw {
cv.set(x, t, BORDER);
cv.set(x, H as i32 - 1 - t, BORDER);
}
}
for y in 0..H as i32 {
for t in 0..bw {
cv.set(t, y, BORDER);
cv.set(W as i32 - 1 - t, y, BORDER);
}
}
cv
}
@@ -470,8 +585,18 @@ fn make_back_2() -> Canvas {
// 4-pixel border
let bw = 4i32;
for x in 0..W as i32 { for t in 0..bw { cv.set(x, t, BORDER); cv.set(x, H as i32 - 1 - t, BORDER); } }
for y in 0..H as i32 { for t in 0..bw { cv.set(t, y, BORDER); cv.set(W as i32 - 1 - t, y, BORDER); } }
for x in 0..W as i32 {
for t in 0..bw {
cv.set(x, t, BORDER);
cv.set(x, H as i32 - 1 - t, BORDER);
}
}
for y in 0..H as i32 {
for t in 0..bw {
cv.set(t, y, BORDER);
cv.set(W as i32 - 1 - t, y, BORDER);
}
}
// Circle array (staggered rows)
let gx = 16.0f32;
@@ -513,8 +638,18 @@ fn make_back_3() -> Canvas {
// 4-pixel border
let bw = 4i32;
for x in 0..W as i32 { for t in 0..bw { cv.set(x, t, BORDER); cv.set(x, H as i32 - 1 - t, BORDER); } }
for y in 0..H as i32 { for t in 0..bw { cv.set(t, y, BORDER); cv.set(W as i32 - 1 - t, y, BORDER); } }
for x in 0..W as i32 {
for t in 0..bw {
cv.set(x, t, BORDER);
cv.set(x, H as i32 - 1 - t, BORDER);
}
}
for y in 0..H as i32 {
for t in 0..bw {
cv.set(t, y, BORDER);
cv.set(W as i32 - 1 - t, y, BORDER);
}
}
cv
}
@@ -543,8 +678,18 @@ fn make_back_4() -> Canvas {
// 4-pixel border
let bw = 4i32;
for x in 0..W as i32 { for t in 0..bw { cv.set(x, t, BORDER); cv.set(x, H as i32 - 1 - t, BORDER); } }
for y in 0..H as i32 { for t in 0..bw { cv.set(t, y, BORDER); cv.set(W as i32 - 1 - t, y, BORDER); } }
for x in 0..W as i32 {
for t in 0..bw {
cv.set(x, t, BORDER);
cv.set(x, H as i32 - 1 - t, BORDER);
}
}
for y in 0..H as i32 {
for t in 0..bw {
cv.set(t, y, BORDER);
cv.set(W as i32 - 1 - t, y, BORDER);
}
}
cv
}
@@ -574,7 +719,7 @@ fn make_bg_0() -> Canvas {
fn make_bg_1() -> Canvas {
const BASE: [u8; 4] = [0x40, 0x2D, 0x1A, 0xFF];
const PLANK_EDGE: [u8; 4] = [0x28, 0x1A, 0x0A, 0xFF]; // dark plank separator
const GRAIN: [u8; 4] = [0x55, 0x3D, 0x28, 0xA0]; // lighter grain streak
const GRAIN: [u8; 4] = [0x55, 0x3D, 0x28, 0xA0]; // lighter grain streak
let mut cv = Canvas::new();
cv.fill_solid(BASE);
// Horizontal plank edges every 24 px
@@ -585,7 +730,9 @@ fn make_bg_1() -> Canvas {
// Grain lines within each plank (every 3 px between plank edges)
for y in (0..H as i32).step_by(3) {
// Skip the plank edge rows
if y % 24 < 2 { continue; }
if y % 24 < 2 {
continue;
}
cv.hline(y, 2, W as i32 - 3, GRAIN);
}
cv
@@ -608,7 +755,11 @@ fn make_bg_2() -> Canvas {
let mut cx = gx * 0.5 + offset;
while cx < W as f32 {
// alternate bright/dim to give depth
let c = if (row + (cx / gx) as u32).is_multiple_of(3) { STAR_A } else { STAR_B };
let c = if (row + (cx / gx) as u32).is_multiple_of(3) {
STAR_A
} else {
STAR_B
};
cv.circle(cx, cy, 1.0, c);
cx += gx;
}
@@ -679,12 +830,13 @@ fn main() {
let font_path = root.join("assets/fonts/main.ttf");
let font_bytes = std::fs::read(&font_path)
.unwrap_or_else(|e| panic!("failed to read {}: {e}", font_path.display()));
let font = FontRef::try_from_slice(&font_bytes)
.expect("failed to parse assets/fonts/main.ttf");
let font = FontRef::try_from_slice(&font_bytes).expect("failed to parse assets/fonts/main.ttf");
// 52 card faces
let suits = ["c", "d", "h", "s"];
let ranks = ["a","2","3","4","5","6","7","8","9","10","j","q","k"];
let ranks = [
"a", "2", "3", "4", "5", "6", "7", "8", "9", "10", "j", "q", "k",
];
for suit in 0u8..4 {
for rank in 0u8..13 {
let cv = make_card_face(&font, rank, suit);
@@ -696,14 +848,32 @@ fn main() {
}
// Card backs
for (i, cv) in [make_back_0(), make_back_1(), make_back_2(), make_back_3(), make_back_4()].iter().enumerate() {
for (i, cv) in [
make_back_0(),
make_back_1(),
make_back_2(),
make_back_3(),
make_back_4(),
]
.iter()
.enumerate()
{
let path = root.join(format!("assets/cards/backs/back_{i}.png"));
save_card_png(&path, cv);
println!("wrote {}", path.display());
}
// Backgrounds
for (i, cv) in [make_bg_0(), make_bg_1(), make_bg_2(), make_bg_3(), make_bg_4()].iter().enumerate() {
for (i, cv) in [
make_bg_0(),
make_bg_1(),
make_bg_2(),
make_bg_3(),
make_bg_4(),
]
.iter()
.enumerate()
{
let path = root.join(format!("assets/backgrounds/bg_{i}.png"));
save_card_png(&path, cv);
println!("wrote {}", path.display());