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
//! The level list a puzzle run walks, and the player-made levels that join
//! the built-in ones.
use crate::app::editor;
use crate::sim::{Level, LevelKind};
use bevy::prelude::*;
/// Which level list the player is running.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub enum CampaignKind {
TidePool,
BeachDay,
}
impl CampaignKind {
/// Stable save-file key, so progress in one list is not progress in the
/// other.
pub fn key(self) -> &'static str {
match self {
CampaignKind::TidePool => "tide",
CampaignKind::BeachDay => "beach",
}
}
/// The list a save-file key names, if this build has it.
pub fn from_key(key: &str) -> Option<CampaignKind> {
[CampaignKind::TidePool, CampaignKind::BeachDay]
.into_iter()
.find(|kind| kind.key() == key)
}
}
#[derive(Resource)]
pub struct Campaign {
pub kind: CampaignKind,
pub levels: Vec<Level>,
pub index: usize,
/// How many leading entries of `levels` ship with the game. The rest are
/// the player's own, which the stage list never locks.
pub builtins: usize,
}
impl Campaign {
pub fn current(&self) -> &Level {
&self.levels[self.index]
}
/// Where the current level sits in its own section, and whether that
/// section is the player's own: their levels are a shelf behind the
/// shipped list rather than stage a hundred and one, so both the
/// hundredth stage and the first driftwood read as one of their own
/// count. The stage list draws the two sections apart the same way.
pub fn place(&self) -> (usize, usize, bool) {
match self.index < self.builtins {
true => (self.index + 1, self.builtins, false),
false => (
self.index - self.builtins + 1,
self.levels.len() - self.builtins,
true,
),
}
}
/// Whether the current level ends the run Enter is walking. Both
/// sections end one: the shipped campaign stops at its last shipped
/// stage rather than walking on into the player's own levels, and
/// those stop at the end of the list.
pub fn is_last(&self) -> bool {
let (place, of, _) = self.place();
place == of
}
/// Swap in a fresh level list and start from its first level.
/// A whole struct literal, so a new field cannot survive a reset unseen.
pub(crate) fn reset(&mut self, kind: CampaignKind, levels: Vec<Level>, builtins: usize) {
let builtins = builtins.min(levels.len());
*self = Campaign {
kind,
levels,
index: 0,
builtins,
};
}
}
/// The Tide Pool list: the shipped campaign, then whatever the player has
/// built, with the count of shipped levels (the ones the stage list gates).
pub(crate) fn tide_pool_levels() -> (Vec<Level>, usize) {
let mut levels = crate::sim::campaign_levels();
let builtins = levels.len();
levels.extend(custom_puzzles(load_custom_levels()));
disambiguate(&mut levels, builtins);
(levels, builtins)
}
/// Give every level on the list a name of its own. Progress is filed by
/// name, and so are the translated names and the hints, so a player's
/// puzzle called "Welcome Ashore" would share the shipped one's tick, its
/// French name and its hint. The shipped list is unique already; the
/// player's levels are renamed on the way in, in list order, with a count
/// after the name, and the file on disk keeps the name it was saved
/// under.
pub(crate) fn disambiguate(levels: &mut [Level], builtins: usize) {
let mut taken: std::collections::HashSet<String> = levels[..builtins.min(levels.len())]
.iter()
.map(|level| level.name.clone())
.collect();
for level in levels.iter_mut().skip(builtins) {
if taken.insert(level.name.clone()) {
continue;
}
// Claiming the name is the test for it.
let renamed = (2..)
.find_map(|n| {
let candidate = format!("{} ({n})", level.name);
taken.insert(candidate.clone()).then_some(candidate)
})
.unwrap_or_else(|| level.name.clone());
level.name = renamed;
}
}
/// The player's levels that are stages: the ones they built as puzzles, and
/// that have somebody to route. A beach built for a match is not a stage
/// with an unusual number of castles, and on the list it ends the
/// campaign.
pub fn custom_puzzles(levels: Vec<Level>) -> Vec<Level> {
levels
.into_iter()
.filter(|level| level.kind == LevelKind::Puzzle && level.crab_count() > 0)
.collect()
}
/// The player's levels that are beaches to fight over. The seat count is
/// the map dial's to check: a two-castle arena is a beach, just not one a
/// table of four can sit at.
pub fn custom_arenas(levels: Vec<Level>) -> Vec<Level> {
levels
.into_iter()
.filter(|level| level.kind == LevelKind::Arena)
.collect()
}
/// Everything on the player's shelf: the editor's old save slot plus
/// anything dropped into `levels/custom/` under the XDG data directory.
/// Unparseable files are skipped with a log line rather than breaking
/// startup; which list a level joins is [`Level::kind`]'s to say.
pub(crate) fn load_custom_levels() -> Vec<Level> {
load_custom_levels_in(&editor::legacy_save_path(), &editor::custom_dir())
}
/// [`load_custom_levels`] with both places named outright: the old single
/// save slot and the shelf directory. The real ones hang off the data
/// directory, read from the process environment, which a test cannot change
/// without racing every other test in the binary.
pub fn load_custom_levels_in(legacy: &std::path::Path, custom: &std::path::Path) -> Vec<Level> {
let mut paths = vec![legacy.to_path_buf()];
if let Ok(dir) = std::fs::read_dir(custom) {
let mut extra: Vec<_> = dir
.filter_map(Result::ok)
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|ext| ext == "txt"))
.collect();
extra.sort();
paths.extend(extra);
}
let mut levels = Vec::new();
for path in paths {
let Ok(text) = std::fs::read_to_string(&path) else {
continue; // absent is normal (nothing saved yet)
};
match Level::parse(&text) {
Ok(level) => {
info!(
"loaded custom {} {:?} from {}",
level.kind.token(),
level.name,
path.display()
);
// Said here, beside the line that read the file, and once
// per read: the filters below are asked the same question
// by two screens and would answer it twice over.
if level.kind == LevelKind::Puzzle && level.crab_count() == 0 {
warn!("{}: a puzzle with no crabs to route", path.display());
}
if level.kind == LevelKind::Arena && level.seats() < 2 {
warn!(
"{}: a beach with castles for {} - no table can sit at it",
path.display(),
level.seats()
);
}
levels.push(level);
}
Err(e) => warn!("skipping {}: {e}", path.display()),
}
}
levels
}
#[cfg(test)]
mod tests {
use super::*;
fn level(name: &str, kind: &str, crabs: bool) -> Level {
let crab = if crabs { "crab: 0,0 R L common\n" } else { "" };
Level::parse(&format!(
"name: {name}\nposts: 2\nkind: {kind}\n{crab}map:\n+-+-+-+\n|0 . 1|\n+-+-+-+\n"
))
.expect("a level")
}
/// The shelf splits by what the author chose, not by what the board
/// looks like: both of these have two castles and only one is a beach.
#[test]
fn the_shelf_splits_by_kind() {
let shelf = || {
vec![
level("Stage", "puzzle", true),
level("Beach", "arena", true),
level("Empty Beach", "arena", false),
level("Crabless", "puzzle", false),
]
};
let names = |levels: Vec<Level>| -> Vec<String> {
levels.into_iter().map(|level| level.name).collect()
};
assert_eq!(names(custom_puzzles(shelf())), ["Stage"]);
// A beach fed only by holes has no crab standing on it at the
// start, which is not a reason to keep it off the map dial.
assert_eq!(names(custom_arenas(shelf())), ["Beach", "Empty Beach"]);
}
/// The names progress is filed under have to be one each on the
/// shipped lists, or two stages would share a tick.
#[test]
fn shipped_level_names_are_unique() {
for levels in [
crate::sim::campaign_levels(),
crate::sim::challenge_levels(),
] {
let mut seen = std::collections::HashSet::new();
for level in &levels {
assert!(seen.insert(level.name.clone()), "{:?} twice", level.name);
}
}
}
/// A player's level named like a shipped one, or like another of
/// theirs, is told apart on the list, so it does not inherit the
/// other's cleared tick. The shipped ones are left alone.
#[test]
fn same_named_player_levels_are_told_apart() {
let mut levels = vec![
level("Welcome Ashore", "puzzle", true),
level("Gull Alley", "puzzle", true),
level("Welcome Ashore", "puzzle", true),
level("Mine", "puzzle", true),
level("Mine", "puzzle", true),
level("Mine", "puzzle", true),
level("Mine (2)", "puzzle", true),
];
disambiguate(&mut levels, 2);
let names: Vec<&str> = levels.iter().map(|level| level.name.as_str()).collect();
assert_eq!(
names,
[
"Welcome Ashore",
"Gull Alley",
"Welcome Ashore (2)",
"Mine",
"Mine (2)",
"Mine (3)",
"Mine (2) (2)",
]
);
}
}