pinch-points 0.8.0

A fast, kid-friendly crab-routing game: route streams of crabs into your sandcastle before time runs out
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
//! What the keys mean while a round is on: the phase-flow input systems.
//!
//! The cursor file answers "where is the cursor and how does it move";
//! this one answers "what does a press do to the round": place a post,
//! start the run, retry, advance the campaign, queue a versus action,
//! leave the results card. None of it moves a cursor, and two of the
//! systems never read one.

use crate::app::cursor::{Cursor, FLASH_SECS, keymap};
use crate::app::settings::{CommitScheme, GameSettings};
use crate::app::{Campaign, LoadLevel, PendingActions, Phase, PlacementDenied, Screen, Sim};
use crate::sim::PlayerAction;
use bevy::prelude::*;

/// Puzzle setup phase: spend the inventory directly on the board, and
/// Enter to run. N/P, which jump between levels, are [`browse_levels`].
///
/// Every cursor here places the one seat's arrows: in co-op the second is a
/// second pair of hands (see [`crate::app::Coop`]), on the second seat's
/// keys, and the IJKL one-hand preset stands down for it.
pub fn setup_input(
    keys: Res<ButtonInput<KeyCode>>,
    settings: Res<GameSettings>,
    mut sim: ResMut<Sim>,
    mut next_phase: ResMut<NextState<Phase>>,
    mut denied: MessageWriter<PlacementDenied>,
    mut cursors: Query<&mut Cursor>,
) {
    // Esc is the pause card's key; it handles leaving.
    let hands = cursors.iter().filter(|c| c.player < 2).count();
    let commit = if hands > 1 {
        CommitScheme::Arrows
    } else {
        settings.commit
    };
    for mut hand in cursors.iter_mut().filter(|c| c.player < 2) {
        let map = keymap(&settings, hand.player, commit);
        for (key, dir) in map.places {
            if keys.just_pressed(key) {
                // CapPolicy::Reject enforces the inventory; surface the
                // no-op, and say which no it was.
                let spent = sim.0.out_of_signposts(0, hand.x, hand.y);
                if !sim.0.place_signpost(0, hand.x, hand.y, dir) {
                    hand.flash = FLASH_SECS;
                    denied.write(PlacementDenied {
                        player: 0,
                        out_of_signposts: spent,
                    });
                }
            }
        }
        if keys.just_pressed(map.remove) {
            let _ = sim.0.remove_signpost(0, hand.x, hand.y);
        }
        if keys.just_pressed(map.clear_all) {
            clear_setup(&mut sim);
        }
    }
    if keys.just_pressed(KeyCode::Enter) && cursors.iter().any(|c| c.player == 0) {
        next_phase.set(Phase::Running);
    }
}

/// Clear-all in a puzzle's setup: every arrow up at once. The board is
/// still, so there is no tick to space the removals over as versus does.
/// The controls table, the guide and the pad's help all promise this key
/// in a puzzle too, where it used to do nothing.
pub fn clear_setup(sim: &mut Sim) {
    while let Some((x, y)) = sim.0.first_signpost_of(0) {
        if !sim.0.remove_signpost(0, x, y) {
            break;
        }
    }
}

/// N/P during puzzle setup: the next level or the one before, as far as
/// the ladder goes. A stage not yet open is refused with the same flash
/// and word as a placement that cannot go down.
pub fn browse_levels(
    keyboard: crate::app::keycaps::Keyboard,
    mut ladder: crate::app::progress::Ladder,
    mut load: MessageWriter<LoadLevel>,
    mut denied: MessageWriter<PlacementDenied>,
    mut cursors: Query<&mut Cursor>,
) {
    let Some(mut cursor) = cursors.iter_mut().find(|c| c.player == 0) else {
        return;
    };
    let at = ladder.campaign.index;
    // Within the section the stage is in, the shipped campaign or the
    // player's own levels, as Enter's run is (`Campaign::is_last`): N on
    // the last shipped stage used to walk on into the player's levels,
    // and P on the first wrapped onto the last of those.
    let builtins = ladder.campaign.builtins;
    let (start, end) = match at < builtins {
        true => (0, builtins),
        false => (builtins, ladder.campaign.levels.len()),
    };
    let len = end - start;
    // Browsing forward stops at the ladder: the stage list is the only
    // way past a stage you have not cleared. Backward from the first
    // shipped stage wraps onto the last, which is as locked as anything
    // ahead: the same ladder, the same refusal.
    let asked = if keyboard.caps.just_pressed(&keyboard.keys, 'N') {
        start + (at - start + 1) % len
    } else if keyboard.caps.just_pressed(&keyboard.keys, 'P') {
        start + (at - start + len - 1) % len
    } else {
        return;
    };
    if ladder.climb_to(asked) {
        load.write(LoadLevel { keep_posts: false });
    } else {
        cursor.flash = FLASH_SECS;
        denied.write(PlacementDenied {
            player: 0,
            out_of_signposts: false,
        });
    }
}

/// Puzzle running phase: R resets to setup (keeping placed posts).
///
/// Esc is the pause card's key, as it is in setup and in versus. Toggling
/// `Paused` on it as well, the card read that as the state to hand back on
/// closing, so Continue left the round frozen with no card up.
pub fn running_input(
    keys: Res<ButtonInput<KeyCode>>,
    caps: Res<crate::app::keycaps::KeyCaps>,
    mut load: MessageWriter<LoadLevel>,
) {
    if caps.just_pressed(&keys, 'R') {
        load.write(LoadLevel { keep_posts: true });
    }
}

/// Puzzle won/lost phase: Enter advances (or retries after a loss), R replays.
pub fn done_input(
    keys: Res<ButtonInput<KeyCode>>,
    caps: Res<crate::app::keycaps::KeyCaps>,
    phase: Res<State<Phase>>,
    mut campaign: ResMut<Campaign>,
    mut next_screen: ResMut<NextState<Screen>>,
    mut load: MessageWriter<LoadLevel>,
) {
    if keys.just_pressed(KeyCode::Escape) {
        // Back to the stage list, which is the door every puzzle is entered
        // through; Esc again from there reaches the menu.
        next_screen.set(Screen::StageSelect);
        return;
    }
    if keys.just_pressed(KeyCode::Enter) {
        if *phase.get() != Phase::Won {
            load.write(LoadLevel { keep_posts: true });
            return;
        }
        // The end of the list is the end of the list: wrapping to level one
        // reads as the game not having noticed, the card saying "that was
        // the last level" and then putting you back on the first. The
        // shipped campaign ends with its last shipped stage too, rather
        // than walking on into the player's own levels.
        if campaign.is_last() {
            next_screen.set(Screen::Menu);
            return;
        }
        campaign.index += 1;
        load.write(LoadLevel { keep_posts: false });
        // An R in the same frame would reload with this stage's posts
        // carried onto the next one.
        return;
    }
    if caps.just_pressed(&keys, 'R') {
        load.write(LoadLevel { keep_posts: true });
    }
}

/// Versus: every player's commits go through `PendingActions` and take effect
/// on the next 30 Hz tick, the input path rollback netcode will feed.
/// One action per player per tick (spec §7.6); a later press in the same
/// window overwrites the earlier one.
pub fn versus_input(
    keys: Res<ButtonInput<KeyCode>>,
    sim: Res<Sim>,
    settings: Res<GameSettings>,
    online: Res<crate::app::net::Online>,
    mut pending: ResMut<PendingActions>,
    mut denied: MessageWriter<PlacementDenied>,
    mut cursors: Query<&mut Cursor>,
) {
    // Esc is the pause card's key; it handles leaving (including a dead
    // online session or truncated replay).
    let board = &sim.0;
    for mut cursor in &mut cursors {
        let p = cursor.player as usize;
        // Online: whatever your seat, your hands are on the primary layout.
        // Local: the keyboard seats two; pads drive seats 3 and up.
        let map = if online.0.is_some() {
            keymap(&settings, 0, CommitScheme::Arrows)
        } else if cursor.player < 2 {
            keymap(&settings, cursor.player, CommitScheme::Arrows)
        } else {
            continue;
        };
        // Online or not, a cursor carries the seat it places for: online
        // spawns one cursor and gives it the local seat.
        let seat = cursor.player;
        for (key, dir) in map.places {
            if keys.just_pressed(key) {
                if !board.can_place_signpost(seat, cursor.x, cursor.y) {
                    cursor.flash = FLASH_SECS;
                    denied.write(PlacementDenied {
                        player: seat,
                        out_of_signposts: board.out_of_signposts(seat, cursor.x, cursor.y),
                    });
                    continue;
                }
                pending.0[p] = PlayerAction::Place {
                    x: cursor.x,
                    y: cursor.y,
                    dir,
                };
            }
        }
        if keys.just_pressed(map.remove) {
            pending.0[p] = PlayerAction::Remove {
                x: cursor.x,
                y: cursor.y,
            };
        }
        // Clear-all: queue one removal per tick until none remain, staying
        // inside the one-action-per-tick input model.
        if keys.pressed(map.clear_all)
            && let Some((x, y)) = board.first_signpost_of(cursor.player)
        {
            pending.0[p] = PlayerAction::Remove { x, y };
        }
    }
}

/// Versus results: Enter continues a running series, otherwise back to
/// the menu - or, for a match formed in the beach lobby, back to that
/// lobby with the whole table still connected.
///
/// Online, the host's Enter calls the next round for the whole table,
/// admitting anyone who queued while this one played, and every peer is
/// walked back into the arena when the invitation lands. Mid-series a
/// joiner's Enter is its own way out, since the series plays on without it;
/// once the match is over, Enter takes everyone back to the lobby.
pub fn versus_over_input(
    keys: Res<ButtonInput<KeyCode>>,
    pause: Res<crate::app::pause::PauseMenu>,
    mut tournament: ResMut<crate::app::tournament::Tournament>,
    mut online: ResMut<crate::app::net::Online>,
    mut homecoming: ResMut<crate::app::lobby::Homecoming>,
    mut next_screen: ResMut<NextState<Screen>>,
) {
    let series_on = tournament.is_running();
    // The invitation may already have arrived, in which case nobody
    // pressed anything here: the host decided and this peer follows.
    //
    // Left set on purpose: `end_versus` reads it as "keep me", and the
    // interlude clears it once the session is safely through.
    if online.0.as_ref().is_some_and(|session| session.next_round) {
        next_screen.set(Screen::Interlude);
        return;
    }
    // The pause card's Enter is the card's. Not gated in the schedule like
    // the other play keys, because the invitation above is followed with
    // the card up or not.
    if pause.open || !crate::app::menu_ui::enter(&keys) {
        return;
    }
    match after_round(&online, series_on) {
        AfterRound::NextRound => {
            // Online, that is the host, and only the host: the seats are
            // re-dealt for the next round and the series tally with them,
            // the returned standing being the same wins on the chairs
            // their holders now sit in.
            if let Some(session) = &mut online.0 {
                let standing = session.call_next_round(
                    crate::app::match_setup::next_round_terms(
                        session.terms,
                        session.seats,
                        crate::app::clock::fresh_seed(),
                    ),
                    tournament.standing(),
                );
                if let Some(crate::transport::SeriesStanding { round, wins }) = standing {
                    tournament.round = round;
                    tournament.wins = wins;
                }
            }
            next_screen.set(Screen::Interlude);
        }
        AfterRound::Menu => next_screen.set(Screen::Menu),
        AfterRound::Lobby => {
            // The match is over and it was formed in the lobby: the whole
            // table goes back there together, sockets and all.
            let session = online.0.take().expect("the lobby door needs a session");
            homecoming.0 = Some(session.back_to_the_lobby());
            next_screen.set(Screen::Lobby);
        }
    }
}

/// What Enter does when the tide is in, named by the one function the key
/// itself reads.
///
/// The prompt line, the spectators' line and the results card all want
/// it, and each used to spell it out for itself: a flat "Enter: menu" for
/// a spectator walked back to the lobby, and "Enter: next round" on the
/// card of a joiner whose Enter leaves the series.
pub fn enter_door(
    tr: &crate::app::i18n::Tr,
    online: &crate::app::net::Online,
    series_on: bool,
) -> &'static str {
    match after_round(online, series_on) {
        AfterRound::NextRound => tr.tour_next,
        AfterRound::Lobby => tr.prompt_enter_lobby,
        AfterRound::Menu => tr.prompt_enter_menu,
    }
}

/// Where Enter leads once a versus round is over.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum AfterRound {
    /// On into the next round of a series, through the interlude.
    NextRound,
    /// Back to the lobby the whole table came from.
    Lobby,
    Menu,
}

/// Which door Enter opens when the tide has come in.
///
/// A function of its own because two readers need the answer: the key
/// itself, and the prompt line that tells the player what the key does.
/// Spelled out twice, they disagreed - mid-series the card said "Enter:
/// next round" over a prompt line promising the menu, which is the game
/// arguing with itself.
pub fn after_round(online: &crate::app::net::Online, series_on: bool) -> AfterRound {
    let Some(session) = &online.0 else {
        // Local: a series plays on, a single round is done with.
        return match series_on {
            true => AfterRound::NextRound,
            false => AfterRound::Menu,
        };
    };
    if session.is_host() && series_on {
        return AfterRound::NextRound;
    }
    // Mid-series a joiner's Enter still means leaving, since the series
    // plays on without it, and a direct `PINCH_HOST` pair has no lobby to
    // go back to.
    match series_on || !session.home.from_lobby {
        true => AfterRound::Menu,
        false => AfterRound::Lobby,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::app::campaign::CampaignKind;
    use crate::app::progress::Progress;
    use crate::sim::{Level, campaign_levels};

    /// The prompt line and the key have to name the same door. At a local
    /// table Enter carries a series on into the next round, and the prompt
    /// used to say "Enter: menu" under a card reading "Enter: next round".
    #[test]
    fn a_local_series_carries_enter_on_to_the_next_round() {
        let alone = crate::app::net::Online::default();
        assert_eq!(after_round(&alone, true), AfterRound::NextRound);
        assert_eq!(after_round(&alone, false), AfterRound::Menu);
    }

    /// Clear-all in setup takes every arrow up at once.
    #[test]
    fn clear_all_empties_a_puzzle_setup() {
        let level = campaign_levels()
            .into_iter()
            .find(|level| level.posts >= 2)
            .expect("a stage with two arrows");
        let mut sim = Sim(level.board());
        let mut placed = 0;
        for (x, y, _) in sim.0.tiles().collect::<Vec<_>>() {
            if placed < 2 && sim.0.place_signpost(0, x, y, crate::sim::Direction::Up) {
                placed += 1;
            }
        }
        assert_eq!(sim.0.signpost_count(0), 2);
        clear_setup(&mut sim);
        assert_eq!(sim.0.signpost_count(0), 0);
    }

    /// Three shipped stages, and one of the player's own behind them when
    /// asked for.
    fn campaign_with(index: usize, mine: bool) -> Campaign {
        let mut levels: Vec<Level> = campaign_levels().into_iter().take(3).collect();
        if mine {
            let mut mine = levels[0].clone();
            mine.name = "Mine".into();
            levels.push(mine);
        }
        Campaign {
            kind: CampaignKind::TidePool,
            levels,
            index,
            builtins: 3,
        }
    }

    fn campaign(index: usize) -> Campaign {
        campaign_with(index, true)
    }

    fn tap(app: &mut App, key: KeyCode) {
        let mut keys = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
        keys.reset_all();
        keys.press(key);
        app.update();
        app.world_mut()
            .resource_mut::<ButtonInput<KeyCode>>()
            .reset_all();
    }

    /// The won card, with the list at `index`.
    fn won(index: usize) -> App {
        let mut app = App::new();
        app.add_plugins(bevy::state::app::StatesPlugin);
        app.init_state::<Screen>();
        app.init_state::<Phase>();
        app.insert_resource(State::new(Screen::Puzzle));
        app.insert_resource(State::new(Phase::Won));
        app.init_resource::<ButtonInput<KeyCode>>();
        app.init_resource::<crate::app::keycaps::KeyCaps>();
        app.insert_resource(campaign(index));
        app.add_message::<LoadLevel>();
        app.add_systems(Update, done_input);
        app
    }

    fn loads(app: &App) -> usize {
        app.world()
            .resource::<Messages<LoadLevel>>()
            .iter_current_update_messages()
            .count()
    }

    /// The shipped campaign ends with its last shipped stage: Enter there
    /// goes to the menu rather than walking on into the player's own
    /// levels behind it, which are a shelf and not stage four.
    #[test]
    fn enter_after_the_last_shipped_stage_goes_to_the_menu() {
        let mut app = won(2);
        tap(&mut app, KeyCode::Enter);
        assert_eq!(loads(&app), 0, "nothing loaded");
        app.update();
        assert_eq!(*app.world().resource::<State<Screen>>().get(), Screen::Menu);
        assert_eq!(
            app.world().resource::<Campaign>().index,
            2,
            "the list stays put"
        );
    }

    /// In the middle of the list Enter loads the next stage, and the last
    /// of the player's own levels ends the list the same way the campaign
    /// ends.
    #[test]
    fn enter_walks_the_list_and_stops_at_its_end() {
        let mut app = won(0);
        tap(&mut app, KeyCode::Enter);
        assert_eq!(loads(&app), 1, "the next stage is loaded");
        assert_eq!(app.world().resource::<Campaign>().index, 1);
        app.update();
        assert_eq!(
            *app.world().resource::<State<Screen>>().get(),
            Screen::Puzzle
        );

        let mut app = won(3);
        tap(&mut app, KeyCode::Enter);
        assert_eq!(loads(&app), 0);
        app.update();
        assert_eq!(*app.world().resource::<State<Screen>>().get(), Screen::Menu);
    }

    /// Browsing from setup honours the ladder both ways: P from the first
    /// stage wraps onto the end of the shipped list, which is as locked as
    /// any stage ahead, and is refused with the same flash; once the first
    /// stage is cleared N opens the second, and P walks back. A level of
    /// the player's own is on the shelf behind them, and the wrap does not
    /// land on it: browsing stays within the shipped campaign.
    #[test]
    fn browsing_from_setup_honours_the_ladder_both_ways() {
        let mut app = App::new();
        app.add_plugins(bevy::state::app::StatesPlugin);
        app.init_state::<Phase>();
        app.insert_resource(State::new(Phase::Setup));
        app.init_resource::<ButtonInput<KeyCode>>();
        app.init_resource::<crate::app::keycaps::KeyCaps>();
        app.insert_resource(GameSettings::default());
        app.init_resource::<Progress>();
        app.insert_resource(Sim(campaign_with(0, true).levels[0].board()));
        app.insert_resource(campaign_with(0, true));
        app.add_message::<LoadLevel>();
        app.add_message::<PlacementDenied>();
        app.add_systems(Update, browse_levels);
        let mut cursor = Cursor::seated(0);
        (cursor.x, cursor.y) = (1, 1);
        app.world_mut().spawn((cursor, Transform::default()));
        let denials = |app: &App| {
            app.world()
                .resource::<Messages<PlacementDenied>>()
                .iter_current_update_messages()
                .count()
        };

        tap(&mut app, KeyCode::KeyP);
        assert_eq!(
            app.world().resource::<Campaign>().index,
            0,
            "the end is locked"
        );
        assert_eq!(denials(&app), 1, "and refused out loud");
        tap(&mut app, KeyCode::KeyN);
        assert_eq!(
            app.world().resource::<Campaign>().index,
            0,
            "so is the second"
        );
        assert_eq!(denials(&app), 1);

        let first = app.world().resource::<Campaign>().levels[0].name.clone();
        app.world_mut()
            .resource_mut::<Progress>()
            .mark(CampaignKind::TidePool, &first);
        tap(&mut app, KeyCode::KeyN);
        assert_eq!(
            app.world().resource::<Campaign>().index,
            1,
            "cleared, so open"
        );
        assert_eq!(loads(&app), 1);
        tap(&mut app, KeyCode::KeyP);
        assert_eq!(app.world().resource::<Campaign>().index, 0, "and back");
        assert_eq!(denials(&app), 0);
    }
}