Skip to content

S13: The journal - #378

Merged
jamescrosswell merged 6 commits into
mainfrom
task/360-journal
Oct 22, 2026
Merged

jamescrosswell merged 6 commits into
mainfrom
task/360-journal

Conversation

@jamescrosswell

@jamescrosswell jamescrosswell commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

The journal: the server writes what reaches the player about Aris into S2's Investigation.journal, sends it as journal on connect and on every change, and J opens it in the client.

What makes an entry (server/simile/story/journal.py, recording.py, started by the story module's new running hook)

  • Told. Once a resident is set up for what they know (S12's PlayerTalk.tellings), their next line spoken to the player writes each fact they TELL: its what, with its leads. TELL goes by the stance alone. A check on the words would miss a paraphrase, and would never pass on the fake LLM that S15 runs on.
  • Lied to. A fact they LIE about is written as their cover, in quotes, with no leads, once their lines in the conversation, joined, hold that cover word for word. says (in simile.story, saying.py) ignores case, curly or straight quotes and apostrophes, punctuation and spacing. The lines are joined because a two-sentence cover arrives as two lines. A cover they were set up with but never gave isn't written. That includes S11's refusals, which set up only lies. KEEP writes nothing.
  • Found. A pick_up (E) on a clue's cell (load_story().clues, clue_reach, half a tile) writes each fact whose found_in is that clue.
  • One thing per press. The clue is a claim on pick_up (claimers_of in simile.player.pickups). The server takes the nearer of a clue and a pile, never both, and the pile on a tie. The client orders them the same way, and the server's clue reach is the client's, so the hint and the press agree.
  • Flushed. From the stance and the player's Body: with the infrared eye a lie is true and the truth false. Without it, and always for a clue, it's null.
  • Leads. A new entry gets its fact's leads, minus any to facts already in the journal, and closes every lead to its own fact, a lie included.
  • Once. A truth already in the journal isn't written again, whoever tells it. A lie isn't written again by the same resident, unless the eye now catches a flush it missed. Then the lie moves to the end, flushed.

Client

  • JournalPanel (client/src/ui/journal-panel.ts) opens and closes with J, or closes with Esc. It shows the open leads first, then what was learned, newest first. Each entry has who said it or the clue it came from, the sim time, and a red flushed mark where the eye caught one.
  • A "J journal" hint in the top-right corner counts what's new since the panel was last open. It sits at top: 40px, below S4's lamp gauge (top: 16px) and its line (top: 28px). The journal and the controls close each other.
  • On a clue's cell, a hint says "Press E to look at Aris's notes.", and E sends pick_up and opens the journal in place of the controls (lookAtClue), as J does. The client offers it only on the cell itself, so the maker space's door, beside the door frame, keeps E. The side walk presses E at every door, and all its legs pass locally.
  • J is in the help panel and in the README's controls, and E's description now mentions clues.

Please look at

The open journal in the real client, against a copy of server/data/simile.db. I found the notes and asked Desmond before fitting the eye, then asked Marguerite with the eye and her trust lowered so she'd lie, and found the door frame:

the open journal

I took it before the review's change, while a lie was still written from the stance: on the canned fake LLM, Marguerite never actually says her cover. The panel itself hasn't changed since. The preview is on the orphan branch previews/360-journal. Delete it after review.

Notes for review

  • No hook in telling.py. conversation/ is unchanged. The recorder hears LineSpoken and reads the player's talk.tellings, which the conversation README already offers to other modules.
  • Lies follow the words; truths follow the stance. S8 can call says(line_or_lines, cover) when it sets line.flushed, so the face flush and the journal's mark agree. The fake replies in S15's playthrough need to say an attacker's cover word for word to catch the lie.
  • E at a clue reuses pick_up. No message changed. Follow-up: docs/protocol.md's pick_up section should say it finds a clue too. That's left for S8's contract, because S7's contract PR is changing that file.
  • Outside the story paths:
    • client/src/world/reach.ts gains a clue reachable, and there's a new client/src/world/clues.ts. E has to see the clue, or the side scene takes the workshop's door beside the door frame.
    • server/simile/player/ (S6's, now merged): pickups.py gains Claim and claimers_of, and pick_up(others) arbitrates between a claim and the nearest pile. lifespan.py passes the claimers in.
    • The client gains a dependency, smol-toml, to read the clue cells from content/story/story.toml.
    • server/tests/protocol/test_lifespan.py expects the new journal greeting after player_state.
    • origin/main is merged in: S6 (S6: Salvage, and selling it #373), S10 (S10: The player, visibly bionic #374), S11 (S11: The village reacts #376), S4a (S4a: Contract: the lamp switch #377) and S4 (S4: Darkness and the lamp #379). Both sides of reach.ts, dialogue-scene.ts, help.ts, player/lifespan.py and the READMEs are kept, with S6's reachables.piles.
    • The lamp gauge is created before the journal panel, so an open journal covers it. In the real client at 1280×720, the journal hint (CSS px 80–101) sits clear of the controls hint (8–29), the gauge (32–57) and the gauge's longest line (56–77).
  • Checks:
    • Residents' days are unchanged: simile behaviour simulate --hours 24 --seed 0 matches main except for the wall-clock seconds.
    • A copy of server/data/simile.db, saved before the player and the investigation existed, loads. It makes its incident from templates (SIMILE_LLM=fake), and keeps the journal across two restarts.

Closes #360

🤖 Generated with Claude Code

The server writes what reaches the player into S2's journal: a fact a resident tells
them, a lie as its cover with no leads, and a clue picked up at its cell with E. With
the infrared eye a lie is flushed and the truth isn't. Leads open and close by S2's
rules, and the journal is sent on connect and on each change. J opens the client's
journal panel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The side walk presses E at every door, and the door frame's clue is the
cell beside the maker space's door. The server takes a pick_up within a
tile of a clue, past the half tile the client offers it from.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
E now takes one thing per press on both sides. The clue is a claim on
pick_up (claimers_of), so the server takes the nearer of a clue and a pile,
the pile on a tie, never both; the client orders them the same way. The
server's clue reach is the client's half tile, so they agree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jamescrosswell

Copy link
Copy Markdown
Contributor Author

Changes requested. Two items. Everything else holds up.

Changes

  1. Write a lie only once it's said. Once S12 sets an attacker up, their next line writes every cover they'd use, flushed if the player has the eye. That happens even though UNASKED tells them to give each cover only to its own question. test_an_attacker_lies_and_the_eye_sees_them_flush shows it: a fake "Hmm." writes both Ben's alibi and his deflection. With main's S11 merged, a refusal does the same.
    • S12 has covers said word for word. So in Recorder.on_spoken, write a LIE entry only once the resident's lines in this conversation, joined, contain the cover.
    • Normalise both sides first: case, curly quotes and apostrophes, punctuation and whitespace.
    • Join the lines because Sentences splits a two-sentence cover, like Marguerite's in the screenshot, into two LineSpokens.
    • Put the matcher where S8 can reuse it for line.flushed.
    • Tests:
      • Ben asked where he was, with the fake replying with the alibi, writes there_ben and not attack.
      • A two-sentence cover is written once, on its second line.
    • Update the story README's Told bullet and the PR's note.
    • TELL can stay on the stance for now, but the README should say so. If you'd rather gate it, the cheap route is words/shared from simile.news.interest against telling.remembers, using only words their other tellings don't share.
  2. E at a clue should close the controls. use() opens the journal but leaves the help panel open, so the two stack in the top-right corner. Close help there too, as J does, which means keeping help on the scene.

Checked

  • Scope: each change outside Owns is small and needed: reach.ts (+8), clues.ts, Claim/claimers_of plus one line in player/lifespan.py, one test line, and smol-toml.
  • Claim/claimers_of: small and sound. S7's E uses its own sell_part/fit messages (S7a: Contract for the clinic #381) and never touches pick_up, so it fits.
  • E:
    • One press takes the nearer of a clue and a pile, the pile on a tie, and never both, on the server and the client alike.
    • The reach is 0.5 on both sides.
    • The door-frame cell leaves the door on E.
  • Rules: the duplicate and lead rules match the S2 contract. The Done-when tests are all there.
  • Panel: J, Esc, the new count, the controls/journal swap, the help panel and the README all work. The screenshot reads well.
  • The day: simile behaviour simulate --hours 24 --seed 0 matches main exactly.
  • Old saves: a copy of a pre-player save, on the fake LLM, makes its incident, finds the notes, and keeps the journal across two restarts.
  • Main: the PR merges cleanly with main (S11, S4a). On the merged tree, the story, player, protocol, persistence, conversation and social tests pass, and so do the client tests and tsc.
  • Code:
    • No Claude API, anthropic SDK or API key.
    • Heavy imports stay inside the hooks.
    • Every comment and docstring is one line.
    • Nothing personal is committed.

Notes (not blockers)

  • S4: Darkness and the lamp #379 (S4):
    • The conflicts are trivial: each side adds a line to the README and help controls, the dialogue-scene.ts fields and the player/lifespan.py imports.
    • Its .lamp-gauge sits at right: 4px; top: 16px, the same spot as .journal-hint. Whichever PR lands second should move its element below the other.
  • Protocol: docs/protocol.md's pick_up section doesn't mention clues yet. That's a one-line contract follow-up.
  • S9: it has to register the sewers' clue. That clue is generated, so neither load_story().clues nor the client's build-time read of story.toml will have it. The server will need to send clue cells, which would also let the client drop smol-toml and stop bundling story.toml's grievance templates.

jamescrosswell and others added 3 commits October 23, 2026 07:48
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A LIE entry waits until the resident's lines in the conversation, joined,
hold the cover word for word (simile.story.says, which ignores case, quotes,
apostrophes, punctuation and spacing), so an alibi-only reply writes only the
alibi and a refusal only the cover it gave. TELL stays on the stance. E at a
clue opens the journal in place of the controls, as J does, and the journal
hint moves below where S4's lamp gauge and its line go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both sides kept in the README's controls, help.ts, dialogue-scene.ts and player/lifespan.py. The lamp gauge is made before the journal panel, so an open journal covers it, and the journal hint sits below the gauge and its line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jamescrosswell

Copy link
Copy Markdown
Contributor Author

Approved. I re-reviewed the diff since d718386. Both requested changes are in.

  • Lies once said: Recorder.on_spoken joins only the speaking resident's own lines (each.speaker == line.speaker), so a cover can't match across two speakers. says pads with spaces, so it matches whole words only. It returns false on an empty cover.
  • Tests: they cover an alibi-only reply, a two-sentence cover with a curly apostrophe, a refusal, and the says table. TELL staying on the stance is documented.
  • E at a clue: lookAtClue closes help and opens the journal, with a test.
  • S4 merge: all four conflicts keep both sides: J and L in the README and help.ts, the lamp gauge before the journal panel in dialogue-scene.ts, and both Lamp and claimers_of in player/lifespan.py. The hint now sits at top: 40px, clear of the gauge. The PR also merges cleanly with main's newer S14.
  • Comments: every added docstring and comment is one line.
  • Tests run: tests/story, tests/player and tests/protocol pass (1602), and so do the client's src/ui and src/world (241).

@jamescrosswell
jamescrosswell marked this pull request as ready for review October 22, 2026 19:37
@jamescrosswell
jamescrosswell merged commit fdfe102 into main Oct 22, 2026
7 checks passed
@jamescrosswell
jamescrosswell deleted the task/360-journal branch October 22, 2026 19:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

S13: The journal

1 participant