介绍
一次计分只是一个判断。完整比赛必须记住使用过的类别,并在你纠正无效选择时保留相同骰子。你将完成一个三回合游戏,保存一张可依据骰子与类别重新计算并检查的记分卡。
第一实验的计分规则已经安装在这个独立项目中。你应了解这些规则、Rust 函数、enum、match、Option、数组,以及基本终端路径。本实验会在使用前介绍预备结构体、可变方法、Result 和切片。预计约需 55 分钟,包括阅读和普通错误处理;这是作者估计。
只提交合法选择
在本步骤中,你将设计并实现接受一次选择时的状态转换。从 Terminal 标签页开始。这个独立项目已具备完整计分规则,不使用第一实验的文件。设置当前 shell 的搜索路径并进入项目;新 shell 中需重复这些命令:
export PATH=/home/labex/.cargo/bin:$PATH
cd /home/labex/project/dice-game
先从正常可用的计分开始,运行前预测结果:
cargo run --quiet -- score pair 2 2 4 4 6
应显示这些骰子和 pair: 8。这证明添加比赛状态前,提供的规则层已经可用。阅读框架:
cat src/state.rs
struct 将描述一个对象的具名字段组合起来。Round 记录五枚骰子、类别和分数。Game 保存 current: Option<Dice> 和 rounds: Vec<Round>。Some(dice) 表示还有选择待提交;None 表示没有当前掷骰。Vec<Round> 是可增长的集合;Vec::new() 创建空集合,push 追加一项。
也阅读预备外壳中的调用方:
cat src/main.rs
找到 match game.submit(input.trim())。其 Err 分支打印拒绝,Ok 分支打印分数,然后只在记录不足三个回合时获取下一组骰子。你不编辑外壳。方法必须在拒绝时保持状态不变,让下一个提示显示相同骰子和掷骰次数。
预测每类提交后的字段:
| 提交 | 调用后的 rounds |
调用后的 current |
|---|---|---|
| 没有待选骰子、未知单词或已用类别 | 不变 | 不变 |
| 合法未用类别,包括零分 | 追加一条完整新记录 | None |
成功后提供下一组骰子的是外壳,不是 submit。不要在方法内掷骰。impl Game 将 Game 的方法组织起来。&mut self 允许方法通过可变引用更新该游戏。Result<u16, &'static str> 表示 Ok(points) 或 Err(message);&'static str 是固定错误消息文本的类型。与 Option 不同,Result 可以解释失败。
ok_or(message) 把 Some 转为 Ok,把 None 转为 Err。后续 ? 会用成功值继续,或立即返回错误。例如,检查待选掷骰可以写为:
let dice = self.current.ok_or("match finished")?;
对 Category::parse(input) 应用同样的思路。修改任何字段前,用 for 循环借用 &self.rounds 中的已有记录,并拒绝相同类别。检查的组织方式由你决定;表格就是契约。验证后,用 score(dice, category) 得到 points。成功提交有三个动作:
self.rounds.push(Round { dice, category, points });
self.current = None;
Ok(points)
简写字段名使用同名局部变量。零分仍然成功。被拒绝的路径不能执行这些修改。
保持 src/main.rs、Cargo.toml、公开类型、函数名和类型,以及模块导出不变。use crate::{score, Category, Dice} 从当前库导入;src/lib.rs 的 mod state 和 pub use 连接模块。本步只编辑 src/state.rs 的 submit,保留结构体、new 方法和未完成的 replay。
编辑前为这个允许修改的文件创建唯一备份。如前所学,mktemp -d 将六个 X 替换为唯一后缀。$() 保存其打印的路径;&& 只在目录创建成功后运行 cp -p。引号内路径仍为一个参数。失败时阅读错误,并成功获得备份后再编辑。
rs02_before_submit=$(mktemp -d .edit-before-submit.XXXXXX) && cp -p src/state.rs "$rs02_before_submit/state.rs"
用 nano 打开文件;-c 显示光标位置:
nano -c src/state.rs
按 Ctrl+W,搜索 pub fn submit,按 Enter。检查方法的起止花括号。只把未使用的 _input 参数绑定重命名为 input,不要改变类型。仅将未完成的函数体替换为你对上述检查与成功提交的实现。保留其他方法和数据定义。用方向键和 Backspace/Delete 编辑。按 Ctrl+O、Enter 保存,再按 Ctrl+X 退出。Nano 的 ^ 表示 Ctrl。如果有意外的未保存编辑,按 Ctrl+X,在保存问题处按 N 放弃;这不会撤销更早的已保存编辑。
运行前阅读保存的方法:
cat src/state.rs
用已知固定样例观察状态判断。--fixture 选择公开的掷骰序列,不使用随机源:
cargo run --quiet -- play --fixture
等待每次提示,输入一个单词并按 Enter:
先输入 pairs:未知类别,拒绝后仍显示相同的 [2, 2, 4, 4, 6] 和 rolls 1。
接着输入 pair:得 8 分,推进到 [1, 2, 3, 4, 6]、rolls 2。
再次输入 pair:已用类别被拒绝,骰子和 rolls 2 不变。
然后输入 straight:这个合法选择得零分,并推进到第三组骰子。
最后输入 sum:得 20 分。
结束时应出现 Error: replay not implemented,状态为 1。提交已经可用,但 replay 仍是原来未完成的函数,所以外壳拒绝保存未经检查的卡。这是计划中的中间状态,不是成功保存的游戏。如果意外选择另一合法类别,可完成剩余未用选择,或按 Ctrl+C,有意开始新固定样例比赛。同一比赛内,拒绝绝不能重新开始或重新掷骰。
对于编译错误,阅读报告的文件/行号,重新打开 nano,搜索受影响的方法并修复。对于无法修复的已保存错误,恢复自己的提交前文件,再实现并测试 submit:
仅在恢复已保存错误时:原 Terminal 中的检查点变量仍保存路径。在新 Terminal 中,先重复之前的 PATH 和项目目录命令。列出本阶段备份:
ls -d .edit-before-submit.*
ls -d 列出目录名而非内容;* 匹配本阶段唯一目录的后缀。这些是真实生成的名称,不要编造后缀。如果没有匹配备份,确认项目目录及是否创建过检查点;不要从无关阶段复制。选择一个打印出的路径时,运行:
read rs02_before_submit
shell 会等待一行输入。粘贴所选目录的准确名称并按 Enter。read 将该行保存到本会话的具名变量中。恢复前确认备份内容:
cat "$rs02_before_submit/state.rs"
它应包含实现 submit 前的初始程序。如果路径或阶段错误,用正确的已打印路径重新执行 read 并再次检查。只有确认后才恢复。如果你不确定原 Terminal 变量指向哪个检查点,也应先做这项检查。
cp -p "$rs02_before_submit/state.rs" src/state.rs
该命令有意只替换允许编辑的状态文件。备份变量只属于当前 Terminal;另一个 shell 中应使用上述条件恢复说明。不要重置 setup 或替换准备好的外壳来修复状态错误。
重新计算并保存完整记分卡
在本步骤中,你将实现对每条回合记录的独立检查,再接受总分。继续在同一个 Terminal 中操作,工作目录为 /home/labex/project/dice-game;必要时重复之前的 export PATH 和 cd。保留完成的 submit 方法。
先手算拟议的固定样例卡:[2, 2, 4, 4, 6] 选 pair 得 8,[1, 2, 3, 4, 6] 选 straight 得 0,[6, 5, 4, 3, 2] 选 sum 得 20。重新计算的总分是 28。如果有人将第一行的 8 写成 10,累加存储分数会掩盖错误记录。记分卡不能用自己的存储总分或分数证明自己。
replay 接收切片 &[Round]:对记录的借用视图,不转移所有权,也不修改。编写前先设计检查。要求恰好三条记录。每条记录在调用 score 前拒绝 1 到 6 之外的点数,因为计分实现可能用点数作为数组索引。用小 Vec 跟踪已用类别;contains 检查当前类别是否出现过。拒绝重复。根据真实骰子和类别重新计分,与存储分数比较。只将重新计算的结果加入总分,全部记录有效后才返回总分。
条件失败时可提前 return Err("...")。在 for round in rounds 中,每条回合记录是借用的;round.dice 是小型 Copy 数组,可以检查而不改变记录。条件 !(1..=6).contains(&face) 表示点数在包含端点的合法范围之外。选择有意义的拒绝消息;实现可用不同方式组织这些判断,但需保留契约。
保存新的撤销点,包含已经可用的 submit。不要与更早的初始程序备份混淆:
rs02_before_replay=$(mktemp -d .edit-before-replay.XXXXXX) && cp -p src/state.rs "$rs02_before_replay/state.rs"
打开同一文件,用 Ctrl+W 搜索 pub fn replay:
nano -c src/state.rs
将未使用的 _rounds 参数绑定重命名为 rounds,保留其类型,只实现这个函数体。保留 submit、结构体和模块连接。使用按顺序设计的检查和累加器;不要只返回 28、信任存储分数或静默跳过无效记录。按 Ctrl+O、Enter 保存,按 Ctrl+X 退出。阅读保存的代码:
cat src/state.rs
再次运行固定样例:
cargo run --quiet -- play --fixture
依次输入 pair、straight、sum,每次等待提示。最终输出包含:
1 | [2, 2, 4, 4, 6] | pair | 8
2 | [1, 2, 3, 4, 6] | straight | 0
3 | [6, 5, 4, 3, 2] | sum | 20
Replayed total: 28
Saved match.tsv
准备好的外壳只在完整卡通过 replay 后才写入 match.tsv。每行是五个以空格分隔的点数、制表符、类别、另一个制表符和分数。这个格式由外壳处理;你不实现文件解析器。完成的新游戏替换这张课程专用卡,未完成游戏保留原卡不变。
读取并回放实际保存的文件:
cat match.tsv
cargo run --quiet -- replay match.tsv
应显示相同记录和总分 28,不显示 Saved match.tsv。回放读取准确路径,不修改文件,也不回退到样例。为检查你没有将之前的类别顺序写死到 submit,再玩一次固定样例:
cargo run --quiet -- play --fixture
依次使用 straight、sum、pair。预测 0、16 和 0:三次选择都合法,重新计算总分为 16。这场完成的游戏现在替换之前的卡。回放当前文件:
cargo run --quiet -- replay match.tsv
记录必须反映第二次顺序,而不是之前的 28 分比赛。总分不同是预期的,因为每组骰子选择的规则变了。下一步用相同判断处理真正的随机骰子。
对于编译错误,重新打开指出的文件,修复报告的 replay 行。必要时恢复自己的回放前检查点,它保留 submit,然后重新实现 replay:
仅在恢复已保存错误时:原 Terminal 中的检查点变量仍保存路径。在新 Terminal 中,先重复之前的 PATH 和项目目录命令。列出本阶段备份:
ls -d .edit-before-replay.*
ls -d 列出目录名而非内容;* 匹配本阶段唯一目录的后缀。这些是真实生成的名称,不要编造后缀。如果没有匹配备份,确认项目目录及是否创建过检查点;不要从无关阶段复制。选择一个打印出的路径时,运行:
read rs02_before_replay
shell 会等待一行输入。粘贴所选目录的准确名称并按 Enter。read 将该行保存到本会话的具名变量中。恢复前确认备份内容:
cat "$rs02_before_replay/state.rs"
它应包含实现 replay 前已经可用的 submit。如果路径或阶段错误,用正确的已打印路径重新执行 read 并再次检查。只有确认后才恢复。如果你不确定原 Terminal 变量指向哪个检查点,也应先做这项检查。
cp -p "$rs02_before_replay/state.rs" src/state.rs
恢复源码不改变已有记分卡。后续完成的游戏只在 replay 成功后替换它;失败游戏和读取卡不会替换。下方完整参考用于尝试后比较具体函数,不是正常学习中替换模块的操作。
用于比较和恢复的完整状态参考
保留字段和之前成功的工作。只比较需要修复的函数,在 nano 中编辑,再运行固定样例并回放真实卡。
use crate::{score, Category, Dice};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Round {
pub dice: Dice,
pub category: Category,
pub points: u16,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Game {
pub current: Option<Dice>,
pub rounds: Vec<Round>,
}
impl Game {
pub fn new(first: Dice) -> Self {
Self { current: Some(first), rounds: Vec::new() }
}
pub fn submit(&mut self, input: &str) -> Result<u16, &'static str> {
let dice = self.current.ok_or("match finished")?;
let category = Category::parse(input).ok_or("unknown category")?;
for round in &self.rounds {
if round.category == category {
return Err("category already used");
}
}
let points = score(dice, category);
self.rounds.push(Round { dice, category, points });
self.current = None;
Ok(points)
}
}
pub fn replay(rounds: &[Round]) -> Result<u16, &'static str> {
if rounds.len() != 3 {
return Err("card needs three rounds");
}
let mut used = Vec::new();
let mut total = 0;
for round in rounds {
for face in round.dice {
if !(1..=6).contains(&face) {
return Err("card has an invalid face");
}
}
if used.contains(&round.category) {
return Err("card repeats a category");
}
let expected = score(round.dice, round.category);
if expected != round.points {
return Err("card has incorrect points");
}
used.push(round.category);
total += expected;
}
Ok(total)
}
游玩并复用自己的比赛
在本步骤中,你将用完成的规则处理真正的新点数,并从一次普通文件路径错误中恢复。继续使用同一个 Terminal,工作目录为 /home/labex/project/dice-game。
不使用 --fixture 时,预备外壳从 /dev/urandom 读取随机字节,每次生成五个 1 到 6 的点数。这是本地练习游戏,不要求达到某个目标总分或采用获胜策略,但计分必须符合规则。sum、pair 和 straight 必须各使用一次,顺序任意。被拒绝的单词和重复类别都会保留当前掷骰结果。
开始一场新比赛:
cargo run --quiet -- play
阅读第一组骰子,选择尚未使用的类别并按 Enter。第二、三回合重复此操作。你也可以故意输入一次 pairs 观察拒绝,再改成一个可用类别。纠正时注意骰子和 rolls 计数没有变化。三次选择被接受后,检查三条记录和重新计算的总分。随机点数可能与固定样例不同;完成比赛会替换 match.tsv。
读回新记分卡:
cargo run --quiet -- replay match.tsv
记录必须与最近一次比赛一致,包括合法零分。为练习普通路径错误,下一条命令故意请求一个不存在的文件。应出现 Error: missing-card.tsv: ... 消息并以状态 1 退出,不能生成替代记分卡:
cargo run --quiet -- replay missing-card.tsv
使用真实文件名恢复:
cargo run --quiet -- replay match.tsv
两次回放都不会改变记分卡,它仍然可读。保留 src/main.rs、Cargo.toml 和公开 API。检查器在隔离副本中执行你的原始实现,并读取真实的 match.tsv,不会代你创建记分卡。
总结
你为游戏及其记录建模,明确返回成功或拒绝,并且只在验证后修改状态。无效选择保留当前掷骰,合法零分推进比赛,回放则重新计算完整记分卡。你尝试了新点数,并复用了真实保存的文件,无需依赖之前的 VM。



