Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions content/story/story.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# Aris, her workshop and what the incident is made from. Read by server/simile/story/.

[aris]
name = "Aris"
workshop = "maker_space"
street = "high_street"
description = """\
Aris runs the maker space at the edge of the village. If it's broken, she can fix it: the pump \
that keeps the village's water running, the bakery's oven, half the phones in town. She's more \
machine than woman, and she built most of the machine herself: a steel hand, an eye that glows \
faintly in the dark, a voice that comes through a grille in her throat. Parents steer their \
children away from her door, and the village has an unspoken rule that what she does to herself \
in that workshop at night is best not asked about."""

# Where the player finds Aris's notes and the clue in her workshop: a cell on its ground floor.
[[clues]]
id = "aris_notes"
label = "Aris's notes"
map = "maker_space_floor_0"
x = 3
y = 2
pages = [
"Pump at the water tower: the bearing's going again. Ordered two. Tell the council it's the last time I bodge it.",
"Bakery oven, thermostat. Fixed. Ten minutes' work and they looked at me as if I'd raised the dead.",
"Hand: the third finger sticks when it's cold. Rebuild the tendon spring before winter.",
"The kid came by again and soldered a whole board without burning anything. Better than I was at that age.",
]

[[clues]]
id = "door_frame"
label = "The splintered door frame"
map = "maker_space_floor_0"
x = 13
y = 2

# Where attackers meet the evening before, when there's more than one.
[[meeting_places]]
id = "noodle_bar"
name = "the noodle bar"

[[meeting_places]]
id = "coffee_shop"
name = "the Daily Grind"

# What Aris did to an attacker, and the values that make it rankle (see server/simile/story/).
[grievances.repair]
values = { success = 1.0, security = 1.0, ambition = 0.5, self_respect = 0.5 }
told = "A repair Aris did for them failed when it mattered most, and it cost them."
grievance = "{name} blamed Aris for a repair of hers that failed when it mattered most."
resents = "I've never forgiven Aris for that repair of hers that failed me when it mattered most."
note = "{initial}. came back about the repair. Told them it wasn't my work that failed. They said I'd be sorry."

[grievances.bionics]
values = { tradition = 1.0, security = 1.0, obedience = 0.5, open_mindedness = -1.0, adventure = -0.5 }
told = "They think what Aris does to her own body is unnatural, and they fear for the children she lets hang around the workshop."
grievance = "{name} thought what Aris did to her own body was unnatural, and wanted the village's children kept away from her."
resents = "What Aris does to herself in that workshop isn't natural, and I won't have the village's children learning it."
note = "{initial}. at the door again, telling me to keep the children out. Said there are ways to make me."

[grievances.rules]
values = { obedience = 1.0, discipline = 1.0, security = 0.5, freedom = -0.5 }
told = "Aris flouts the rules: welding late at night, fumes in the lane, work nobody licensed. They tried to have the workshop shut down, and failed."
grievance = "{name} tried to have the maker space shut down for breaking the rules, and Aris saw the attempt off."
resents = "Aris breaks every rule this village has, and when I tried to stop her she made me look a fool."
note = "{initial}. waving the bylaws at me over the welding. Told them the council already said no. They didn't like that."

[grievances.money]
values = { success = 1.0, ambition = 1.0, honesty = -1.0, justice = -0.5 }
told = "Aris cost them money: she exposed a deal of theirs, or wouldn't sell them what they wanted."
grievance = "{name} blamed Aris for a deal that fell through because of her."
resents = "Aris cost me a deal I'd worked on for months, and she never even said sorry."
note = "{initial}. offering money again for what isn't for sale. Said no. They said everything has a price, one way or another."

[grievances.pride]
values = { self_respect = 1.0, peace_of_mind = -0.5, loyalty = -0.5 }
told = "Aris made a fool of them in front of the village, and they never forgave her."
grievance = "{name} never forgave Aris for making a fool of them in front of half the village."
resents = "Aris made a fool of me in front of half the village, and people still smirk about it."
note = "{initial}. still won't look at me since the square. Heard they've been saying things. Let them."

# What residents say or remember when nobody wrote it for them.
[texts]
remembers = "I went to the maker space the night Aris was attacked{with}, to make her back down. It got out of hand, and I've told nobody."
alibi = "I was at home all night."
deflection = "Terrible business. I don't know a thing about it."
denial = "Aris and I always got on fine."
alone = "{name} went to the maker space to have it out with Aris, and it went too far."
together = "They went to the maker space together to frighten her into leaving, and it went too far."
relieved = "Good riddance, if you ask me."

# What a witness who'd rather lie than keep quiet says instead, by what they saw.
[texts.covers]
shouting = "I was fast asleep that night. I didn't hear a thing."
seen = "I didn't see anything that night."
grudge = "I keep out of other people's business."
meeting = "I didn't notice who was in that night."
34 changes: 34 additions & 0 deletions server/simile/conversation/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,40 @@ tells the model what the lie is (`temptation.py`).
lent them money, knows the secret or holds the grudge). Nobody is set up to hide being short or a
debt in a scene set up for them to borrow or pay back.

## Telling

What residents know of S12's incident ([`simile.story`](../story/README.md)) comes up only with the
player, and only as their trust allows (`telling.py`). `engine.incident`, which the lifespan points
at the saved investigation, is the incident.

- **Asked.** Once the player's lines since the resident last spoke mention Aris or the attack (Aris,
the mechanic, her workshop or steel hand, what happened to her or at the workshop, who smashed up
the workshop, the break-in, the attack, ...: `CUES[Kind.FACT]`; "a heart attack", everyday
talk of the maker space, "that night" or "where were you" alone don't count), the resident is set
up, for the rest of the conversation, for each fact they know
(`engine.tellings(npc, PLAYER_ID)`), by R1's trust in the player then:
- from the knower's `tells_from`, they **tell** it: "You remember this, and trust Visitor enough to
tell them if they ask:" and their first memory of it, so a lie they told since never takes its
place;
- below it, with a `cover`, they **lie**: it's one of the lies above, of kind `fact`, with no
roll, since trust has already decided it. Its direction ties the cover to the one question it
answers, without the truth: 'You won't say where you'd been the night Aris was attacked. Only
if asked that, say exactly: "I was at home all night."' After the lies, once, they're told never
to volunteer a cover story or add a denial of their own;
- below it, without a cover, they **keep it back**: one paragraph, however many facts, says they'd
rather not talk about it, without denying that they know something.

Their memory of a fact they lie about or keep back isn't in the prompt, so it can't slip out.
- **Elsewhere.** The incident's memories are out of R2's `recall`, so they're not on anyone's mind
in a scene, a reply or a work until the player asks. After that, B3 remembers the conversation
like any other: a resident who told the player something remembers telling it, which `recall`
returns. A lie's direction holds no truth, and both the liar's memory of lying and their summary
of that conversation carry the fact's id (`Lie.fact_id`), so `recall` leaves them out. Residents
don't tell each other what they know.
- **For other modules.** `PlayerTalk.tellings` has what the resident was set up to say about each
fact, by its id, with the `Stance` (`tell`, `lie` or `keep`). Each lie is also in `talk.lies`, with
`Temptation.fact_id`, and in `engine.lies` for B3.

## Only promising what's possible

B3 turns what a resident says they'll do into an intention that B1 acts on (see *Intentions* in
Expand Down
11 changes: 11 additions & 0 deletions server/simile/conversation/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
scene_prompt,
script_system,
)
from simile.conversation.telling import Telling, tellings
from simile.conversation.temptation import (
Kind,
Lie,
Expand All @@ -60,6 +61,7 @@
CoreValue,
FlagKind,
Id,
Incident,
Line,
Memory,
MemoryKind,
Expand Down Expand Up @@ -114,6 +116,8 @@ def recall(

def shared_history(self, a: str, b: str, now: datetime, *, k: int = 10) -> SharedHistory: ...

def facts(self, owner: str) -> list[Memory]: ...


class Audio(Protocol):
"""I1's view of the audio it makes for each line."""
Expand Down Expand Up @@ -230,6 +234,8 @@ def __init__(
"""A work by id, for the works residents might bring up."""
self.missed: Callable[[Id], list[Missed]] = lambda _: []
"""E3's shifts a resident skipped or was late to."""
self.incident: Callable[[], Incident | None] = lambda: None
"""S12's incident, for what residents know of it."""
self._talks: dict[str, Talk] = {}
self._loans: dict[str, Loan] = {}
self._lies: dict[str, list[Lie]] = {}
Expand Down Expand Up @@ -404,6 +410,11 @@ def tell(self, conversation_id: str, temptation: Temptation, listener: Id) -> Li
self._lies.setdefault(conversation_id, []).append(lying)
return lying

def tellings(self, npc: Id, listener: Id) -> list[Telling]:
"""What `npc` would say of each incident fact they know, by their trust in `listener`."""
trust = self._relationship(npc, listener).trust
return tellings(self.incident(), npc, trust, self.memory.facts(npc))

def tick(self, dt: float) -> None:
for talk in tuple(self._talks.values()):
if isinstance(talk, Scene):
Expand Down
2 changes: 2 additions & 0 deletions server/simile/conversation/lifespan.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ async def lifespan(app: Starlette) -> AsyncIterator[None]:
from simile.conversation.player import PlayerConversations
from simile.conversation.temptation import Missed, missed_shifts
from simile.news import news_of
from simile.persistence import investigation_of
from simile.protocol import PlayerSayMessage, hub_of
from simile.schema import Id, NewsItem, Work
from simile.sim import world_of
Expand All @@ -36,6 +37,7 @@ def missed(npc: Id) -> list[Missed]:
players = PlayerConversations(engine, connected=lambda: bool(hub.connections), article=article)
engine.player_request, engine.article, engine.work = players.request, article, work
engine.missed = missed
engine.incident = lambda: investigation_of(app).incident
app.state.players = players
stops = [
behaviour_of(app).hooks.set(request_conversation=engine.request, conversations=engine.live),
Expand Down
23 changes: 22 additions & 1 deletion server/simile/conversation/player.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
system_prompt,
turn_prompt,
)
from simile.conversation.telling import UNASKED, Stance, Telling, asked, told
from simile.conversation.temptation import Temptation, raised, touched
from simile.llm import Model
from simile.news.interest import shared, words
Expand Down Expand Up @@ -195,6 +196,8 @@ class PlayerTalk(Talk):
"""The conversation the NPC was pulled out of, whose end their lines wait for."""
lies: dict[str, Temptation] = field(default_factory=dict)
"""What the NPC has been set up to lie about, by key."""
tellings: dict[Id, Telling] = field(default_factory=dict)
"""What the NPC was set up to say of each incident fact they know, by fact id."""
joked: bool = False
"""Whether the NPC's last reply was told to joke."""

Expand Down Expand Up @@ -377,6 +380,7 @@ async def _reply(self, talk: PlayerTalk, cue: Cue, lines: Sequence[Line]) -> Dra
pressed = engine.pressing(npc)
recalled = self._recalled(npc, lines)
self._tempt(talk, lines)
self._ask(talk, lines)
seed = f"{talk.conversation.id}/{npc}/{len(lines)}"
joke = jokes(party.npc.humour, seed, after_joke=talk.joked)
talk.joked = bool(joke)
Expand All @@ -393,7 +397,11 @@ async def _reply(self, talk: PlayerTalk, cue: Cue, lines: Sequence[Line]) -> Dra
mood=pressed.mood,
errand=pressed.errand,
detail=detailed(recalled, party.topics),
lies=[temptation.direction() for temptation in talk.lies.values()],
facts=told(talk.tellings.values(), engine.player.name),
lies=[
*(temptation.direction() for temptation in talk.lies.values()),
*([UNASKED] if any(t.stance is Stance.LIE for t in talk.tellings.values()) else []),
],
joke=joke,
)
stream = engine.llm().stream(
Expand Down Expand Up @@ -435,6 +443,19 @@ def _tempt(self, talk: PlayerTalk, lines: Sequence[Line]) -> None:
talk.lies[temptation.key] = temptation
engine.tell(talk.conversation.id, temptation, PLAYER_ID)

def _ask(self, talk: PlayerTalk, lines: Sequence[Line]) -> None:
"""Once the player touches the incident, sets the NPC up for each fact they know."""
engine, npc = self.engine, talk.npc
said = since(npc, lines)
if talk.tellings or not said or not asked(said):
return
for telling in engine.tellings(npc, PLAYER_ID):
talk.tellings[telling.fact.id] = telling
if telling.stance is Stance.LIE:
temptation = telling.temptation()
talk.lies[temptation.key] = temptation
engine.tell(talk.conversation.id, temptation, PLAYER_ID)

def _recalled(self, npc: Id, lines: Sequence[Line]) -> list[Memory]:
"""What `npc` read about the articles the player has mentioned since `npc` last spoke."""
said = since(npc, lines)
Expand Down
7 changes: 3 additions & 4 deletions server/simile/conversation/prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -286,12 +286,11 @@ def turn_prompt(
mood: Sequence[str] = (),
errand: str | None = None,
detail: Memory | None = None,
facts: Sequence[str] = (),
lies: Sequence[str] = (),
joke: bool | None = None,
) -> str:
"""The speaker's next line, feeling `mood`, with what stuck with them from the article
`detail` is of and the `lies` they've been directed to tell, and with a `joke` or without
when it's set. A goodbye says they're off to do `errand`."""
"""The speaker's next line, with their mood, article `detail`, `facts`, `lies` and `joke`."""
recent = lines[-config.transcript_lines :]
if recent:
transcript = "\n".join(f"{names[line.speaker]}: {line.text}" for line in recent)
Expand All @@ -301,7 +300,7 @@ def turn_prompt(
else:
so_far = "The conversation hasn't started yet."
parts = [background, feels("you", mood)] if mood else [background]
parts += [*lies, *your_works(works, speaker), so_far]
parts += [*facts, *lies, *your_works(works, speaker), so_far]
if recalled:
parts.append(
f"You read about what {other} just mentioned:\n"
Expand Down
97 changes: 97 additions & 0 deletions server/simile/conversation/telling.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import re
from collections.abc import Iterable, Sequence
from dataclasses import dataclass
from enum import StrEnum

from simile.conversation.temptation import CUES, Kind, Temptation
from simile.schema import Fact, Id, Incident, Knower, Memory
from simile.story.incident import question

SUBJECT = "what happened to Aris"
TELL = 'You remember this, and trust {other} enough to tell them if they ask: "{remembers}"'
KEEP = (
"You know more about the night Aris was attacked than you let on, but you don't trust "
"{other} enough to say what. If it comes up, say you'd rather not talk about it, without "
"denying that you know something."
)
HIDES = "{Who} won't say {question}."
COVER = 'Only if asked that, {say} exactly: "{cover}"'
UNASKED = (
"Give each of those cover stories only when asked that very question. Never bring one up "
"yourself, and add no other denial about Aris or that night."
)


class Stance(StrEnum):
"""How a resident answers about a fact they know: tell it, lie, or keep it back."""

TELL = "tell"
LIE = "lie"
KEEP = "keep"


def stance(knower: Knower, trust: float) -> Stance:
"""Tell it from `tells_from` trust, and below it lie with a cover or else keep quiet."""
if knower.tells_from is not None and trust >= knower.tells_from:
return Stance.TELL
return Stance.LIE if knower.cover is not None else Stance.KEEP


@dataclass(frozen=True)
class Telling:
"""What a resident is set up to say about one of the incident's facts."""

fact: Fact
knower: Knower
stance: Stance
remembers: str
"""Their memory of it, or what happened if they have none."""

def temptation(self) -> Temptation:
"""The cover story as one of #285's lies, tied to its question, without the truth."""
asked = question(self.fact.id, self.knower.how)
return Temptation(
Kind.FACT,
self.knower.id,
f"fact/{self.fact.id}",
HIDES.replace("{question}", asked),
COVER.replace("{cover}", self.knower.cover or ""),
SUBJECT,
involves=frozenset(self.fact.about),
known_by=frozenset(k.id for k in self.fact.known_by if k.id != self.knower.id),
fact_id=self.fact.id,
)


def told(tellings: Iterable[Telling], other: str) -> list[str]:
"""The directions for what they'll tell `other`, and for what they keep back, once."""
tellings = list(tellings)
directions = [
TELL.format(other=other, remembers=t.remembers) for t in tellings if t.stance is Stance.TELL
]
if any(t.stance is Stance.KEEP for t in tellings):
directions.append(KEEP.format(other=other))
return directions


def asked(said: str) -> bool:
"""Whether `said` touches the incident."""
return re.search(rf"\b(?:{CUES[Kind.FACT]})\b", said, re.IGNORECASE) is not None


def tellings(
incident: Incident | None, npc: Id, trust: float, memories: Sequence[Memory] = ()
) -> list[Telling]:
"""What `npc` would say of each fact they know, in their first memory of it, at `trust`."""
if incident is None:
return []
remembered: dict[Id, str] = {}
for memory in sorted(memories, key=lambda memory: memory.timestamp):
if memory.fact_id:
remembered.setdefault(memory.fact_id, memory.content)
return [
Telling(fact, knower, stance(knower, trust), remembered.get(fact.id, fact.what))
for fact in incident.facts
for knower in fact.known_by
if knower.id == npc
]
Loading
Loading