Save and load the day
Every day is a draft. Sleep promotes it, quitting throws it away, and a fight inside the day is a draft of the draft that can be played again.
Every timeline of the save file at the end of the run, from timeline.list, indented by depth. Solid green: promoted. Dashed: discarded.
The idea
A save system is a question of what counts. Until the player sleeps, nothing that happened today is part of the world; if they quit, none of it ever was; if they reload a fight, only the last attempt counts. Drafts are exactly that: timelines that see the world as it was when they were opened, and join it all at once, or never.
Your game decides
- when a day starts and when it is saved
- which fights can be replayed
- what to do when the world moved meanwhile
- that quitting throws the day away
Umwelt guarantees
- a draft is invisible until promoted
- promoting is all of it at once
- a draft never lands on a world it has not seen
- a purged draft leaves nothing behind
Deepmoor
Day 12 of a run through the moors. Wren finds the old bridge gone, and on the road a bandit ambushes her. She falls the first time. The player reloads the fight, wins, and learns the bandit’s name.
While Wren is out, the world simulation keeps writing to main: the river floods.
The day is a draft
Each read names its timeline. Main has not seen the bridge; the day has. The ambush is a draft of the day, so it sees the bridge too, and the day does not see the ambush.
const collapsed = claim.unary('bridge', 'collapsed');
const flooded = claim.unary('river', 'flooded');
const banditName = claim.value('bandit', 'is-named', 'Ressa');
// What Wren sees becomes canon and her own knowledge of it, at the scene that holds them.
const sees = (id: string, seen: Claim, eventId: string): RecordInput[] => [
fact(seen, { id: `fact-${id}` }),
knows('wren', seen, witnessed(eventId), { id }),
];
const scene = (timelineId: string, id: string, title: string, tick: number, records: RecordInput[]) => client.commit({
projectId, timelineId, attemptId: id,
event: { id, type: 'scene', title, position: at(tick), participants: ['wren'], provenance },
records,
});
const wrenKnows = async (asked: Claim, timelineId: string) =>
(await client.holds({ projectId, timelineId, holder: 'wren', claim: asked })).holdsAs === 'same';var collapsed = Claim.Unary("bridge", "collapsed");
var flooded = Claim.Unary("river", "flooded");
var banditName = Claim.Value("bandit", "is-named", "Ressa");
// What Wren sees becomes canon and her own knowledge of it, at the scene that holds them.
List<RecordInput> Sees(string id, Claim seen, string eventId) => new()
{
Records.Fact(seen, id: $"fact-{id}"),
Records.Knows("wren", seen, Acquired.Witnessed(eventId), id: id),
};
CommitOutcome Scene(string timelineId, string id, string title, long tick, List<RecordInput> records) => engine.Commit(new CommitInput
{
ProjectId = projectId, TimelineId = timelineId, AttemptId = id,
Event = new EventInput { Id = id, Type = "scene", Title = title, Position = Position.At(tick), Participants = new() { "wren" }, Provenance = provenance },
Records = records,
});
bool WrenKnows(Claim claim, string timelineId) =>
engine.Holds(new HoldsPayload { ProjectId = projectId, TimelineId = timelineId, Holder = "wren", Claim = claim }).HoldsAs == HoldsAs.Same;// Morning of day 12: the day is a draft. Nothing in it counts until Wren sleeps.
await client.timelineOpenDraft({ projectId, id: 'day-12', label: 'Day 12' });
await scene('day-12', 'bridge-down', 'The bridge is gone', 12, sees('wren-bridge', collapsed, 'bridge-down'));
// An ambush on the road: a draft inside the day. Wren falls, and the player reloads it.
await client.timelineOpenDraft({ projectId, id: 'ambush-1', label: 'Ambush, first try', baseTimelineId: 'day-12' });
await scene('ambush-1', 'ambush', 'Ambush on the road', 12, sees('wren-name', banditName, 'ambush'));
print('midday', {
bridgeOnMain: await wrenKnows(collapsed, 'main'),
bridgeInDay: await wrenKnows(collapsed, 'day-12'),
nameInDay: await wrenKnows(banditName, 'day-12'),
bridgeInAmbush: await wrenKnows(collapsed, 'ambush-1'),
nameOnMain: await wrenKnows(banditName, 'main'),
nameInAmbush: await wrenKnows(banditName, 'ambush-1'),
});
await client.timelineDiscard({ projectId, timelineId: 'ambush-1' });// Morning of day 12: the day is a draft. Nothing in it counts until Wren sleeps.
engine.TimelineOpenDraft(new OpenDraftInput { ProjectId = projectId, Id = "day-12", Label = "Day 12" });
Scene("day-12", "bridge-down", "The bridge is gone", 12, Sees("wren-bridge", collapsed, "bridge-down"));
// An ambush on the road: a draft inside the day. Wren falls, and the player reloads it.
engine.TimelineOpenDraft(new OpenDraftInput { ProjectId = projectId, Id = "ambush-1", Label = "Ambush, first try", BaseTimelineId = "day-12" });
Scene("ambush-1", "ambush", "Ambush on the road", 12, Sees("wren-name", banditName, "ambush"));
Print("midday", new
{
bridgeOnMain = WrenKnows(collapsed, "main"),
bridgeInDay = WrenKnows(collapsed, "day-12"),
nameInDay = WrenKnows(banditName, "day-12"),
bridgeInAmbush = WrenKnows(collapsed, "ambush-1"),
nameOnMain = WrenKnows(banditName, "main"),
nameInAmbush = WrenKnows(banditName, "ambush-1"),
});
engine.TimelineDiscard(new DiscardPayload { ProjectId = projectId, TimelineId = "ambush-1" });| Question | main | day-12 | ambush-1 |
|---|---|---|---|
| Does Wren know the bridge is gone? | false | true | true |
| Does she know the bandit’s name? | false | false | true |
Reloading a fight
The second try replays the scene with the same ids. A discarded draft still reserves its ids, so the engine refuses, and the game purges the first try and replays.
// The second try replays the same scene, with the same ids. A discarded draft still holds them.
await client.timelineOpenDraft({ projectId, id: 'ambush-2', label: 'Ambush, second try', baseTimelineId: 'day-12' });
try {
await scene('ambush-2', 'ambush', 'Ambush on the road', 12, sees('wren-name', banditName, 'ambush'));
} catch (error) {
if (!(error instanceof UmweltError) || error.code !== 'CONFLICT') throw error;
print('replay-refused', { code: error.code });
print('purged', await client.timelinePurge({ projectId, timelineId: 'ambush-1' }));
await scene('ambush-2', 'ambush', 'Ambush on the road', 12, sees('wren-name', banditName, 'ambush'));
}
await client.timelinePromote({ projectId, timelineId: 'ambush-2' });// The second try replays the same scene, with the same ids. A discarded draft still holds them.
engine.TimelineOpenDraft(new OpenDraftInput { ProjectId = projectId, Id = "ambush-2", Label = "Ambush, second try", BaseTimelineId = "day-12" });
try
{
Scene("ambush-2", "ambush", "Ambush on the road", 12, Sees("wren-name", banditName, "ambush"));
}
catch (ConflictException error)
{
Print("replay-refused", new { code = error.Code });
Print("purged", engine.TimelinePurge(new TimelineRef { ProjectId = projectId, TimelineId = "ambush-1" }));
Scene("ambush-2", "ambush", "Ambush on the road", 12, Sees("wren-name", banditName, "ambush"));
}
engine.TimelinePromote(new TimelineRef { ProjectId = projectId, TimelineId = "ambush-2" });{ "code": "CONFLICT" }{
"purged": true,
"removed": { "attempts": 1, "events": 1, "records": 2, "transitions": 0 }
}Discard with purge, when you will replay. A game that reloads the same scene with the same ids discards with purge: true in one call. Here it is shown in two steps, to see the refusal.
Sleeping
Wren sleeps. Promoting the day fails: main moved while the day was open. The game rebases the day on top of the flood, every commit of it checked again, and promotes.
// Meanwhile the world kept turning on main: the river flooded. When Wren sleeps,
// the day cannot be promoted over a main it has not seen.
await scene('main', 'flood', 'The river floods', 12, [fact(flooded, { id: 'fact-flood' })]);
try {
await client.timelinePromote({ projectId, timelineId: 'day-12' });
} catch (error) {
if (!(error instanceof UmweltError) || error.code !== 'VERSION_CONFLICT') throw error;
print('sleep-refused', { code: error.code, details: error.details });
const rebased = await client.timelineRebase({ projectId, timelineId: 'day-12' });
print('rebased', { baseVersion: rebased.baseVersion, headVersion: rebased.headVersion });
await client.timelinePromote({ projectId, timelineId: 'day-12' });
}
print('after-sleep', {
bridge: await wrenKnows(collapsed, 'main'),
name: await wrenKnows(banditName, 'main'),
});// Meanwhile the world kept turning on main: the river flooded. When Wren sleeps,
// the day cannot be promoted over a main it has not seen.
Scene("main", "flood", "The river floods", 12, new() { Records.Fact(flooded, id: "fact-flood") });
try
{
engine.TimelinePromote(new TimelineRef { ProjectId = projectId, TimelineId = "day-12" });
}
catch (VersionConflictException error)
{
Print("sleep-refused", new { code = error.Code, details = error.Details });
var rebased = engine.TimelineRebase(new TimelineRef { ProjectId = projectId, TimelineId = "day-12" });
Print("rebased", new { baseVersion = rebased.BaseVersion, headVersion = rebased.HeadVersion });
engine.TimelinePromote(new TimelineRef { ProjectId = projectId, TimelineId = "day-12" });
}
Print("after-sleep", new { bridge = WrenKnows(collapsed, "main"), name = WrenKnows(banditName, "main") });{
"code": "VERSION_CONFLICT",
"details": {
"baseVersion": 0,
"mainHeadVersion": 4,
"parentHeadVersion": 4,
"parentTimelineId": "main"
}
}{ "baseVersion": 4, "headVersion": 6 }{ "bridge": true, "name": true }Nothing lands on a world it has not seen. A promote requires the parent to be exactly as the draft saw it. That is what keeps a saved day consistent with everything that happened around it.
Quitting without saving
Day 13: Wren sees the flooded ford, and the player quits. Discard and purge: main never saw it, and Wren, on main, still knows nothing of the flood.
// Day 13: the player quits without saving. Discard and purge, and main never saw it.
await client.timelineOpenDraft({ projectId, id: 'day-13', label: 'Day 13' });
await scene('day-13', 'ford', 'The flooded ford', 13, sees('wren-flood', flooded, 'ford'));
const quit = await client.timelineDiscard({ projectId, timelineId: 'day-13', purge: true });
print('quit', { status: quit.status, purged: Boolean(quit.purgedAt), floodOnMain: await wrenKnows(flooded, 'main') });
print('timelines', (await client.timelineList({ projectId })).map(({ id, parentTimelineId, status, depth, purgedAt }) =>
({ id, parent: parentTimelineId ?? null, status, depth, purged: Boolean(purgedAt) })));
await client.close();// Day 13: the player quits without saving. Discard and purge, and main never saw it.
engine.TimelineOpenDraft(new OpenDraftInput { ProjectId = projectId, Id = "day-13", Label = "Day 13" });
Scene("day-13", "ford", "The flooded ford", 13, Sees("wren-flood", flooded, "ford"));
var quit = engine.TimelineDiscard(new DiscardPayload { ProjectId = projectId, TimelineId = "day-13", Purge = true });
Print("quit", new { status = quit.Status, purged = quit.PurgedAt is not null, floodOnMain = WrenKnows(flooded, "main") });
Print("timelines", engine.TimelineList(new TimelineListPayload { ProjectId = projectId }).Select(t => new
{
id = t.Id, parent = t.ParentTimelineId, status = t.Status, depth = t.Depth, purged = t.PurgedAt is not null,
}));{ "status": "discarded", "purged": true, "floodOnMain": false }Confirmed history is never deleted. Purge removes only a draft that was never promoted. Day 12 is part of main now, and stays.
Try next
changes on main: a timeline.promoted item tells you the day is in.propose is lighter than a draft. See What each player knows.The whole program
One file that builds the world, runs it and prints every answer on this page. The TypeScript program and the .NET one print the same answers: the script that runs them for these pages checks it.
import { at, by, claim, createClient, fact, knows, UmweltError, witnessed } from '@kallamo/umwelt';
import type { Claim, RecordInput } from '@kallamo/umwelt';
const client = createClient({ file: 'deepmoor.umw', create: true });
const projectId = 'deepmoor';
const provenance = by.human();
const print = (label: string, value: unknown) => console.log(`@@${label}\n${JSON.stringify(value)}`);
// The world, declared once: sending the same manifest again changes nothing.
await client.worldEnsure({
project: { id: projectId, name: 'Deepmoor', timescale: 'day' },
entityTypes: [
{ id: 'person', name: 'Person', kind: 'agent' },
{ id: 'place', name: 'Place', kind: 'other' },
],
entities: [
{ id: 'wren', typeId: 'person', name: 'Wren, the player' },
{ id: 'bandit', typeId: 'person', name: 'The bandit' },
{ id: 'bridge', typeId: 'place', name: 'The old bridge' },
{ id: 'river', typeId: 'place', name: 'The river' },
],
predicates: [
{ id: 'collapsed', label: 'collapsed', object: 'none', cardinality: 'one' },
{ id: 'flooded', label: 'flooded', object: 'none', cardinality: 'one' },
{ id: 'is-named', label: 'is named', object: 'value', cardinality: 'one' },
],
});
const collapsed = claim.unary('bridge', 'collapsed');
const flooded = claim.unary('river', 'flooded');
const banditName = claim.value('bandit', 'is-named', 'Ressa');
// What Wren sees becomes canon and her own knowledge of it, at the scene that holds them.
const sees = (id: string, seen: Claim, eventId: string): RecordInput[] => [
fact(seen, { id: `fact-${id}` }),
knows('wren', seen, witnessed(eventId), { id }),
];
const scene = (timelineId: string, id: string, title: string, tick: number, records: RecordInput[]) => client.commit({
projectId, timelineId, attemptId: id,
event: { id, type: 'scene', title, position: at(tick), participants: ['wren'], provenance },
records,
});
const wrenKnows = async (asked: Claim, timelineId: string) =>
(await client.holds({ projectId, timelineId, holder: 'wren', claim: asked })).holdsAs === 'same';
// Morning of day 12: the day is a draft. Nothing in it counts until Wren sleeps.
await client.timelineOpenDraft({ projectId, id: 'day-12', label: 'Day 12' });
await scene('day-12', 'bridge-down', 'The bridge is gone', 12, sees('wren-bridge', collapsed, 'bridge-down'));
// An ambush on the road: a draft inside the day. Wren falls, and the player reloads it.
await client.timelineOpenDraft({ projectId, id: 'ambush-1', label: 'Ambush, first try', baseTimelineId: 'day-12' });
await scene('ambush-1', 'ambush', 'Ambush on the road', 12, sees('wren-name', banditName, 'ambush'));
print('midday', {
bridgeOnMain: await wrenKnows(collapsed, 'main'),
bridgeInDay: await wrenKnows(collapsed, 'day-12'),
nameInDay: await wrenKnows(banditName, 'day-12'),
bridgeInAmbush: await wrenKnows(collapsed, 'ambush-1'),
nameOnMain: await wrenKnows(banditName, 'main'),
nameInAmbush: await wrenKnows(banditName, 'ambush-1'),
});
await client.timelineDiscard({ projectId, timelineId: 'ambush-1' });
// The second try replays the same scene, with the same ids. A discarded draft still holds them.
await client.timelineOpenDraft({ projectId, id: 'ambush-2', label: 'Ambush, second try', baseTimelineId: 'day-12' });
try {
await scene('ambush-2', 'ambush', 'Ambush on the road', 12, sees('wren-name', banditName, 'ambush'));
} catch (error) {
if (!(error instanceof UmweltError) || error.code !== 'CONFLICT') throw error;
print('replay-refused', { code: error.code });
print('purged', await client.timelinePurge({ projectId, timelineId: 'ambush-1' }));
await scene('ambush-2', 'ambush', 'Ambush on the road', 12, sees('wren-name', banditName, 'ambush'));
}
await client.timelinePromote({ projectId, timelineId: 'ambush-2' });
// Meanwhile the world kept turning on main: the river flooded. When Wren sleeps,
// the day cannot be promoted over a main it has not seen.
await scene('main', 'flood', 'The river floods', 12, [fact(flooded, { id: 'fact-flood' })]);
try {
await client.timelinePromote({ projectId, timelineId: 'day-12' });
} catch (error) {
if (!(error instanceof UmweltError) || error.code !== 'VERSION_CONFLICT') throw error;
print('sleep-refused', { code: error.code, details: error.details });
const rebased = await client.timelineRebase({ projectId, timelineId: 'day-12' });
print('rebased', { baseVersion: rebased.baseVersion, headVersion: rebased.headVersion });
await client.timelinePromote({ projectId, timelineId: 'day-12' });
}
print('after-sleep', {
bridge: await wrenKnows(collapsed, 'main'),
name: await wrenKnows(banditName, 'main'),
});
// Day 13: the player quits without saving. Discard and purge, and main never saw it.
await client.timelineOpenDraft({ projectId, id: 'day-13', label: 'Day 13' });
await scene('day-13', 'ford', 'The flooded ford', 13, sees('wren-flood', flooded, 'ford'));
const quit = await client.timelineDiscard({ projectId, timelineId: 'day-13', purge: true });
print('quit', { status: quit.status, purged: Boolean(quit.purgedAt), floodOnMain: await wrenKnows(flooded, 'main') });
print('timelines', (await client.timelineList({ projectId })).map(({ id, parentTimelineId, status, depth, purgedAt }) =>
({ id, parent: parentTimelineId ?? null, status, depth, purged: Boolean(purgedAt) })));
await client.close();using System.Text.Json;
using System.Text.Json.Serialization;
using Kallamo.Umwelt;
using var engine = UmweltEngine.Create("deepmoor.umw");
const string projectId = "deepmoor";
var provenance = Provenance.Human();
var json = new JsonSerializerOptions
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.KebabCaseLower) },
};
void Print(string label, object? value) => Console.WriteLine($"@@{label}\n{JsonSerializer.Serialize(value, json)}");
// The world, declared once: sending the same manifest again changes nothing.
engine.WorldEnsure(new EnsureWorldInput
{
Project = new WorldProject { Id = projectId, Name = "Deepmoor", Timescale = "day" },
EntityTypes = new()
{
new WorldEntityType { Id = "person", Name = "Person", Kind = EntityKind.Agent },
new WorldEntityType { Id = "place", Name = "Place", Kind = EntityKind.Other },
},
Entities = new()
{
new WorldEntity { Id = "wren", TypeId = "person", Name = "Wren, the player" },
new WorldEntity { Id = "bandit", TypeId = "person", Name = "The bandit" },
new WorldEntity { Id = "bridge", TypeId = "place", Name = "The old bridge" },
new WorldEntity { Id = "river", TypeId = "place", Name = "The river" },
},
Predicates = new()
{
new PredicateDef { Id = "collapsed", Label = "collapsed", Object = ObjectKind.None, Cardinality = Cardinality.One },
new PredicateDef { Id = "flooded", Label = "flooded", Object = ObjectKind.None, Cardinality = Cardinality.One },
new PredicateDef { Id = "is-named", Label = "is named", Object = ObjectKind.Value, Cardinality = Cardinality.One },
},
});
var collapsed = Claim.Unary("bridge", "collapsed");
var flooded = Claim.Unary("river", "flooded");
var banditName = Claim.Value("bandit", "is-named", "Ressa");
// What Wren sees becomes canon and her own knowledge of it, at the scene that holds them.
List<RecordInput> Sees(string id, Claim seen, string eventId) => new()
{
Records.Fact(seen, id: $"fact-{id}"),
Records.Knows("wren", seen, Acquired.Witnessed(eventId), id: id),
};
CommitOutcome Scene(string timelineId, string id, string title, long tick, List<RecordInput> records) => engine.Commit(new CommitInput
{
ProjectId = projectId, TimelineId = timelineId, AttemptId = id,
Event = new EventInput { Id = id, Type = "scene", Title = title, Position = Position.At(tick), Participants = new() { "wren" }, Provenance = provenance },
Records = records,
});
bool WrenKnows(Claim claim, string timelineId) =>
engine.Holds(new HoldsPayload { ProjectId = projectId, TimelineId = timelineId, Holder = "wren", Claim = claim }).HoldsAs == HoldsAs.Same;
// Morning of day 12: the day is a draft. Nothing in it counts until Wren sleeps.
engine.TimelineOpenDraft(new OpenDraftInput { ProjectId = projectId, Id = "day-12", Label = "Day 12" });
Scene("day-12", "bridge-down", "The bridge is gone", 12, Sees("wren-bridge", collapsed, "bridge-down"));
// An ambush on the road: a draft inside the day. Wren falls, and the player reloads it.
engine.TimelineOpenDraft(new OpenDraftInput { ProjectId = projectId, Id = "ambush-1", Label = "Ambush, first try", BaseTimelineId = "day-12" });
Scene("ambush-1", "ambush", "Ambush on the road", 12, Sees("wren-name", banditName, "ambush"));
Print("midday", new
{
bridgeOnMain = WrenKnows(collapsed, "main"),
bridgeInDay = WrenKnows(collapsed, "day-12"),
nameInDay = WrenKnows(banditName, "day-12"),
bridgeInAmbush = WrenKnows(collapsed, "ambush-1"),
nameOnMain = WrenKnows(banditName, "main"),
nameInAmbush = WrenKnows(banditName, "ambush-1"),
});
engine.TimelineDiscard(new DiscardPayload { ProjectId = projectId, TimelineId = "ambush-1" });
// The second try replays the same scene, with the same ids. A discarded draft still holds them.
engine.TimelineOpenDraft(new OpenDraftInput { ProjectId = projectId, Id = "ambush-2", Label = "Ambush, second try", BaseTimelineId = "day-12" });
try
{
Scene("ambush-2", "ambush", "Ambush on the road", 12, Sees("wren-name", banditName, "ambush"));
}
catch (ConflictException error)
{
Print("replay-refused", new { code = error.Code });
Print("purged", engine.TimelinePurge(new TimelineRef { ProjectId = projectId, TimelineId = "ambush-1" }));
Scene("ambush-2", "ambush", "Ambush on the road", 12, Sees("wren-name", banditName, "ambush"));
}
engine.TimelinePromote(new TimelineRef { ProjectId = projectId, TimelineId = "ambush-2" });
// Meanwhile the world kept turning on main: the river flooded. When Wren sleeps,
// the day cannot be promoted over a main it has not seen.
Scene("main", "flood", "The river floods", 12, new() { Records.Fact(flooded, id: "fact-flood") });
try
{
engine.TimelinePromote(new TimelineRef { ProjectId = projectId, TimelineId = "day-12" });
}
catch (VersionConflictException error)
{
Print("sleep-refused", new { code = error.Code, details = error.Details });
var rebased = engine.TimelineRebase(new TimelineRef { ProjectId = projectId, TimelineId = "day-12" });
Print("rebased", new { baseVersion = rebased.BaseVersion, headVersion = rebased.HeadVersion });
engine.TimelinePromote(new TimelineRef { ProjectId = projectId, TimelineId = "day-12" });
}
Print("after-sleep", new { bridge = WrenKnows(collapsed, "main"), name = WrenKnows(banditName, "main") });
// Day 13: the player quits without saving. Discard and purge, and main never saw it.
engine.TimelineOpenDraft(new OpenDraftInput { ProjectId = projectId, Id = "day-13", Label = "Day 13" });
Scene("day-13", "ford", "The flooded ford", 13, Sees("wren-flood", flooded, "ford"));
var quit = engine.TimelineDiscard(new DiscardPayload { ProjectId = projectId, TimelineId = "day-13", Purge = true });
Print("quit", new { status = quit.Status, purged = quit.PurgedAt is not null, floodOnMain = WrenKnows(flooded, "main") });
Print("timelines", engine.TimelineList(new TimelineListPayload { ProjectId = projectId }).Select(t => new
{
id = t.Id, parent = t.ParentTimelineId, status = t.Status, depth = t.Depth, purged = t.PurgedAt is not null,
}));