← All work · 2024 – now
Planetfall AI
Floyd is back, and now he listens
Steve Meretzky’s 1983 science-fiction classic, rebuilt room by room on the ZorkAI engine. You’re still a lowly Ensign Seventh Class scrubbing the deck of the Feinstein. Now you can talk to Floyd, Blather and the Ambassador in plain English, and they answer in character.

- 1983
- the original, by Steve Meretzky
- 128
- rooms rebuilt from scratch
- 1
- review from the original author
It’s quite wonderful; all the new text feels quite at home in the spirit of the game.
Steve Meretzky, creator of Planetfall, after playing an early build
- What I did
- Rebuilding the game’s world, characters and puzzles on the engine, and deciding how much personality the narrator is allowed to add.
- What AI did
- The voices of the narrator, Floyd, Blather and the Ambassador. In development, AI agents write much of the code against the test suite while I review.
Why Planetfall
Between the ages of about eight and twelve, an Apple IIc was my portal to Infocom. I thought about the puzzles even away from the computer. I remember sitting at the back of a seventh-grade classroom trying to work out how to get past the mutants in Planetfall, and when I finally did, it gave me a sense of accomplishment and wonder I’ll never forget.
Planetfall and A Mind Forever Voyaging, both by Steve Meretzky, were my two favorites. I started ZorkAI with Zork because it was simpler. Planetfall was always the point.
Bringing Floyd to life
Floyd is the heart and soul of the game: a small, childlike multipurpose robot who follows you around, wants to play Hider-and-Seeker, and in 1983 made people cry. When I started, I wrote that the challenge I was most looking forward to was seeing how far I could go in letting you have real conversations with him, while keeping all the childlike charm that made him the star.
In the original, Floyd was wonderful but scripted. Now he understands everything you type. Talk to him. Ask him to dance. Ask him about the control panel. He reacts to the world around him, and his idle chatter is generated fresh, on purpose, instead of repeating one canned line. Under the hood, Floyd has his own conversational service, separate from the narrator.
The same goes for the rest of the cast. Ensign First Class Blather still hands out demerits and still sneers at your polishing. The Ambassador from Blow’k-bibben-Gordo still leaves a trail of slime and still has opinions about his celery.
Protecting the story
The more the characters can say, the more ways they can say the wrong thing. Before you’d met Floyd, the narrator kept improvising jokes about him, and later it kept making them in exactly the wrong tone. So now, mention Floyd before he exists and the game answers: Floyd? There’s nobody here by that name. Someone’s played Planetfall before, haven’t they? Afterwards, it simply says Floyd is gone.
The hardest bug was the quietest. Floyd’s big moment, the scene everyone remembers, could not happen in production. One piece of his state was never saved between turns, and because every turn runs on a fresh server, the game was unwinnable online while every test on my machine passed. Tests that replay the whole game against the real, stateless setup came out of that one.
Faithful first
Every room, object, puzzle, score and saved game has been rebuilt from scratch in the new engine: the Feinstein, the escape pod, the Kalamontee and Lawanda complexes, the shuttles, the mutants. The rebuild is playable from start to finish and still being polished. AI hints, grounded in the game’s own walkthrough tests instead of guesses, are next.
The best review I’ve had
Steve Meretzky, who wrote Planetfall in 1983, played an early build. His verdict: “It’s quite wonderful; all the new text feels quite at home in the spirit of the game.” He also found a lot of bugs. We’re working on it.
962 private async Task<string> ProcessActorsAndContextEndOfTurn(string? contextPrepend, string? turnResult, 963 bool isFreeCommand = false) 964 { 965 // Check if player died during the turn (e.g., from location hazards, items, etc.) 966 if (Context.PendingDeath is not null) 967 { 968 var deathResult = Context.PendingDeath; 969 // Include any context and turn result before the death message (e.g., "The door opens.") 970 // The death message is already in turnResult since it was returned from the interaction 971 var preDeathOutput = FormatResult(contextPrepend, turnResult, null, null); 972 RestartAfterDeath(deathResult.DeathCount); 973 return PostProcessing(preDeathOutput + "\n" + Context.CurrentLocation.GetDescription(Context)); 974 } 975 976 // Skip actor processing when disambiguation question is being asked 977 // The player hasn't completed a real action yet - just asking for clarification 978 // Note: When disambiguation is resolved (player answers), the clarified command IS a real action 979 var actors = _processorInProgress is DisambiguationProcessor 980 ? string.Empty 981 : await ProcessActors(); 982 983 // Check if player died during actor processing (e.g., Bio Lock mutations) 984 if (Context.PendingDeath is not null) 985 { 986 var deathResult = Context.PendingDeath; 987 // The death message is already included in actors output (from BioLockStateMachineManager etc.) 988 // We just need to format everything before death, restart, and append the new location 989 var preDeathOutput = FormatResult(contextPrepend, turnResult, actors, null); 990 RestartAfterDeath(deathResult.DeathCount); 991 return PostProcessing(preDeathOutput + "\n" + Context.CurrentLocation.GetDescription(Context)); 992 } 993 994 // Free commands (issue #354) skip end-of-turn survival-clock processing (e.g. Planetfall's 995 // Chronometer tick) - actors above still ran, so the world keeps moving, but the player's own 996 // status check doesn't itself consume survival-clock time. 997 var contextAppend = isFreeCommand ? null : Context.ProcessEndOfTurn(); 998 return PostProcessing(FormatResult(contextPrepend, turnResult, actors, contextAppend)); 999 } 1000 1001 private string PostProcessing(string finalResult) 1002 { 1003 _logger?.LogDebug($"Items in inventory: {Context.LogItems()}"); 1004 _logger?.LogDebug($"Items in location: {Context.CurrentLocation.LogItems()}"); 1005 _logger?.LogDebug($"Moves: {Context.Moves}"); 1006 1007 if (!string.IsNullOrEmpty(_currentInput)) 1008 _turnLogger?.WriteLogEvents(new TurnLog 1009 { 1010 SessionId = _sessionId, 1011 Location = Context.CurrentLocation.Name, 1012 Score = Context.Score, 1013 Moves = Context.Moves, 1014 Input = _currentInput, 1015 Response = finalResult.Trim() 1016 }); 1017 1018 if (!string.IsNullOrEmpty(finalResult)) 1019 { 1020 _inputOutputs.Push((_currentInput!, finalResult, _lastResponseWasGenerated)); 1021 GenerationClient.LastFiveInputOutputs = _inputOutputs.GetAll(); 1022 } 1023 1024 _lastResponseWasGenerated = false; 1025 1026 // Store response for pronoun resolution 1027 var trimmedResult = finalResult.TrimEnd(); 1028 if (!string.IsNullOrWhiteSpace(trimmedResult)) 1029 Context.LastResponse = trimmedResult; 1030 1031 return trimmedResult + Environment.NewLine; 1032 } 1033 1034 private async Task<string> ProcessGlobalCommandIntent(GlobalCommandIntent intent) 1035 { 1036 var intentResponse = await intent.Command.Process( 1037 _currentInput, 1038 Context, 1039 GenerationClient, 1040 Runtime 1041 ); 1042 if (intent.Command is IStatefulProcessor { Completed: false } statefulProcessor) 1043 _processorInProgress = statefulProcessor; 1044 1045 intentResponse += Environment.NewLine; 1046 return intentResponse; 1047 } 1048 1049 private async Task<string> ProcessActors() 1050 { 1051 var actorResults = string.Empty; 1052 foreach (var actor in Context.Actors.ToList()) 1053 { 1054 _logger?.LogDebug($"Processing actor: {actor.GetType()}"); 1055 var task = await actor.Act(Context, GenerationClient); 1056 actorResults += $"{task} "; 1057 1058 // Stop processing actors if death occurred - subsequent actors should not run 1059 if (Context.PendingDeath is not null) 1060 { 1061 _logger?.LogDebug("Death occurred during actor processing, stopping remaining actors"); 1062 break; 1063 } 1064 } 1065 1066 return actorResults.Trim(); 1067 } 1068 1069 private async Task<(bool, string?)> RunProcessorInProgress(string? playerInput) 1070 { 1071 string? processorInProgressOutput = null; 1072 var immediatelyReturn = false; 1073 1074 // When this is not null, it means we have another processor in progress. 1075 // Defer all execution to that processor until it's complete. 1076 if (_processorInProgress == null) 1077 return (immediatelyReturn, processorInProgressOutput); 1078 1079 processorInProgressOutput = await _processorInProgress.Process( 1080 playerInput, 1081 Context, 1082 GenerationClient, 1083 Runtime 1084 ); 1085 1086 // The processor is done. Clear it, and see what we want to do with the output. 1087 if (_processorInProgress.Completed) 1088 { 1089 var continueProcessingThisInput = _processorInProgress.ContinueProcessing; 1090 ClearProcessorInProgress(); 1091 1092 // Does the processor want us to return what it outputted?.... 1093 if (!continueProcessingThisInput) 1094 immediatelyReturn = true; 1095 1096 // ....or does it want to push that output through for further processing? 1097 _currentInput = processorInProgressOutput; 1098 } 1099 // Return the output and keep processing? Or are we done here yet. 1100 else 1101 { 1102 immediatelyReturn = true; 1103 } 1104 1105 return (immediatelyReturn, processorInProgressOutput); 1106 } 1107 1108 /// <summary> 1109 /// Arms a pending "which one do you mean?" disambiguation and, crucially, mirrors the two fields 1110 /// the answer needs into a serializable descriptor on the <see cref="Context" /> so the prompt 1111 /// survives the stateless per-request save/restore boundary (issue #472). The live 1112 /// <see cref="DisambiguationProcessor" /> is an in-memory field that is never serialized; the 1113 /// descriptor is what actually round-trips and lets <see cref="RehydrateProcessorInProgress" /> 1114 /// rebuild the processor on the next request. 1115 /// </summary> 1116 private void ArmDisambiguation(DisambiguationInteractionResult disambiguation) 1117 { 1118 _processorInProgress = new DisambiguationProcessor(disambiguation); 1119 Context.PendingDisambiguation = new PendingDisambiguation 1120 { 1121 PossibleResponses = disambiguation.PossibleResponses, 1122 ReplacementString = disambiguation.ReplacementString 1123 }; 1124 } 1125 1126 /// <summary> 1127 /// Clears the in-progress stateful processor together with the persisted descriptors that back the 1128 /// two prompts which must survive the request boundary (issue #472). Called whenever a processor 1129 /// finishes or is abandoned; clearing both descriptors unconditionally is safe because at most one 1130 /// can be set at a time, and the save/quit/restore processors never set either. 1131 /// </summary> 1132 private void ClearProcessorInProgress() 1133 { 1134 _processorInProgress = null; 1135 Context.PendingDisambiguation = null; 1136 Context.PendingClarificationCommand = null; 1137 } 1138 1139 /// <summary> 1140 /// Reconstructs <see cref="_processorInProgress" /> from whichever pending-prompt descriptor 1141 /// round-tripped on the restored <see cref="Context" /> (issue #472). The two descriptors are 1142 /// mutually exclusive by construction — a turn arms exactly one prompt — so disambiguation is 1143 /// simply checked first. Does nothing when no prompt was pending, leaving normal parsing in charge. 1144 /// </summary> 1145 private void RehydrateProcessorInProgress() 1146 { 1147 if (Context.PendingDisambiguation is { } pending) 1148 { 1149 // The stored prompt text is irrelevant when answering — only the response map and template 1150 // drive resolution — so rebuild the result with an empty message. 1151 _processorInProgress = new DisambiguationProcessor( 1152 new DisambiguationInteractionResult( 1153 string.Empty, pending.PossibleResponses, pending.ReplacementString)); 1154 return; 1155 } 1156 1157 if (!string.IsNullOrEmpty(Context.PendingClarificationCommand)) 1158 _processorInProgress = new ItProcessor(Context.PendingClarificationCommand); 1159 } 1160 1161 private static async Task<string> GetGeneratedNoOpResponse( 1162 string input, 1163 IGenerationClient generationClient, 1164 IContext context 1165 ) 1166 { 1167 var request = new CommandHasNoEffectOperationRequest( 1168 context.CurrentLocation.GetDescriptionForGeneration(context), 1169 input 1170 ); 1171 var result = await generationClient.GenerateNarration(request, context.SystemPromptAddendum); 1172 return result + Environment.NewLine; 1173 } 1174 1175 1176 private static async Task<string> GetGeneratedMultipleCommandsResponse( 1177 string input, 1178 IGenerationClient generationClient, 1179 IContext context 1180 ) 1181 { 1182 var request = new MultipleCommandsRequest( 1183 context.CurrentLocation.GetDescriptionForGeneration(context), 1184 input 1185 ); 1186 var result = await generationClient.GenerateNarration(request, context.SystemPromptAddendum); 1187 return result + Environment.NewLine; 1188 } 1189 1190 private async Task<string> GetGeneratedNoCommandResponse() 1191 { 1192 var request = new EmptyRequest(); 1193 var result = await GenerationClient.GenerateNarration(request, String.Empty); 1194 return result; 1195 } 1196 1197 private static JsonSerializerSettings JsonSettings() 1198 { 1199 return new JsonSerializerSettings 1200 { 1201 ReferenceLoopHandling = ReferenceLoopHandling.Ignore, 1202 TypeNameHandling = TypeNameHandling.All, 1203 PreserveReferencesHandling = PreserveReferencesHandling.Objects, 1204 ContractResolver = new DoNotSerializeReadOnlyPropertiesResolver(), 1205 ConstructorHandling = ConstructorHandling.AllowNonPublicDefaultConstructor, 1206 // Replace collections during deserialization instead of adding to them. 1207 // This prevents items from accumulating in Items lists across session restores. 1208 ObjectCreationHandling = ObjectCreationHandling.Replace 1209 }; 1210 } 1211} 1212 1213/// <summary> 1214/// A custom resolver used to modify the serialization behavior of JSON objects 1215/// to exclude read-only properties. Extends <see cref="DefaultContractResolver" /> 1216/// and provides a mechanism to exclude non-writable properties from being serialized. 1217/// </summary> 1218/// <remarks> 1219/// This class is useful for scenarios where serialization of read-only properties 1220/// is not desired, such as when saving the state of objects or generating JSON 1221/// responses to ensure only editable or writable properties are included. 1222/// This is achieved by overriding the <see cref="CreateProperty" /> method and adjusting 1223/// the serialization behavior based on property writability. 1224/// </remarks> 1225public class DoNotSerializeReadOnlyPropertiesResolver : DefaultContractResolver 1226{ 1227 protected override JsonProperty CreateProperty( 1228 MemberInfo member, 1229 MemberSerialization memberSerialization 1230 ) 1231 { 1232 var property = base.CreateProperty(member, memberSerialization); 1233 1234 if (!property.Writable) property.ShouldSerialize = _ => false; 1235 1236 return property; 1237 }
962 private async Task<string> ProcessActorsAndContextEndOfTurn(string? contextPrepend, string? turnResult, 963 bool isFreeCommand = false) 964 { 965 // Check if player died during the turn (e.g., from location hazards, items, etc.) 966 if (Context.PendingDeath is not null) 967 { 968 var deathResult = Context.PendingDeath; 969 // Include any context and turn result before the death message (e.g., "The door opens.") 970 // The death message is already in turnResult since it was returned from the interaction 971 var preDeathOutput = FormatResult(contextPrepend, turnResult, null, null); 972 RestartAfterDeath(deathResult.DeathCount); 973 return PostProcessing(preDeathOutput + "\n" + Context.CurrentLocation.GetDescription(Context)); 974 } 975 976 // Skip actor processing when disambiguation question is being asked 977 // The player hasn't completed a real action yet - just asking for clarification 978 // Note: When disambiguation is resolved (player answers), the clarified command IS a real action 979 var actors = _processorInProgress is DisambiguationProcessor 980 ? string.Empty 981 : await ProcessActors(); 982 983 // Check if player died during actor processing (e.g., Bio Lock mutations) 984 if (Context.PendingDeath is not null) 985 { 986 var deathResult = Context.PendingDeath; 987 // The death message is already included in actors output (from BioLockStateMachineManager etc.) 988 // We just need to format everything before death, restart, and append the new location 989 var preDeathOutput = FormatResult(contextPrepend, turnResult, actors, null); 990 RestartAfterDeath(deathResult.DeathCount); 991 return PostProcessing(preDeathOutput + "\n" + Context.CurrentLocation.GetDescription(Context)); 992 } 993 994 // Free commands (issue #354) skip end-of-turn survival-clock processing (e.g. Planetfall's 995 // Chronometer tick) - actors above still ran, so the world keeps moving, but the player's own 996 // status check doesn't itself consume survival-clock time. 997 var contextAppend = isFreeCommand ? null : Context.ProcessEndOfTurn(); 998 return PostProcessing(FormatResult(contextPrepend, turnResult, actors, contextAppend)); 999 } 1000 1001 private string PostProcessing(string finalResult) 1002 { 1003 _logger?.LogDebug($"Items in inventory: {Context.LogItems()}"); 1004 _logger?.LogDebug($"Items in location: {Context.CurrentLocation.LogItems()}"); 1005 _logger?.LogDebug($"Moves: {Context.Moves}"); 1006 1007 if (!string.IsNullOrEmpty(_currentInput)) 1008 _turnLogger?.WriteLogEvents(new TurnLog 1009 { 1010 SessionId = _sessionId, 1011 Location = Context.CurrentLocation.Name, 1012 Score = Context.Score, 1013 Moves = Context.Moves, 1014 Input = _currentInput, 1015 Response = finalResult.Trim() 1016 }); 1017 1018 if (!string.IsNullOrEmpty(finalResult)) 1019 { 1020 _inputOutputs.Push((_currentInput!, finalResult, _lastResponseWasGenerated)); 1021 GenerationClient.LastFiveInputOutputs = _inputOutputs.GetAll(); 1022 } 1023 1024 _lastResponseWasGenerated = false; 1025 1026 // Store response for pronoun resolution 1027 var trimmedResult = finalResult.TrimEnd(); 1028 if (!string.IsNullOrWhiteSpace(trimmedResult)) 1029 Context.LastResponse = trimmedResult; 1030 1031 return trimmedResult + Environment.NewLine; 1032 } 1033 1034 private async Task<string> ProcessGlobalCommandIntent(GlobalCommandIntent intent) 1035 { 1036 var intentResponse = await intent.Command.Process( 1037 _currentInput, 1038 Context, 1039 GenerationClient, 1040 Runtime 1041 ); 1042 if (intent.Command is IStatefulProcessor { Completed: false } statefulProcessor) 1043 _processorInProgress = statefulProcessor; 1044 1045 intentResponse += Environment.NewLine; 1046 return intentResponse; 1047 } 1048 1049 private async Task<string> ProcessActors() 1050 { 1051 var actorResults = string.Empty; 1052 foreach (var actor in Context.Actors.ToList()) 1053 { 1054 _logger?.LogDebug($"Processing actor: {actor.GetType()}"); 1055 var task = await actor.Act(Context, GenerationClient); 1056 actorResults += $"{task} "; 1057 1058 // Stop processing actors if death occurred - subsequent actors should not run 1059 if (Context.PendingDeath is not null) 1060 { 1061 _logger?.LogDebug("Death occurred during actor processing, stopping remaining actors"); 1062 break; 1063 } 1064 } 1065 1066 return actorResults.Trim(); 1067 } 1068 1069 private async Task<(bool, string?)> RunProcessorInProgress(string? playerInput) 1070 { 1071 string? processorInProgressOutput = null; 1072 var immediatelyReturn = false; 1073 1074 // When this is not null, it means we have another processor in progress. 1075 // Defer all execution to that processor until it's complete. 1076 if (_processorInProgress == null) 1077 return (immediatelyReturn, processorInProgressOutput); 1078 1079 processorInProgressOutput = await _processorInProgress.Process( 1080 playerInput, 1081 Context, 1082 GenerationClient, 1083 Runtime 1084 ); 1085 1086 // The processor is done. Clear it, and see what we want to do with the output. 1087 if (_processorInProgress.Completed) 1088 { 1089 var continueProcessingThisInput = _processorInProgress.ContinueProcessing; 1090 ClearProcessorInProgress(); 1091 1092 // Does the processor want us to return what it outputted?.... 1093 if (!continueProcessingThisInput) 1094 immediatelyReturn = true; 1095 1096 // ....or does it want to push that output through for further processing? 1097 _currentInput = processorInProgressOutput; 1098 } 1099 // Return the output and keep processing? Or are we done here yet. 1100 else 1101 { 1102 immediatelyReturn = true; 1103 } 1104 1105 return (immediatelyReturn, processorInProgressOutput); 1106 } 1107 1108 /// <summary> 1109 /// Arms a pending "which one do you mean?" disambiguation and, crucially, mirrors the two fields 1110 /// the answer needs into a serializable descriptor on the <see cref="Context" /> so the prompt 1111 /// survives the stateless per-request save/restore boundary (issue #472). The live 1112 /// <see cref="DisambiguationProcessor" /> is an in-memory field that is never serialized; the 1113 /// descriptor is what actually round-trips and lets <see cref="RehydrateProcessorInProgress" /> 1114 /// rebuild the processor on the next request. 1115 /// </summary> 1116 private void ArmDisambiguation(DisambiguationInteractionResult disambiguation) 1117 { 1118 _processorInProgress = new DisambiguationProcessor(disambiguation); 1119 Context.PendingDisambiguation = new PendingDisambiguation 1120 { 1121 PossibleResponses = disambiguation.PossibleResponses, 1122 ReplacementString = disambiguation.ReplacementString 1123 }; 1124 } 1125 1126 /// <summary> 1127 /// Clears the in-progress stateful processor together with the persisted descriptors that back the 1128 /// two prompts which must survive the request boundary (issue #472). Called whenever a processor 1129 /// finishes or is abandoned; clearing both descriptors unconditionally is safe because at most one 1130 /// can be set at a time, and the save/quit/restore processors never set either. 1131 /// </summary> 1132 private void ClearProcessorInProgress() 1133 { 1134 _processorInProgress = null; 1135 Context.PendingDisambiguation = null; 1136 Context.PendingClarificationCommand = null; 1137 } 1138 1139 /// <summary> 1140 /// Reconstructs <see cref="_processorInProgress" /> from whichever pending-prompt descriptor 1141 /// round-tripped on the restored <see cref="Context" /> (issue #472). The two descriptors are 1142 /// mutually exclusive by construction — a turn arms exactly one prompt — so disambiguation is 1143 /// simply checked first. Does nothing when no prompt was pending, leaving normal parsing in charge. 1144 /// </summary> 1145 private void RehydrateProcessorInProgress() 1146 { 1147 if (Context.PendingDisambiguation is { } pending) 1148 { 1149 // The stored prompt text is irrelevant when answering — only the response map and template 1150 // drive resolution — so rebuild the result with an empty message. 1151 _processorInProgress = new DisambiguationProcessor( 1152 new DisambiguationInteractionResult( 1153 string.Empty, pending.PossibleResponses, pending.ReplacementString)); 1154 return; 1155 } 1156 1157 if (!string.IsNullOrEmpty(Context.PendingClarificationCommand)) 1158 _processorInProgress = new ItProcessor(Context.PendingClarificationCommand); 1159 } 1160 1161 private static async Task<string> GetGeneratedNoOpResponse( 1162 string input, 1163 IGenerationClient generationClient, 1164 IContext context 1165 ) 1166 { 1167 var request = new CommandHasNoEffectOperationRequest( 1168 context.CurrentLocation.GetDescriptionForGeneration(context), 1169 input 1170 ); 1171 var result = await generationClient.GenerateNarration(request, context.SystemPromptAddendum); 1172 return result + Environment.NewLine; 1173 } 1174 1175 1176 private static async Task<string> GetGeneratedMultipleCommandsResponse( 1177 string input, 1178 IGenerationClient generationClient, 1179 IContext context 1180 ) 1181 { 1182 var request = new MultipleCommandsRequest( 1183 context.CurrentLocation.GetDescriptionForGeneration(context), 1184 input 1185 ); 1186 var result = await generationClient.GenerateNarration(request, context.SystemPromptAddendum); 1187 return result + Environment.NewLine; 1188 } 1189 1190 private async Task<string> GetGeneratedNoCommandResponse() 1191 { 1192 var request = new EmptyRequest(); 1193 var result = await GenerationClient.GenerateNarration(request, String.Empty); 1194 return result; 1195 } 1196 1197 private static JsonSerializerSettings JsonSettings() 1198 { 1199 return new JsonSerializerSettings 1200 { 1201 ReferenceLoopHandling = ReferenceLoopHandling.Ignore, 1202 TypeNameHandling = TypeNameHandling.All, 1203 PreserveReferencesHandling = PreserveReferencesHandling.Objects, 1204 ContractResolver = new DoNotSerializeReadOnlyPropertiesResolver(), 1205 ConstructorHandling = ConstructorHandling.AllowNonPublicDefaultConstructor, 1206 // Replace collections during deserialization instead of adding to them. 1207 // This prevents items from accumulating in Items lists across session restores. 1208 ObjectCreationHandling = ObjectCreationHandling.Replace 1209 }; 1210 } 1211} 1212 1213/// <summary> 1214/// A custom resolver used to modify the serialization behavior of JSON objects 1215/// to exclude read-only properties. Extends <see cref="DefaultContractResolver" /> 1216/// and provides a mechanism to exclude non-writable properties from being serialized. 1217/// </summary> 1218/// <remarks> 1219/// This class is useful for scenarios where serialization of read-only properties 1220/// is not desired, such as when saving the state of objects or generating JSON 1221/// responses to ensure only editable or writable properties are included. 1222/// This is achieved by overriding the <see cref="CreateProperty" /> method and adjusting 1223/// the serialization behavior based on property writability. 1224/// </remarks> 1225public class DoNotSerializeReadOnlyPropertiesResolver : DefaultContractResolver 1226{ 1227 protected override JsonProperty CreateProperty( 1228 MemberInfo member, 1229 MemberSerialization memberSerialization 1230 ) 1231 { 1232 var property = base.CreateProperty(member, memberSerialization); 1233 1234 if (!property.Writable) property.ShouldSerialize = _ => false; 1235 1236 return property; 1237 }