From 5e1fcfc57fe58a93827555404dc97e0941bfefa9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Andr=C3=A1s=20B=20Nagy?=
<20251272+BNAndras@users.noreply.github.com>
Date: Fri, 26 Jun 2026 20:24:34 -0700
Subject: [PATCH 1/2] Initial implementation, deprecate `hangman`
---
config.json | 9 +
exercises/Exercises.slnx | 1 +
.../save-the-cow/.docs/instructions.append.md | 9 +
.../save-the-cow/.docs/instructions.md | 7 +
.../save-the-cow/.docs/introduction.md | 4 +
exercises/practice/save-the-cow/.editorconfig | 141 +++++++++
.../practice/save-the-cow/.meta/Example.cs | 58 ++++
.../practice/save-the-cow/.meta/config.json | 23 ++
.../practice/save-the-cow/.meta/tests.toml | 40 +++
exercises/practice/save-the-cow/SaveTheCow.cs | 30 ++
.../practice/save-the-cow/SaveTheCow.csproj | 24 ++
.../practice/save-the-cow/SaveTheCowTests.cs | 299 ++++++++++++++++++
.../practice/save-the-cow/packages.lock.json | 177 +++++++++++
13 files changed, 822 insertions(+)
create mode 100644 exercises/practice/save-the-cow/.docs/instructions.append.md
create mode 100644 exercises/practice/save-the-cow/.docs/instructions.md
create mode 100644 exercises/practice/save-the-cow/.docs/introduction.md
create mode 100644 exercises/practice/save-the-cow/.editorconfig
create mode 100644 exercises/practice/save-the-cow/.meta/Example.cs
create mode 100644 exercises/practice/save-the-cow/.meta/config.json
create mode 100644 exercises/practice/save-the-cow/.meta/tests.toml
create mode 100644 exercises/practice/save-the-cow/SaveTheCow.cs
create mode 100644 exercises/practice/save-the-cow/SaveTheCow.csproj
create mode 100644 exercises/practice/save-the-cow/SaveTheCowTests.cs
create mode 100644 exercises/practice/save-the-cow/packages.lock.json
diff --git a/config.json b/config.json
index 0a0f666b88..b9d73368d2 100644
--- a/config.json
+++ b/config.json
@@ -1971,6 +1971,15 @@
"slug": "hangman",
"name": "Hangman",
"uuid": "c9b12d50-09dc-4f43-9e7e-66b277432347",
+ "practices": [],
+ "prerequisites": [],
+ "difficulty": 8,
+ "status": "deprecated"
+ },
+ {
+ "slug": "save-the-cow",
+ "name": "Save the Cow",
+ "uuid": "9c8d05ae-c260-4701-8d8d-78e5f9ff5b01",
"practices": [
"sets",
"immutability",
diff --git a/exercises/Exercises.slnx b/exercises/Exercises.slnx
index d56e3d758e..9ae9aca2e0 100644
--- a/exercises/Exercises.slnx
+++ b/exercises/Exercises.slnx
@@ -154,6 +154,7 @@
+
diff --git a/exercises/practice/save-the-cow/.docs/instructions.append.md b/exercises/practice/save-the-cow/.docs/instructions.append.md
new file mode 100644
index 0000000000..e2fdfa8592
--- /dev/null
+++ b/exercises/practice/save-the-cow/.docs/instructions.append.md
@@ -0,0 +1,9 @@
+# Instructions append
+
+## Hints
+
+This exercise requires you to work with Reactive extension. For more information, see [this page](http://reactivex.io/intro.html).
+
+In reactive programming it's easier to communicate intentions in marble diagrams.
+Tests are augmented with marble diagram information.
+Text format is parsable by [this tool](https://bitbucket.org/achary/rx-marbles/src/master/docs/syntax.md?fileviewer=file-view-default).
diff --git a/exercises/practice/save-the-cow/.docs/instructions.md b/exercises/practice/save-the-cow/.docs/instructions.md
new file mode 100644
index 0000000000..1ec98a43a4
--- /dev/null
+++ b/exercises/practice/save-the-cow/.docs/instructions.md
@@ -0,0 +1,7 @@
+# Instructions
+
+Implement the logic for a word-guessing game.
+
+A player tries to solve a secret word by guessing individual letters.
+They win if they reveal all the letters in the secret word.
+They lose if they make ten incorrect guesses before revealing the word.
diff --git a/exercises/practice/save-the-cow/.docs/introduction.md b/exercises/practice/save-the-cow/.docs/introduction.md
new file mode 100644
index 0000000000..c87a7fa928
--- /dev/null
+++ b/exercises/practice/save-the-cow/.docs/introduction.md
@@ -0,0 +1,4 @@
+# Introduction
+
+Bessie the cow has wandered onto an alien spaceship.
+Guess the secret door code to bring her home before the ship blasts off.
diff --git a/exercises/practice/save-the-cow/.editorconfig b/exercises/practice/save-the-cow/.editorconfig
new file mode 100644
index 0000000000..48c6015211
--- /dev/null
+++ b/exercises/practice/save-the-cow/.editorconfig
@@ -0,0 +1,141 @@
+###############################
+# Core EditorConfig Options #
+###############################
+
+; This file is for unifying the coding style for different editors and IDEs.
+; More information at:
+; https://docs.microsoft.com/en-us/visualstudio/ide/editorconfig-code-style-settings-reference?view=vs-2017
+; https://docs.microsoft.com/en-us/visualstudio/ide/create-portable-custom-editor-options?view=vs-2017
+
+root = true
+
+[*]
+indent_style = space
+
+[Satellite.cs]
+indent_size = 4
+
+###############################
+# .NET Coding Conventions #
+###############################
+
+# Organize usings
+dotnet_sort_system_directives_first = true
+dotnet_separate_import_directive_groups = true
+
+# this. preferences
+dotnet_style_qualification_for_field = false:suggestion
+dotnet_style_qualification_for_property = false:suggestion
+dotnet_style_qualification_for_method = false:suggestion
+dotnet_style_qualification_for_event = false:suggestion
+
+# Language keywords vs BCL types preferences
+dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion
+dotnet_style_predefined_type_for_member_access = true:suggestion
+
+# Parentheses preferences
+dotnet_style_parentheses_in_arithmetic_binary_operators = never_if_unnecessary:none
+dotnet_style_parentheses_in_relational_binary_operators = never_if_unnecessary:none
+dotnet_style_parentheses_in_other_binary_operators = never_if_unnecessary:none
+dotnet_style_parentheses_in_other_operators = never_if_unnecessary:suggestion
+
+# Modifier preferences
+dotnet_style_require_accessibility_modifiers = always:suggestion
+dotnet_style_readonly_field = true:suggestion
+
+# Expression-level preferences
+dotnet_style_object_initializer = true:suggestion
+dotnet_style_collection_initializer = true:suggestion
+dotnet_style_explicit_tuple_names = true:suggestion
+dotnet_style_prefer_inferred_tuple_names = true:suggestion
+dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion
+dotnet_style_prefer_auto_properties = true:suggestion
+dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion
+dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion
+dotnet_style_prefer_conditional_expression_over_return = true:suggestion
+dotnet_style_coalesce_expression = true:suggestion
+dotnet_style_null_propagation = true:suggestion
+
+###############################
+# Naming Conventions #
+###############################
+
+# Style Definitions
+dotnet_naming_style.pascal_case_style.capitalization = pascal_case
+
+# Use PascalCase for constant fields
+dotnet_naming_rule.constant_fields_should_be_pascal_case.severity = suggestion
+dotnet_naming_rule.constant_fields_should_be_pascal_case.symbols = constant_fields
+dotnet_naming_rule.constant_fields_should_be_pascal_case.style = pascal_case_style
+dotnet_naming_symbols.constant_fields.applicable_kinds = field
+dotnet_naming_symbols.constant_fields.applicable_accessibilities = *
+dotnet_naming_symbols.constant_fields.required_modifiers = const
+
+###############################
+# C# Code Style Rules #
+###############################
+
+# var preferences
+csharp_style_var_for_built_in_types = true:none
+csharp_style_var_when_type_is_apparent = true:none
+csharp_style_var_elsewhere = true:none
+
+# Expression-bodied members
+csharp_style_expression_bodied_methods = true:suggestion
+csharp_style_expression_bodied_constructors = true:suggestion
+csharp_style_expression_bodied_operators = true:suggestion
+csharp_style_expression_bodied_properties = true:suggestion
+csharp_style_expression_bodied_indexers = true:suggestion
+csharp_style_expression_bodied_accessors = true:suggestion
+
+# Pattern-matching preferences
+csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
+csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
+
+# Null-checking preferences
+csharp_style_throw_expression = true:suggestion
+csharp_style_conditional_delegate_call = true:suggestion
+
+# Modifier preferences
+csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:suggestion
+
+# Expression-level preferences
+csharp_prefer_braces = true:none
+csharp_prefer_simple_default_expression = true:suggestion
+csharp_style_deconstructed_variable_declaration = true:suggestion
+csharp_style_pattern_local_over_anonymous_function = true:suggestion
+csharp_style_inlined_variable_declaration = true:suggestion
+
+###############################
+# C# Formatting Rules #
+###############################
+
+# New line preferences
+csharp_new_line_before_open_brace = all
+csharp_new_line_before_else = true
+csharp_new_line_before_catch = true
+csharp_new_line_before_finally = true
+csharp_new_line_before_members_in_object_initializers = false
+csharp_new_line_before_members_in_anonymous_types = false
+csharp_new_line_between_query_expression_clauses = true
+
+# Indentation preferences
+csharp_indent_case_contents = true
+csharp_indent_switch_labels = true
+csharp_indent_labels = flush_left
+
+# Space preferences
+csharp_space_after_cast = false
+csharp_space_after_keywords_in_control_flow_statements = true
+csharp_space_between_method_declaration_parameter_list_parentheses = false
+csharp_space_between_method_call_parameter_list_parentheses = false
+csharp_space_before_colon_in_inheritance_clause = true
+csharp_space_after_colon_in_inheritance_clause = true
+csharp_space_around_binary_operators = before_and_after
+csharp_space_between_method_declaration_empty_parameter_list_parentheses = false
+csharp_space_between_method_call_name_and_opening_parenthesis = false
+csharp_space_between_method_call_empty_parameter_list_parentheses = false
+
+# Wrapping preferences
+csharp_preserve_single_line_blocks = true
+csharp_preserve_single_line_statements = true
diff --git a/exercises/practice/save-the-cow/.meta/Example.cs b/exercises/practice/save-the-cow/.meta/Example.cs
new file mode 100644
index 0000000000..5c85a26500
--- /dev/null
+++ b/exercises/practice/save-the-cow/.meta/Example.cs
@@ -0,0 +1,58 @@
+using System.Collections.Immutable;
+using System.Reactive;
+using System.Reactive.Subjects;
+
+public class GameState
+{
+ public string MaskedWord { get; }
+ public ImmutableHashSet GuessedChars { get; }
+ public int RemainingGuesses { get; }
+
+ public GameState(string maskedWord, ImmutableHashSet guessedChars, int remainingGuesses)
+ {
+ MaskedWord = maskedWord;
+ GuessedChars = guessedChars;
+ RemainingGuesses = remainingGuesses;
+ }
+}
+
+public class TooManyGuessesException : Exception
+{
+}
+
+public class SaveTheCow
+{
+ public IObservable StateObservable { get; }
+ public IObserver GuessObserver { get; }
+ private const char HidingChar = '_';
+ private const int MaxGuessCount = 9;
+
+ public SaveTheCow(string word)
+ {
+ HashSet emptySetOfChars = new HashSet();
+ var stateSubject = new BehaviorSubject(new GameState(MaskedWord(word, emptySetOfChars), emptySetOfChars.ToImmutableHashSet(), MaxGuessCount));
+
+ StateObservable = stateSubject;
+
+ GuessObserver = Observer.Create(x =>
+ {
+ HashSet guessedChars = new HashSet(stateSubject.Value.GuessedChars);
+ bool isHit = !guessedChars.Contains(x) && word.Contains(x);
+ guessedChars.Add(x);
+ string maskedWord = MaskedWord(word, guessedChars);
+ if (maskedWord == word)
+ stateSubject.OnCompleted();
+ else if (stateSubject.Value.RemainingGuesses < 1)
+ stateSubject.OnError(new TooManyGuessesException());
+ else
+ stateSubject.OnNext(new GameState(maskedWord, guessedChars.ToImmutableHashSet(),
+ isHit ? stateSubject.Value.RemainingGuesses : stateSubject.Value.RemainingGuesses - 1));
+ });
+ }
+
+ private string MaskedWord(string word, HashSet guessedChars)
+ {
+ return string.Concat(word.Select(y => guessedChars.Contains(y) ? y : HidingChar));
+ }
+}
+
diff --git a/exercises/practice/save-the-cow/.meta/config.json b/exercises/practice/save-the-cow/.meta/config.json
new file mode 100644
index 0000000000..9ebd6dfb36
--- /dev/null
+++ b/exercises/practice/save-the-cow/.meta/config.json
@@ -0,0 +1,23 @@
+{
+ "authors": [
+ "erikschierboom"
+ ],
+ "contributors": [
+ "BNAndras"
+ ],
+ "files": {
+ "solution": [
+ "SaveTheCow.cs"
+ ],
+ "test": [
+ "SaveTheCowTests.cs"
+ ],
+ "example": [
+ ".meta/Example.cs"
+ ],
+ "invalidator": [
+ "SaveTheCow.csproj"
+ ]
+ },
+ "blurb": "Implement a word-guessing game."
+}
diff --git a/exercises/practice/save-the-cow/.meta/tests.toml b/exercises/practice/save-the-cow/.meta/tests.toml
new file mode 100644
index 0000000000..eca72c42c6
--- /dev/null
+++ b/exercises/practice/save-the-cow/.meta/tests.toml
@@ -0,0 +1,40 @@
+# This is an auto-generated file.
+#
+# Regenerating this file via `configlet sync` will:
+# - Recreate every `description` key/value pair
+# - Recreate every `reimplements` key/value pair, where they exist in problem-specifications
+# - Remove any `include = true` key/value pair (an omitted `include` key implies inclusion)
+# - Preserve any other key/value pair
+#
+# As user-added comments (using the # character) will be removed when this file
+# is regenerated, comments can be added via a `comment` key.
+
+[71d340f9-fc29-4826-872e-ad7d0b83dd98]
+description = "Initially 9 failures are allowed and no letters are guessed"
+
+[76759c24-8f1a-4fc8-9ffd-d6a1ff0cf03b]
+description = "After 10 failures the game is over"
+
+[d6f2e202-7857-46fb-b709-43a7f3f3b2de]
+description = "Losing with several correct guesses"
+
+[71bc0cda-2032-4637-80c8-fc8771124c08]
+description = "Feeding a correct letter removes underscores"
+
+[5b568a1c-867d-418f-97a8-7b6f8a7ca0a2]
+description = "Feeding a correct letter twice counts as a failure"
+
+[3d40f15b-0271-4c5d-b1a4-3e1f66ff221f]
+description = "Guessing a repeated letter reveals all instances"
+
+[11a86435-e401-4250-a26e-3b0d8c4049ad]
+description = "Getting all the letters right makes for a win"
+
+[b3d81876-84ee-45bb-b531-baa1b05b4709]
+description = "Winning on the last guess is still a win"
+
+[cf204398-5e9f-402f-8bff-42cb7cbbbda9]
+description = "Guessing after a lose is error"
+
+[c2ec5b3d-4923-4a0e-a485-6aa6e78c7ece]
+description = "Guessing after a win is error"
diff --git a/exercises/practice/save-the-cow/SaveTheCow.cs b/exercises/practice/save-the-cow/SaveTheCow.cs
new file mode 100644
index 0000000000..7f687102ac
--- /dev/null
+++ b/exercises/practice/save-the-cow/SaveTheCow.cs
@@ -0,0 +1,30 @@
+using System.Collections.Immutable;
+
+public class GameState
+{
+ public string MaskedWord { get; }
+ public ImmutableHashSet GuessedChars { get; }
+ public int RemainingGuesses { get; }
+
+ public GameState(string maskedWord, ImmutableHashSet guessedChars, int remainingGuesses)
+ {
+ MaskedWord = maskedWord;
+ GuessedChars = guessedChars;
+ RemainingGuesses = remainingGuesses;
+ }
+}
+
+public class TooManyGuessesException : Exception
+{
+}
+
+public class SaveTheCow
+{
+ public IObservable StateObservable { get => throw new NotImplementedException("You need to implement this method."); }
+ public IObserver GuessObserver { get => throw new NotImplementedException("You need to implement this method."); }
+
+ public SaveTheCow(string word)
+ {
+ throw new NotImplementedException("You need to implement this method.");
+ }
+}
diff --git a/exercises/practice/save-the-cow/SaveTheCow.csproj b/exercises/practice/save-the-cow/SaveTheCow.csproj
new file mode 100644
index 0000000000..51a8228616
--- /dev/null
+++ b/exercises/practice/save-the-cow/SaveTheCow.csproj
@@ -0,0 +1,24 @@
+
+
+
+ net10.0
+ Exe
+ enable
+ enable
+ true
+ Exercism
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/exercises/practice/save-the-cow/SaveTheCowTests.cs b/exercises/practice/save-the-cow/SaveTheCowTests.cs
new file mode 100644
index 0000000000..f49cc5af86
--- /dev/null
+++ b/exercises/practice/save-the-cow/SaveTheCowTests.cs
@@ -0,0 +1,299 @@
+using Microsoft.Reactive.Testing;
+using System.Collections.Immutable;
+using System.Reactive.Concurrency;
+
+public class SaveTheCowTests : ReactiveTest
+{
+ [Fact]
+ public void Initial_state_masks_the_word()
+ {
+ var game = new SaveTheCow("foo");
+ var actual = "";
+
+ // +a->
+ game.StateObservable.Subscribe(
+ x => actual = x.MaskedWord,
+ ex => throw new Exception("Should not finish with too many tries"),
+ () => throw new Exception("Should not win yet"));
+ Assert.Equal("___", actual);
+ }
+
+ [Fact(Skip = "Remove this Skip property to run this test")]
+ public void Initial_state_has_9_remaining_guesses()
+ {
+ var game = new SaveTheCow("foo");
+ var actual = 9;
+
+ // +a->
+ game.StateObservable.Subscribe(x => actual = x.RemainingGuesses);
+
+ Assert.Equal(9, actual);
+ }
+
+ [Fact(Skip = "Remove this Skip property to run this test")]
+ public void Initial_state_has_no_guessed_chars()
+ {
+ var game = new SaveTheCow("foo");
+ var actual = new HashSet {'x'}.ToImmutableHashSet();
+
+ // +a->
+ game.StateObservable.Subscribe(x => actual = x.GuessedChars);
+
+ Assert.Equal(new HashSet().ToImmutableHashSet(), actual);
+ }
+
+ [Fact(Skip = "Remove this Skip property to run this test")]
+ public void Guess_changes_state()
+ {
+ var game = new SaveTheCow("foo");
+ GameState? actual = null;
+ game.StateObservable.Subscribe(x => actual = x);
+
+ Assert.NotNull(actual);
+ var initial = actual;
+
+ // +--x->
+ // +a-b->
+ game.GuessObserver.OnNext('x');
+
+ Assert.NotEqual(initial, actual);
+ }
+
+ [Fact(Skip = "Remove this Skip property to run this test")]
+ public void Wrong_guess_decrements_remaining_guesses()
+ {
+ var game = new SaveTheCow("foo");
+ GameState? actual = null;
+ game.StateObservable.Subscribe(x => actual = x);
+
+ Assert.NotNull(actual);
+ var initial = actual;
+
+ // +--x->
+ // +a-b->
+ game.GuessObserver.OnNext('x');
+
+ Assert.Equal(initial.RemainingGuesses - 1, actual.RemainingGuesses);
+ }
+
+ [Fact(Skip = "Remove this Skip property to run this test")]
+ public void After_10_incorrect_guesses_the_game_is_over()
+ {
+ var scheduler = new TestScheduler();
+ IObservable Create()
+ {
+ var game = new SaveTheCow("foo");
+ for (var i = 1; i <= 10; i++)
+ {
+ scheduler.Schedule(TimeSpan.FromTicks(i * 100), () => game.GuessObserver.OnNext('x'));
+ }
+
+ return game.StateObservable;
+ }
+
+ var expected = new[]
+ {
+ OnNext(100, gameState => gameState.RemainingGuesses == 9),
+ OnNext(200, gameState => gameState.RemainingGuesses == 8),
+ OnNext(300, gameState => gameState.RemainingGuesses == 7),
+ OnNext(400, gameState => gameState.RemainingGuesses == 6),
+ OnNext(500, gameState => gameState.RemainingGuesses == 5),
+ OnNext(600, gameState => gameState.RemainingGuesses == 4),
+ OnNext(700, gameState => gameState.RemainingGuesses == 3),
+ OnNext(800, gameState => gameState.RemainingGuesses == 2),
+ OnNext(900, gameState => gameState.RemainingGuesses == 1),
+ OnNext(1000, gameState => gameState.RemainingGuesses == 0),
+ OnError(1100, ex => ex is TooManyGuessesException)
+ };
+
+ // +--x-x-x-x-x-x-x-x-x-x->
+ // +a-b-c-d-e-f-g-h-i-j-#
+ ITestableObserver testableObserver = scheduler.Start(Create, 100, 100, 3000);
+
+ ReactiveAssert.AreElementsEqual(expected, testableObserver.Messages);
+ }
+
+ [Fact(Skip = "Remove this Skip property to run this test")]
+ public void Correctly_guessing_a_letter_unmasks_it()
+ {
+ var scheduler = new TestScheduler();
+ IObservable Create()
+ {
+ var game = new SaveTheCow("foobar");
+ scheduler.Schedule(TimeSpan.FromTicks(100), () => game.GuessObserver.OnNext('b'));
+ scheduler.Schedule(TimeSpan.FromTicks(200), () => game.GuessObserver.OnNext('o'));
+ return game.StateObservable;
+ }
+
+ var expected = new[]
+ {
+ OnNext(100, gameState => gameState.RemainingGuesses == 9 && gameState.MaskedWord == "______"),
+ OnNext(200, gameState => gameState.RemainingGuesses == 9 && gameState.MaskedWord == "___b__"),
+ OnNext(300, gameState => gameState.RemainingGuesses == 9 && gameState.MaskedWord == "_oob__")
+ };
+
+ // +--b-o->
+ // +a-b-c->
+ ITestableObserver testableObserver = scheduler.Start(Create, 100, 100, 3000);
+
+ ReactiveAssert.AreElementsEqual(expected, testableObserver.Messages);
+ }
+
+ [Fact(Skip = "Remove this Skip property to run this test")]
+ public void Guessing_a_correct_letter_twice_counts_as_a_failure()
+ {
+ var scheduler = new TestScheduler();
+ IObservable Create()
+ {
+ var game = new SaveTheCow("foobar");
+ scheduler.Schedule(TimeSpan.FromTicks(100), () => game.GuessObserver.OnNext('b'));
+ scheduler.Schedule(TimeSpan.FromTicks(200), () => game.GuessObserver.OnNext('b'));
+ return game.StateObservable;
+ }
+
+ var expected = new[]
+ {
+ OnNext(100, gameState => gameState.RemainingGuesses == 9 && gameState.MaskedWord == "______"),
+ OnNext(200, gameState => gameState.RemainingGuesses == 9 && gameState.MaskedWord == "___b__"),
+ OnNext(300, gameState => gameState.RemainingGuesses == 8 && gameState.MaskedWord == "___b__")
+ };
+
+ // +--b-b->
+ // +a-b-c->
+ ITestableObserver testableObserver = scheduler.Start(Create, 100, 100, 3000);
+
+ ReactiveAssert.AreElementsEqual(expected, testableObserver.Messages);
+ }
+
+ [Fact(Skip = "Remove this Skip property to run this test")]
+ public void Getting_all_the_letters_right_makes_for_a_win()
+ {
+ var scheduler = new TestScheduler();
+ IObservable Create()
+ {
+ var game = new SaveTheCow("hello");
+ scheduler.Schedule(TimeSpan.FromTicks(100), () => game.GuessObserver.OnNext('b'));
+ scheduler.Schedule(TimeSpan.FromTicks(200), () => game.GuessObserver.OnNext('e'));
+ scheduler.Schedule(TimeSpan.FromTicks(300), () => game.GuessObserver.OnNext('l'));
+ scheduler.Schedule(TimeSpan.FromTicks(400), () => game.GuessObserver.OnNext('o'));
+ scheduler.Schedule(TimeSpan.FromTicks(500), () => game.GuessObserver.OnNext('h'));
+ return game.StateObservable;
+ }
+
+ var expected = new[]
+ {
+ OnNext(100, gameState => gameState.RemainingGuesses == 9 && gameState.MaskedWord == "_____"),
+ OnNext(200, gameState => gameState.RemainingGuesses == 8 && gameState.MaskedWord == "_____"),
+ OnNext(300, gameState => gameState.RemainingGuesses == 8 && gameState.MaskedWord == "_e___"),
+ OnNext(400, gameState => gameState.RemainingGuesses == 8 && gameState.MaskedWord == "_ell_"),
+ OnNext(500, gameState => gameState.RemainingGuesses == 8 && gameState.MaskedWord == "_ello"),
+ OnCompleted(600)
+ };
+
+ // +--b-e-l-o-h->
+ // +a-b-c-d-e-|
+ ITestableObserver testableObserver = scheduler.Start(Create, 100, 100, 3000);
+
+ ReactiveAssert.AreElementsEqual(expected, testableObserver.Messages);
+ }
+
+ // Advanced mode on>
+ [Fact(Skip = "Remove this Skip property to run this test")]
+ public void Second_player_sees_the_same_game_already_started()
+ {
+ var scheduler = new TestScheduler();
+ var player2 = scheduler.CreateObserver();
+ var game = new SaveTheCow("hello");
+
+ var player1 = game.StateObservable;
+ Ready(player1);
+
+ scheduler.Schedule(TimeSpan.FromTicks(100), () => game.GuessObserver.OnNext('e'));
+ scheduler.Schedule(TimeSpan.FromTicks(200), () => game.GuessObserver.OnNext('l'));
+ scheduler.Schedule(TimeSpan.FromTicks(150), () => game.StateObservable.Subscribe(player2));
+
+ var expected = new[]
+ {
+ OnNext(150, gameState => gameState.RemainingGuesses == 9 && gameState.MaskedWord == "_e___"),
+ OnNext(200, gameState => gameState.RemainingGuesses == 9 && gameState.MaskedWord == "_ell_")
+ };
+
+ // +--e--l->
+ // +a-b--c->
+ // ...+b-c->
+ scheduler.Start();
+
+ ReactiveAssert.AreElementsEqual(expected, player2.Messages);
+ }
+
+ private IDisposable Ready(IObservable player)
+ {
+ return player.Subscribe(x => { });
+ }
+
+ // Expert mode on>
+ [Fact(Skip = "Remove this Skip property to run this test")]
+ public void Multiple_players_see_the_same_game_already_started()
+ {
+ var scheduler = new TestScheduler();
+ var player2 = scheduler.CreateObserver();
+ var player3 = scheduler.CreateObserver();
+ var game = new SaveTheCow("hello");
+
+ var player1 = game.StateObservable;
+ Ready(player1);
+
+ scheduler.Schedule(TimeSpan.FromTicks(100), () => game.GuessObserver.OnNext('e'));
+ scheduler.Schedule(TimeSpan.FromTicks(200), () => game.GuessObserver.OnNext('l'));
+ scheduler.Schedule(TimeSpan.FromTicks(150), () =>
+ {
+ game.StateObservable.Subscribe(player2);
+ game.StateObservable.Subscribe(player3);
+ });
+
+ var expected = new[]
+ {
+ OnNext(150, gameState => gameState.RemainingGuesses == 9 && gameState.MaskedWord == "_e___"),
+ OnNext(200, gameState => gameState.RemainingGuesses == 9 && gameState.MaskedWord == "_ell_"),
+ };
+
+ // +--e--l->
+ // +a-b--c->
+ // ...+b-c->
+ // ...+b-c->
+ scheduler.Start();
+
+ ReactiveAssert.AreElementsEqual(expected, player2.Messages);
+ ReactiveAssert.AreElementsEqual(expected, player3.Messages);
+ }
+
+ [Fact(Skip = "Remove this Skip property to run this test")]
+ public void Player_joins_after_other_players_quit()
+ {
+ var scheduler = new TestScheduler();
+ var player2 = scheduler.CreateObserver();
+ var game = new SaveTheCow("a");
+
+ var player1 = game.StateObservable;
+ var subscription = Ready(player1);
+
+ scheduler.Schedule(TimeSpan.FromTicks(100), () => game.GuessObserver.OnNext('a'));
+ scheduler.Schedule(TimeSpan.FromTicks(300), () =>
+ {
+ game.StateObservable.Subscribe(player2);
+ });
+ scheduler.Schedule(TimeSpan.FromTicks(200), () => subscription.Dispose());
+
+ var expected = new[]
+ {
+ OnCompleted(300)
+ };
+
+ // +--a-|
+ // +a-|
+ // .....+|
+ scheduler.Start();
+
+ ReactiveAssert.AreElementsEqual(expected, player2.Messages);
+ }
+}
diff --git a/exercises/practice/save-the-cow/packages.lock.json b/exercises/practice/save-the-cow/packages.lock.json
new file mode 100644
index 0000000000..03e17b0b11
--- /dev/null
+++ b/exercises/practice/save-the-cow/packages.lock.json
@@ -0,0 +1,177 @@
+{
+ "version": 1,
+ "dependencies": {
+ "net10.0": {
+ "Exercism.Tests.xunit.v3": {
+ "type": "Direct",
+ "requested": "[0.1.0-beta1, )",
+ "resolved": "0.1.0-beta1",
+ "contentHash": "XjVtQWWxmHDDj7UMdkPKpBFFKnsW0tkBhlyJSfFFh+fWwGemyyJwJYhdsvWhiKKCY7zItB+mI/o0OQtOKQxUhA==",
+ "dependencies": {
+ "xunit.v3.extensibility.core": "1.1.0"
+ }
+ },
+ "Microsoft.NET.Test.Sdk": {
+ "type": "Direct",
+ "requested": "[18.3.0, )",
+ "resolved": "18.3.0",
+ "contentHash": "xW3kXuWRQtgoxJp4J+gdhHSQyK+6Wb/AZDSd7lMvuMRYlZ1tnpkojyfZlWilB5G4dmZ0Y0ZxU/M23TlubndNkw==",
+ "dependencies": {
+ "Microsoft.CodeCoverage": "18.3.0",
+ "Microsoft.TestPlatform.TestHost": "18.3.0"
+ }
+ },
+ "xunit.runner.visualstudio": {
+ "type": "Direct",
+ "requested": "[3.0.1, )",
+ "resolved": "3.0.1",
+ "contentHash": "lbyYtsBxA8Pz8kaf5Xn/Mj1mL9z2nlBWdZhqFaj66nxXBa4JwiTDm4eGcpSMet6du9TOWI6bfha+gQR6+IHawg=="
+ },
+ "xunit.v3": {
+ "type": "Direct",
+ "requested": "[3.2.2, )",
+ "resolved": "3.2.2",
+ "contentHash": "L+4/4y0Uqcg8/d6hfnxhnwh4j9FaeULvefTwrk30rr1o4n/vdPfyUQ8k0yzH8VJx7bmFEkDdcRfbtbjEHlaYcA==",
+ "dependencies": {
+ "xunit.v3.mtp-v1": "[3.2.2]"
+ }
+ },
+ "Microsoft.ApplicationInsights": {
+ "type": "Transitive",
+ "resolved": "2.23.0",
+ "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw=="
+ },
+ "Microsoft.Bcl.AsyncInterfaces": {
+ "type": "Transitive",
+ "resolved": "6.0.0",
+ "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg=="
+ },
+ "Microsoft.CodeCoverage": {
+ "type": "Transitive",
+ "resolved": "18.3.0",
+ "contentHash": "23BNy/vziREC20Wwhb50K7+kZe0m07KlLWDQv4qjJ9tt3QjpDpDIqJFrhYHmMEo9xDkuSp55U/8h4bMF7MiB+g=="
+ },
+ "Microsoft.Testing.Extensions.Telemetry": {
+ "type": "Transitive",
+ "resolved": "1.9.1",
+ "contentHash": "No5AudZMmSb+uNXjlgL2y3/stHD2IT4uxqc5yHwkE+/nNux9jbKcaJMvcp9SwgP4DVD8L9/P3OUz8mmmcvEIdQ==",
+ "dependencies": {
+ "Microsoft.ApplicationInsights": "2.23.0",
+ "Microsoft.Testing.Platform": "1.9.1"
+ }
+ },
+ "Microsoft.Testing.Extensions.TrxReport.Abstractions": {
+ "type": "Transitive",
+ "resolved": "1.9.1",
+ "contentHash": "AL46Xe1WBi85Ntd4mNPvat5ZSsZ2uejiVqoKCypr8J3wK0elA5xJ3AN4G/Q4GIwzUFnggZoH/DBjnr9J18IO/g==",
+ "dependencies": {
+ "Microsoft.Testing.Platform": "1.9.1"
+ }
+ },
+ "Microsoft.Testing.Platform": {
+ "type": "Transitive",
+ "resolved": "1.9.1",
+ "contentHash": "QafNtNSmEI0zazdebnsIkDKmFtTSpmx/5PLOjURWwozcPb3tvRxzosQSL8xwYNM1iPhhKiBksXZyRSE2COisrA=="
+ },
+ "Microsoft.Testing.Platform.MSBuild": {
+ "type": "Transitive",
+ "resolved": "1.9.1",
+ "contentHash": "oTUtyR4X/s9ytuiNA29FGsNCCH0rNmY5Wdm14NCKLjTM1cT9edVSlA+rGS/mVmusPqcP0l/x9qOnMXg16v87RQ==",
+ "dependencies": {
+ "Microsoft.Testing.Platform": "1.9.1"
+ }
+ },
+ "Microsoft.TestPlatform.ObjectModel": {
+ "type": "Transitive",
+ "resolved": "18.3.0",
+ "contentHash": "AEIEX2aWdPO9XbtR96eBaJxmXRD9vaI9uQ1T/JbPEKlTAZwYx0ZrMzKyULMdh/HH9Sg03kXCoN7LszQ90o6nPQ=="
+ },
+ "Microsoft.TestPlatform.TestHost": {
+ "type": "Transitive",
+ "resolved": "18.3.0",
+ "contentHash": "twmsoelXnp1uWMU3VGip9f0Jr1mZ0PZqgJdF35CIrdYgYrkHIJMV1m8uKyhcdjLdsQDESHAgkR7KhS9i1qpJag==",
+ "dependencies": {
+ "Microsoft.TestPlatform.ObjectModel": "18.3.0",
+ "Newtonsoft.Json": "13.0.3"
+ }
+ },
+ "Microsoft.Win32.Registry": {
+ "type": "Transitive",
+ "resolved": "5.0.0",
+ "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg=="
+ },
+ "Newtonsoft.Json": {
+ "type": "Transitive",
+ "resolved": "13.0.3",
+ "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ=="
+ },
+ "xunit.analyzers": {
+ "type": "Transitive",
+ "resolved": "1.27.0",
+ "contentHash": "y/pxIQaLvk/kxAoDkZW9GnHLCEqzwl5TW0vtX3pweyQpjizB9y3DXhb9pkw2dGeUqhLjsxvvJM1k89JowU6z3g=="
+ },
+ "xunit.v3.assert": {
+ "type": "Transitive",
+ "resolved": "3.2.2",
+ "contentHash": "BPciBghgEEaJN/JG00QfCYDfEfnLgQhfnYEy+j1izoeHVNYd5+3Wm8GJ6JgYysOhpBPYGE+sbf75JtrRc7jrdA=="
+ },
+ "xunit.v3.common": {
+ "type": "Transitive",
+ "resolved": "3.2.2",
+ "contentHash": "Hj775PEH6GTbbg0wfKRvG2hNspDCvTH9irXhH4qIWgdrOSV1sQlqPie+DOvFeigsFg2fxSM3ZAaaCDQs+KreFA==",
+ "dependencies": {
+ "Microsoft.Bcl.AsyncInterfaces": "6.0.0"
+ }
+ },
+ "xunit.v3.core.mtp-v1": {
+ "type": "Transitive",
+ "resolved": "3.2.2",
+ "contentHash": "Ga5aA2Ca9ktz+5k3g5ukzwfexwoqwDUpV6z7atSEUvqtd6JuybU1XopHqg1oFd78QdTfZgZE9h5sHpO4qYIi5w==",
+ "dependencies": {
+ "Microsoft.Testing.Extensions.Telemetry": "1.9.1",
+ "Microsoft.Testing.Extensions.TrxReport.Abstractions": "1.9.1",
+ "Microsoft.Testing.Platform": "1.9.1",
+ "Microsoft.Testing.Platform.MSBuild": "1.9.1",
+ "xunit.v3.extensibility.core": "[3.2.2]",
+ "xunit.v3.runner.inproc.console": "[3.2.2]"
+ }
+ },
+ "xunit.v3.extensibility.core": {
+ "type": "Transitive",
+ "resolved": "3.2.2",
+ "contentHash": "srY8z/oMPvh/t8axtO2DwrHajhFMH7tnqKildvYrVQIfICi8fOn3yIBWkVPAcrKmHMwvXRJ/XsQM3VMR6DOYfQ==",
+ "dependencies": {
+ "xunit.v3.common": "[3.2.2]"
+ }
+ },
+ "xunit.v3.mtp-v1": {
+ "type": "Transitive",
+ "resolved": "3.2.2",
+ "contentHash": "O41aAzYKBT5PWqATa1oEWVNCyEUypFQ4va6K0kz37dduV3EKzXNMaV2UnEhufzU4Cce1I33gg0oldS8tGL5I0A==",
+ "dependencies": {
+ "xunit.analyzers": "1.27.0",
+ "xunit.v3.assert": "[3.2.2]",
+ "xunit.v3.core.mtp-v1": "[3.2.2]"
+ }
+ },
+ "xunit.v3.runner.common": {
+ "type": "Transitive",
+ "resolved": "3.2.2",
+ "contentHash": "/hkHkQCzGrugelOAehprm7RIWdsUFVmIVaD6jDH/8DNGCymTlKKPTbGokD5czbAfqfex47mBP0sb0zbHYwrO/g==",
+ "dependencies": {
+ "Microsoft.Win32.Registry": "[5.0.0]",
+ "xunit.v3.common": "[3.2.2]"
+ }
+ },
+ "xunit.v3.runner.inproc.console": {
+ "type": "Transitive",
+ "resolved": "3.2.2",
+ "contentHash": "ulWOdSvCk+bPXijJZ73bth9NyoOHsAs1ZOvamYbCkD4DNLX/Bd29Ve2ZNUwBbK0MqfIYWXHZViy/HKrdEC/izw==",
+ "dependencies": {
+ "xunit.v3.extensibility.core": "[3.2.2]",
+ "xunit.v3.runner.common": "[3.2.2]"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
From 2f701dd81a58bc4c378512eecc279436049e6dcc Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Andr=C3=A1s=20B=20Nagy?=
<20251272+BNAndras@users.noreply.github.com>
Date: Fri, 26 Jun 2026 20:31:58 -0700
Subject: [PATCH 2/2] regenerate lock file
---
.../practice/save-the-cow/packages.lock.json | 20 ++++++++++++++++---
1 file changed, 17 insertions(+), 3 deletions(-)
diff --git a/exercises/practice/save-the-cow/packages.lock.json b/exercises/practice/save-the-cow/packages.lock.json
index 03e17b0b11..2c58048df4 100644
--- a/exercises/practice/save-the-cow/packages.lock.json
+++ b/exercises/practice/save-the-cow/packages.lock.json
@@ -21,11 +21,20 @@
"Microsoft.TestPlatform.TestHost": "18.3.0"
}
},
+ "Microsoft.Reactive.Testing": {
+ "type": "Direct",
+ "requested": "[5.0.0, )",
+ "resolved": "5.0.0",
+ "contentHash": "dIMpLPr7KG6aVzqIu/PlxxuXGV0rdJs5krgUZ7exfrBkFNzVLQJDXBnnovrzXOCnyZIZ0Bbsz50yLkfvtbzqTw==",
+ "dependencies": {
+ "System.Reactive": "5.0.0"
+ }
+ },
"xunit.runner.visualstudio": {
"type": "Direct",
- "requested": "[3.0.1, )",
- "resolved": "3.0.1",
- "contentHash": "lbyYtsBxA8Pz8kaf5Xn/Mj1mL9z2nlBWdZhqFaj66nxXBa4JwiTDm4eGcpSMet6du9TOWI6bfha+gQR6+IHawg=="
+ "requested": "[3.1.5, )",
+ "resolved": "3.1.5",
+ "contentHash": "tKi7dSTwP4m5m9eXPM2Ime4Kn7xNf4x4zT9sdLO/G4hZVnQCRiMTWoSZqI/pYTVeI27oPPqHBKYI/DjJ9GsYgA=="
},
"xunit.v3": {
"type": "Direct",
@@ -105,6 +114,11 @@
"resolved": "13.0.3",
"contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ=="
},
+ "System.Reactive": {
+ "type": "Transitive",
+ "resolved": "5.0.0",
+ "contentHash": "erBZjkQHWL9jpasCE/0qKAryzVBJFxGHVBAvgRN1bzM0q2s1S4oYREEEL0Vb+1kA/6BKb5FjUZMp5VXmy+gzkQ=="
+ },
"xunit.analyzers": {
"type": "Transitive",
"resolved": "1.27.0",