UmweltDocs
Recipes/What each player knows

What each player knows

The party splits. Players write their own notes, the game master accepts or refuses each line, and every character sheet is a query at its session.

KiraThe duke meets the Drowned Choir.acceptedYou watched them through the window.
KiraThe high priest leads the Choir.rejectedThe priest was never at the manor. You have no way to know.
OrenThe Choir worships the drowned god.acceptedThe murals in the crypt show it.
TaviThe duke meets the Drowned Choir.rejectedOnly Kira saw that, and she has not told anyone yet.

The players’ notes after session 2, line by line, with the game master’s decision and the reason the players read. Rejected lines never become knowledge.

The idea

At a table, the game master knows everything and each player should know only what their character saw. Let players write their own notes, but as proposals: nothing counts until the game master accepts it, line by line, with a reason. A character sheet is then not a document to keep up to date but a question: what does this character hold, at this session?

The table decides

  • what each player writes after a session
  • which lines the game master accepts
  • the reason given for each decision
  • when the party shares what it learned

Umwelt keeps

  • each note as a proposal, untouched until reviewed
  • every review, with its rationale
  • only accepted lines, as Holdings
  • every character’s sheet, at every session

The Salt Road

A campaign in the salt country. In session 1 the party watches raiders hit a salt caravan. In session 2 they split up: Kira scouts Duke Halvard’s manor, Oren visits the temple, Tavi works the tavern.

The game master knows the duke meets a cult called the Drowned Choir, that the Choir worships a drowned god, and that the high priest leads it.

What happened

Session 1 is witnessed by the whole party. The game master’s truth is canon: Facts no character holds yet.

salt-road.ts
const raided = claim.unary('caravan', 'was-raided');
const dukeMeetsCult = claim.entity('duke', 'meets', 'cult');
const cultWorships = claim.entity('cult', 'worships', 'drowned-god');
const priestLeads = claim.entity('priest', 'leads', 'cult');
const party = ['kira', 'oren', 'tavi'];

// Session 1, together: the raid on the caravan.
await client.witness({
  projectId, attemptId: 'session-1',
  event: { id: 'session-1', type: 'session', title: 'Session 1: the raid', position: at(1), participants: party, provenance: gm },
  claims: [{ claim: raided, canon: true, factId: 'canon-raid' }],
  witnesses: party.map((holder) => ({ holder })),
});
// The truth the game master keeps, written before session 2.
await client.commit({
  projectId, attemptId: 'gm-notes',
  event: { id: 'gm-notes', type: 'prep', title: 'The game master’s notes', position: at(2), participants: [], provenance: gm },
  records: [
    fact(dukeMeetsCult, { id: 'canon-meets' }),
    fact(cultWorships, { id: 'canon-worships' }),
    fact(priestLeads, { id: 'canon-leads' }),
  ],
});
Program.cs
var raided = Claim.Unary("caravan", "was-raided");
var dukeMeetsCult = Claim.Entity("duke", "meets", "cult");
var cultWorships = Claim.Entity("cult", "worships", "drowned-god");
var priestLeads = Claim.Entity("priest", "leads", "cult");
var party = new List<string> { "kira", "oren", "tavi" };

// Session 1, together: the raid on the caravan.
engine.Witness(new WitnessInput
{
    ProjectId = projectId, AttemptId = "session-1",
    Event = new EventInput { Id = "session-1", Type = "session", Title = "Session 1: the raid", Position = Position.At(1), Participants = party, Provenance = gm },
    Claims = new() { new WitnessedClaim { Claim = raided, Canon = true, FactId = "canon-raid" } },
    Witnesses = party.Select(holder => new Witness { Holder = holder }).ToList(),
});
// The truth the game master keeps, written before session 2.
var two = Position.At(2);
engine.Commit(new CommitInput
{
    ProjectId = projectId, AttemptId = "gm-notes",
    Event = new EventInput { Id = "gm-notes", Type = "prep", Title = "The game master’s notes", Position = two, Participants = new(), Provenance = gm },
    Records = new()
    {
        Records.Fact(dukeMeetsCult, id: "canon-meets"),
        Records.Fact(cultWorships, id: "canon-worships"),
        Records.Fact(priestLeads, id: "canon-leads"),
    },
});

The players’ notes

Each note is a propose: an event and the Holdings the player claims for their character. The game master reviews each line, then applies what was accepted. Tavi’s note claims something only Kira saw, so it is rejected whole.

salt-road.tsYour code
// Session 2, the party splits. Each player writes what their character learned.
// Notes are proposals: nothing counts until the game master reviews them.
// A note is signed by its player: the record's own provenance, not the event's.
const learned = (id: string, holder: string, seen: Claim, eventId: string) =>
  knows(holder, seen, witnessed(eventId), { id, provenance: by.human(holder) });
const notes = {
  kira: await client.propose({
    projectId,
    event: { id: 'manor', type: 'scene', title: 'Kira scouts the manor', position: at(2), participants: ['kira', 'duke'], provenance: gm },
    records: [learned('kira-duke', 'kira', dukeMeetsCult, 'manor'), learned('kira-priest', 'kira', priestLeads, 'manor')],
  }),
  oren: await client.propose({
    projectId,
    event: { id: 'temple', type: 'scene', title: 'Oren at the temple', position: at(2), participants: ['oren', 'priest'], provenance: gm },
    records: [learned('oren-god', 'oren', cultWorships, 'temple')],
  }),
  tavi: await client.propose({
    projectId,
    event: { id: 'tavern', type: 'scene', title: 'Tavi at the tavern', position: at(2), participants: ['tavi'], provenance: gm },
    records: [learned('tavi-duke', 'tavi', dukeMeetsCult, 'tavern')],
  }),
};

// The game master's review: one decision per line, with the reason the players will read.
await client.proposalReview({ proposalId: notes.kira.id, reviews: [
  { operationIndex: 0, decision: 'accepted', rationale: 'You watched them through the window.' },
  { operationIndex: 1, decision: 'rejected', rationale: 'The priest was never at the manor. You have no way to know.' },
] });
await client.proposalReview({ proposalId: notes.oren.id, reviews: [
  { operationIndex: 0, decision: 'accepted', rationale: 'The murals in the crypt show it.' },
] });
await client.proposalReview({ proposalId: notes.tavi.id, reviews: [
  { operationIndex: 0, decision: 'rejected', rationale: 'Only Kira saw that, and she has not told anyone yet.' },
] });
for (const [who, proposal] of Object.entries(notes)) {
  if (who === 'tavi') { await client.proposalReject({ proposalId: proposal.id, rationale: 'Nothing in this note happened to Tavi.' }); continue; }
  await client.proposalApply({ proposalId: proposal.id, attemptId: `notes-${who}` });
}
const reviewed = await client.proposalList({ projectId });
print('notes', reviewed.map((p) => ({
  event: p.input.event.title, status: p.status,
  lines: (p.reviews ?? []).map((r) => ({ decision: r.decision, rationale: r.rationale })),
  rejected: p.rejectionRationale ?? null,
})));
Program.csYour code
// Session 2, the party splits. Each player writes what their character learned.
// Notes are proposals: nothing counts until the game master reviews them.
// A note is signed by its player: the record's own provenance, not the event's.
RecordInput Learned(string id, string holder, Claim seen, string eventId) =>
    Records.Knows(holder, seen, Acquired.Witnessed(eventId), id: id, provenance: Provenance.Human(holder));
Proposal Note(string eventId, string title, List<string> participants, List<RecordInput> records) => engine.Propose(new ProposeInput
{
    ProjectId = projectId,
    Event = new EventInput { Id = eventId, Type = "scene", Title = title, Position = two, Participants = participants, Provenance = gm },
    Records = records,
});
var notes = new Dictionary<string, Proposal>
{
    ["kira"] = Note("manor", "Kira scouts the manor", new() { "kira", "duke" }, new() { Learned("kira-duke", "kira", dukeMeetsCult, "manor"), Learned("kira-priest", "kira", priestLeads, "manor") }),
    ["oren"] = Note("temple", "Oren at the temple", new() { "oren", "priest" }, new() { Learned("oren-god", "oren", cultWorships, "temple") }),
    ["tavi"] = Note("tavern", "Tavi at the tavern", new() { "tavi" }, new() { Learned("tavi-duke", "tavi", dukeMeetsCult, "tavern") }),
};

// The game master's review: one decision per line, with the reason the players will read.
ReviewInput Decide(ulong index, ReviewDecision decision, string rationale) => new() { OperationIndex = index, Decision = decision, Rationale = rationale };
engine.ProposalReview(new ReviewPayload { ProposalId = notes["kira"].Id, Reviews = new()
{
    Decide(0, ReviewDecision.Accepted, "You watched them through the window."),
    Decide(1, ReviewDecision.Rejected, "The priest was never at the manor. You have no way to know."),
} });
engine.ProposalReview(new ReviewPayload { ProposalId = notes["oren"].Id, Reviews = new() { Decide(0, ReviewDecision.Accepted, "The murals in the crypt show it.") } });
engine.ProposalReview(new ReviewPayload { ProposalId = notes["tavi"].Id, Reviews = new() { Decide(0, ReviewDecision.Rejected, "Only Kira saw that, and she has not told anyone yet.") } });
foreach (var (who, proposal) in notes)
{
    if (who == "tavi") { engine.ProposalReject(new RejectPayload { ProposalId = proposal.Id, Rationale = "Nothing in this note happened to Tavi." }); continue; }
    engine.ProposalApply(new ApplyPayload { ProposalId = proposal.Id, AttemptId = $"notes-{who}" });
}
var reviewed = engine.ProposalList(new ProjectId { ProjectIdValue = projectId });
Print("notes", reviewed.Select(p => new
{
    @event = p.Input.Event.Title, status = p.Status,
    lines = (p.Reviews ?? new()).Select(r => new { decision = r.Decision, rationale = r.Rationale }),
    rejected = p.RejectionRationale,
}));
proposal.list
[
  {
    "event": "Kira scouts the manor",
    "status": "partially-applied",
    "lines": [
      {
        "decision": "accepted",
        "rationale": "You watched them through the window."
      },
      {
        "decision": "rejected",
        "rationale": "The priest was never at the manor. You have no way to know."
      }
    ],
    "rejected": null
  },
  {
    "event": "Oren at the temple",
    "status": "applied",
    "lines": [
      {
        "decision": "accepted",
        "rationale": "The murals in the crypt show it."
      }
    ],
    "rejected": null
  },
  {
    "event": "Tavi at the tavern",
    "status": "rejected",
    "lines": [
      {
        "decision": "rejected",
        "rationale": "Only Kira saw that, and she has not told anyone yet."
      }
    ],
    "rejected": "Nothing in this note happened to Tavi."
  }
]

Partially applied. Kira’s note keeps the line she saw and drops the one she guessed. Its status says so: partially-applied.

Session 3

The party meets again and Kira tells them what she saw at the manor. Now it is theirs too, as something they believe on her word.

salt-road.ts
// Session 3: Kira tells the others what she saw at the manor.
await client.transmitBatch({
  shared: { projectId, from: 'kira', sourceRecordId: 'kira-duke', intent: 'honest', attitude: 'believes', confidence: 0.9, position: at(3) },
  transmits: ['oren', 'tavi'].map((to) => ({ attemptId: `kira-tells-${to}`, to, recordId: `${to}-duke` })),
});
Program.cs
// Session 3: Kira tells the others what she saw at the manor.
engine.TransmitBatch(new TransmitBatchPayload
{
    Shared = new TransmitShared
    {
        ProjectId = projectId, From = "kira", SourceRecordId = "kira-duke", Intent = Intent.Honest,
        Attitude = Attitude.Believes, Confidence = 0.9, Position = Position.At(3),
    },
    Transmits = new[] { "oren", "tavi" }.Select(to => new TransmitBatchItem { AttemptId = $"kira-tells-{to}", To = to, RecordId = $"{to}-duke" }).ToList(),
});

Character sheets

salt-road.ts
// A character sheet is a query: what the character holds at that session, nothing else.
const line = (held: Claim) => `${held.subject} ${held.predicate} ${held.object.kind === 'entity' ? held.object.id : ''}`.trim();
const sheets: Record<string, Record<string, string[]>> = {};
for (const who of party) {
  sheets[who] = {};
  for (const session of [1, 2, 3]) {
    const view = await client.perspective({ projectId, holder: who, at: { tick: session } });
    sheets[who][session] = view.holdings.map(({ record }) => (record.kind === 'holding' ? line(record.claim) : ''));
  }
}
print('sheets', sheets);

const split = await client.compare({ projectId, a: 'kira', b: 'tavi', at: { tick: 2 } });
print('kira-vs-tavi-2', split.counts);
const together = await client.compare({ projectId, a: 'kira', b: 'tavi', at: { tick: 3 } });
print('kira-vs-tavi-3', together.counts);
print('who-knows-the-duke', await client.whoHolds({ projectId, claim: dukeMeetsCult, at: { tick: 3 } }));
await client.close();
Program.cs
// A character sheet is a query: what the character holds at that session, nothing else.
string Line(Claim claim) => $"{claim.Subject} {claim.Predicate} {(claim.Object as ClaimObject.Entity)?.Id}".Trim();
var sheets = new Dictionary<string, Dictionary<string, List<string>>>();
foreach (var who in party)
{
    sheets[who] = new();
    foreach (var session in new long[] { 1, 2, 3 })
    {
        var view = engine.Perspective(new PerspectivePayload { ProjectId = projectId, Holder = who, At = new At { Tick = session } });
        sheets[who][session.ToString()] = view.Holdings.Select(item => item.Record is Record.Holding h ? Line(h.Claim) : "").ToList();
    }
}
Print("sheets", sheets);

Print("kira-vs-tavi-2", engine.Compare(new ComparePayload { ProjectId = projectId, A = "kira", B = "tavi", At = new At { Tick = 2 } }).Counts);
Print("kira-vs-tavi-3", engine.Compare(new ComparePayload { ProjectId = projectId, A = "kira", B = "tavi", At = new At { Tick = 3 } }).Counts);
Print("who-knows-the-duke", engine.WhoHolds(new WhoHoldsPayload { ProjectId = projectId, Claim = dukeMeetsCult, At = new At { Tick = 3 } }));

Kira, the rogue

  1. Session 1caravan was-raided
  2. Session 2caravan was-raidedduke meets cult
  3. Session 3caravan was-raidedduke meets cult

Oren, the cleric

  1. Session 1caravan was-raided
  2. Session 2caravan was-raidedcult worships drowned-god
  3. Session 3caravan was-raidedcult worships drowned-godduke meets cult

Tavi, the bard

  1. Session 1caravan was-raided
  2. Session 2caravan was-raided
  3. Session 3caravan was-raidedduke meets cult

Each sheet is perspective at that session. Green: new since the session before.

1things only Kira knows after session 2
0after session 3, once she has told them
3characters who hold that the duke meets the Choir; nobody holds who leads it

Canon is not a player’s knowledge. The high priest leads the Choir in canon from session 2, and no sheet shows it. Kira’s guess was rejected, and nobody else ever learned it.

Try next

A recap per playerUse compare between sessions to write “what you learned last time” for each player.
Secrets at the tableGive a character a secret with secrecy, and see who tells the party with breaches.
Rebase a stale noteA note written before the game master changed canon: proposal.rebase says which lines still fit.
Long campaignsThe tabletop guide covers sessions, recaps and player screens.

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.

salt-road.ts
import { at, by, claim, createClient, fact, knows, witnessed } from '@kallamo/umwelt';
import type { Claim } from '@kallamo/umwelt';
const client = createClient({ file: 'salt-road.umw', create: true });
const projectId = 'salt-road';
const gm = by.human('gm');
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: 'The Salt Road', timescale: 'session' },
  entityTypes: [
    { id: 'character', name: 'Character', kind: 'agent' },
    { id: 'thing', name: 'Thing', kind: 'other' },
  ],
  entities: [
    { id: 'kira', typeId: 'character', name: 'Kira, the rogue' },
    { id: 'oren', typeId: 'character', name: 'Oren, the cleric' },
    { id: 'tavi', typeId: 'character', name: 'Tavi, the bard' },
    { id: 'duke', typeId: 'character', name: 'Duke Halvard' },
    { id: 'priest', typeId: 'character', name: 'The high priest' },
    { id: 'caravan', typeId: 'thing', name: 'The salt caravan' },
    { id: 'cult', typeId: 'thing', name: 'The Drowned Choir' },
    { id: 'drowned-god', typeId: 'thing', name: 'The drowned god' },
  ],
  predicates: [
    { id: 'was-raided', label: 'was raided', object: 'none', cardinality: 'many' },
    { id: 'meets', label: 'meets', object: 'entity', cardinality: 'many' },
    { id: 'worships', label: 'worships', object: 'entity', cardinality: 'many' },
    { id: 'leads', label: 'leads', object: 'entity', cardinality: 'many' },
  ],
});
const raided = claim.unary('caravan', 'was-raided');
const dukeMeetsCult = claim.entity('duke', 'meets', 'cult');
const cultWorships = claim.entity('cult', 'worships', 'drowned-god');
const priestLeads = claim.entity('priest', 'leads', 'cult');
const party = ['kira', 'oren', 'tavi'];

// Session 1, together: the raid on the caravan.
await client.witness({
  projectId, attemptId: 'session-1',
  event: { id: 'session-1', type: 'session', title: 'Session 1: the raid', position: at(1), participants: party, provenance: gm },
  claims: [{ claim: raided, canon: true, factId: 'canon-raid' }],
  witnesses: party.map((holder) => ({ holder })),
});
// The truth the game master keeps, written before session 2.
await client.commit({
  projectId, attemptId: 'gm-notes',
  event: { id: 'gm-notes', type: 'prep', title: 'The game master’s notes', position: at(2), participants: [], provenance: gm },
  records: [
    fact(dukeMeetsCult, { id: 'canon-meets' }),
    fact(cultWorships, { id: 'canon-worships' }),
    fact(priestLeads, { id: 'canon-leads' }),
  ],
});
// Session 2, the party splits. Each player writes what their character learned.
// Notes are proposals: nothing counts until the game master reviews them.
// A note is signed by its player: the record's own provenance, not the event's.
const learned = (id: string, holder: string, seen: Claim, eventId: string) =>
  knows(holder, seen, witnessed(eventId), { id, provenance: by.human(holder) });
const notes = {
  kira: await client.propose({
    projectId,
    event: { id: 'manor', type: 'scene', title: 'Kira scouts the manor', position: at(2), participants: ['kira', 'duke'], provenance: gm },
    records: [learned('kira-duke', 'kira', dukeMeetsCult, 'manor'), learned('kira-priest', 'kira', priestLeads, 'manor')],
  }),
  oren: await client.propose({
    projectId,
    event: { id: 'temple', type: 'scene', title: 'Oren at the temple', position: at(2), participants: ['oren', 'priest'], provenance: gm },
    records: [learned('oren-god', 'oren', cultWorships, 'temple')],
  }),
  tavi: await client.propose({
    projectId,
    event: { id: 'tavern', type: 'scene', title: 'Tavi at the tavern', position: at(2), participants: ['tavi'], provenance: gm },
    records: [learned('tavi-duke', 'tavi', dukeMeetsCult, 'tavern')],
  }),
};

// The game master's review: one decision per line, with the reason the players will read.
await client.proposalReview({ proposalId: notes.kira.id, reviews: [
  { operationIndex: 0, decision: 'accepted', rationale: 'You watched them through the window.' },
  { operationIndex: 1, decision: 'rejected', rationale: 'The priest was never at the manor. You have no way to know.' },
] });
await client.proposalReview({ proposalId: notes.oren.id, reviews: [
  { operationIndex: 0, decision: 'accepted', rationale: 'The murals in the crypt show it.' },
] });
await client.proposalReview({ proposalId: notes.tavi.id, reviews: [
  { operationIndex: 0, decision: 'rejected', rationale: 'Only Kira saw that, and she has not told anyone yet.' },
] });
for (const [who, proposal] of Object.entries(notes)) {
  if (who === 'tavi') { await client.proposalReject({ proposalId: proposal.id, rationale: 'Nothing in this note happened to Tavi.' }); continue; }
  await client.proposalApply({ proposalId: proposal.id, attemptId: `notes-${who}` });
}
const reviewed = await client.proposalList({ projectId });
print('notes', reviewed.map((p) => ({
  event: p.input.event.title, status: p.status,
  lines: (p.reviews ?? []).map((r) => ({ decision: r.decision, rationale: r.rationale })),
  rejected: p.rejectionRationale ?? null,
})));
// Session 3: Kira tells the others what she saw at the manor.
await client.transmitBatch({
  shared: { projectId, from: 'kira', sourceRecordId: 'kira-duke', intent: 'honest', attitude: 'believes', confidence: 0.9, position: at(3) },
  transmits: ['oren', 'tavi'].map((to) => ({ attemptId: `kira-tells-${to}`, to, recordId: `${to}-duke` })),
});
// A character sheet is a query: what the character holds at that session, nothing else.
const line = (held: Claim) => `${held.subject} ${held.predicate} ${held.object.kind === 'entity' ? held.object.id : ''}`.trim();
const sheets: Record<string, Record<string, string[]>> = {};
for (const who of party) {
  sheets[who] = {};
  for (const session of [1, 2, 3]) {
    const view = await client.perspective({ projectId, holder: who, at: { tick: session } });
    sheets[who][session] = view.holdings.map(({ record }) => (record.kind === 'holding' ? line(record.claim) : ''));
  }
}
print('sheets', sheets);

const split = await client.compare({ projectId, a: 'kira', b: 'tavi', at: { tick: 2 } });
print('kira-vs-tavi-2', split.counts);
const together = await client.compare({ projectId, a: 'kira', b: 'tavi', at: { tick: 3 } });
print('kira-vs-tavi-3', together.counts);
print('who-knows-the-duke', await client.whoHolds({ projectId, claim: dukeMeetsCult, at: { tick: 3 } }));
await client.close();
Program.cs
using System.Text.Json;
using System.Text.Json.Serialization;
using Kallamo.Umwelt;
using var engine = UmweltEngine.Create("salt-road.umw");
const string projectId = "salt-road";
var gm = Provenance.Human("gm");
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 = "The Salt Road", Timescale = "session" },
    EntityTypes = new()
    {
        new WorldEntityType { Id = "character", Name = "Character", Kind = EntityKind.Agent },
        new WorldEntityType { Id = "thing", Name = "Thing", Kind = EntityKind.Other },
    },
    Entities = new()
    {
        new WorldEntity { Id = "kira", TypeId = "character", Name = "Kira, the rogue" },
        new WorldEntity { Id = "oren", TypeId = "character", Name = "Oren, the cleric" },
        new WorldEntity { Id = "tavi", TypeId = "character", Name = "Tavi, the bard" },
        new WorldEntity { Id = "duke", TypeId = "character", Name = "Duke Halvard" },
        new WorldEntity { Id = "priest", TypeId = "character", Name = "The high priest" },
        new WorldEntity { Id = "caravan", TypeId = "thing", Name = "The salt caravan" },
        new WorldEntity { Id = "cult", TypeId = "thing", Name = "The Drowned Choir" },
        new WorldEntity { Id = "drowned-god", TypeId = "thing", Name = "The drowned god" },
    },
    Predicates = new()
    {
        new PredicateDef { Id = "was-raided", Label = "was raided", Object = ObjectKind.None, Cardinality = Cardinality.Many },
        new PredicateDef { Id = "meets", Label = "meets", Object = ObjectKind.Entity, Cardinality = Cardinality.Many },
        new PredicateDef { Id = "worships", Label = "worships", Object = ObjectKind.Entity, Cardinality = Cardinality.Many },
        new PredicateDef { Id = "leads", Label = "leads", Object = ObjectKind.Entity, Cardinality = Cardinality.Many },
    },
});
var raided = Claim.Unary("caravan", "was-raided");
var dukeMeetsCult = Claim.Entity("duke", "meets", "cult");
var cultWorships = Claim.Entity("cult", "worships", "drowned-god");
var priestLeads = Claim.Entity("priest", "leads", "cult");
var party = new List<string> { "kira", "oren", "tavi" };

// Session 1, together: the raid on the caravan.
engine.Witness(new WitnessInput
{
    ProjectId = projectId, AttemptId = "session-1",
    Event = new EventInput { Id = "session-1", Type = "session", Title = "Session 1: the raid", Position = Position.At(1), Participants = party, Provenance = gm },
    Claims = new() { new WitnessedClaim { Claim = raided, Canon = true, FactId = "canon-raid" } },
    Witnesses = party.Select(holder => new Witness { Holder = holder }).ToList(),
});
// The truth the game master keeps, written before session 2.
var two = Position.At(2);
engine.Commit(new CommitInput
{
    ProjectId = projectId, AttemptId = "gm-notes",
    Event = new EventInput { Id = "gm-notes", Type = "prep", Title = "The game master’s notes", Position = two, Participants = new(), Provenance = gm },
    Records = new()
    {
        Records.Fact(dukeMeetsCult, id: "canon-meets"),
        Records.Fact(cultWorships, id: "canon-worships"),
        Records.Fact(priestLeads, id: "canon-leads"),
    },
});
// Session 2, the party splits. Each player writes what their character learned.
// Notes are proposals: nothing counts until the game master reviews them.
// A note is signed by its player: the record's own provenance, not the event's.
RecordInput Learned(string id, string holder, Claim seen, string eventId) =>
    Records.Knows(holder, seen, Acquired.Witnessed(eventId), id: id, provenance: Provenance.Human(holder));
Proposal Note(string eventId, string title, List<string> participants, List<RecordInput> records) => engine.Propose(new ProposeInput
{
    ProjectId = projectId,
    Event = new EventInput { Id = eventId, Type = "scene", Title = title, Position = two, Participants = participants, Provenance = gm },
    Records = records,
});
var notes = new Dictionary<string, Proposal>
{
    ["kira"] = Note("manor", "Kira scouts the manor", new() { "kira", "duke" }, new() { Learned("kira-duke", "kira", dukeMeetsCult, "manor"), Learned("kira-priest", "kira", priestLeads, "manor") }),
    ["oren"] = Note("temple", "Oren at the temple", new() { "oren", "priest" }, new() { Learned("oren-god", "oren", cultWorships, "temple") }),
    ["tavi"] = Note("tavern", "Tavi at the tavern", new() { "tavi" }, new() { Learned("tavi-duke", "tavi", dukeMeetsCult, "tavern") }),
};

// The game master's review: one decision per line, with the reason the players will read.
ReviewInput Decide(ulong index, ReviewDecision decision, string rationale) => new() { OperationIndex = index, Decision = decision, Rationale = rationale };
engine.ProposalReview(new ReviewPayload { ProposalId = notes["kira"].Id, Reviews = new()
{
    Decide(0, ReviewDecision.Accepted, "You watched them through the window."),
    Decide(1, ReviewDecision.Rejected, "The priest was never at the manor. You have no way to know."),
} });
engine.ProposalReview(new ReviewPayload { ProposalId = notes["oren"].Id, Reviews = new() { Decide(0, ReviewDecision.Accepted, "The murals in the crypt show it.") } });
engine.ProposalReview(new ReviewPayload { ProposalId = notes["tavi"].Id, Reviews = new() { Decide(0, ReviewDecision.Rejected, "Only Kira saw that, and she has not told anyone yet.") } });
foreach (var (who, proposal) in notes)
{
    if (who == "tavi") { engine.ProposalReject(new RejectPayload { ProposalId = proposal.Id, Rationale = "Nothing in this note happened to Tavi." }); continue; }
    engine.ProposalApply(new ApplyPayload { ProposalId = proposal.Id, AttemptId = $"notes-{who}" });
}
var reviewed = engine.ProposalList(new ProjectId { ProjectIdValue = projectId });
Print("notes", reviewed.Select(p => new
{
    @event = p.Input.Event.Title, status = p.Status,
    lines = (p.Reviews ?? new()).Select(r => new { decision = r.Decision, rationale = r.Rationale }),
    rejected = p.RejectionRationale,
}));
// Session 3: Kira tells the others what she saw at the manor.
engine.TransmitBatch(new TransmitBatchPayload
{
    Shared = new TransmitShared
    {
        ProjectId = projectId, From = "kira", SourceRecordId = "kira-duke", Intent = Intent.Honest,
        Attitude = Attitude.Believes, Confidence = 0.9, Position = Position.At(3),
    },
    Transmits = new[] { "oren", "tavi" }.Select(to => new TransmitBatchItem { AttemptId = $"kira-tells-{to}", To = to, RecordId = $"{to}-duke" }).ToList(),
});
// A character sheet is a query: what the character holds at that session, nothing else.
string Line(Claim claim) => $"{claim.Subject} {claim.Predicate} {(claim.Object as ClaimObject.Entity)?.Id}".Trim();
var sheets = new Dictionary<string, Dictionary<string, List<string>>>();
foreach (var who in party)
{
    sheets[who] = new();
    foreach (var session in new long[] { 1, 2, 3 })
    {
        var view = engine.Perspective(new PerspectivePayload { ProjectId = projectId, Holder = who, At = new At { Tick = session } });
        sheets[who][session.ToString()] = view.Holdings.Select(item => item.Record is Record.Holding h ? Line(h.Claim) : "").ToList();
    }
}
Print("sheets", sheets);

Print("kira-vs-tavi-2", engine.Compare(new ComparePayload { ProjectId = projectId, A = "kira", B = "tavi", At = new At { Tick = 2 } }).Counts);
Print("kira-vs-tavi-3", engine.Compare(new ComparePayload { ProjectId = projectId, A = "kira", B = "tavi", At = new At { Tick = 3 } }).Counts);
Print("who-knows-the-duke", engine.WhoHolds(new WhoHoldsPayload { ProjectId = projectId, Claim = dukeMeetsCult, At = new At { Tick = 3 } }));

More recipes

Was this page helpful?