diff --git a/content/story/story.toml b/content/story/story.toml new file mode 100644 index 00000000..75728a77 --- /dev/null +++ b/content/story/story.toml @@ -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." diff --git a/server/simile/conversation/README.md b/server/simile/conversation/README.md index ef2f66ab..1eeeb697 100644 --- a/server/simile/conversation/README.md +++ b/server/simile/conversation/README.md @@ -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 diff --git a/server/simile/conversation/engine.py b/server/simile/conversation/engine.py index fa57bdb7..b999383c 100644 --- a/server/simile/conversation/engine.py +++ b/server/simile/conversation/engine.py @@ -38,6 +38,7 @@ scene_prompt, script_system, ) +from simile.conversation.telling import Telling, tellings from simile.conversation.temptation import ( Kind, Lie, @@ -60,6 +61,7 @@ CoreValue, FlagKind, Id, + Incident, Line, Memory, MemoryKind, @@ -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.""" @@ -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]] = {} @@ -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): diff --git a/server/simile/conversation/lifespan.py b/server/simile/conversation/lifespan.py index 59d15014..96852e70 100644 --- a/server/simile/conversation/lifespan.py +++ b/server/simile/conversation/lifespan.py @@ -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 @@ -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), diff --git a/server/simile/conversation/player.py b/server/simile/conversation/player.py index 5c27f201..5d2d3786 100644 --- a/server/simile/conversation/player.py +++ b/server/simile/conversation/player.py @@ -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 @@ -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.""" @@ -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) @@ -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( @@ -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) diff --git a/server/simile/conversation/prompt.py b/server/simile/conversation/prompt.py index 11e6bf79..44c5ddcb 100644 --- a/server/simile/conversation/prompt.py +++ b/server/simile/conversation/prompt.py @@ -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) @@ -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" diff --git a/server/simile/conversation/telling.py b/server/simile/conversation/telling.py new file mode 100644 index 00000000..05d41efd --- /dev/null +++ b/server/simile/conversation/telling.py @@ -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 + ] diff --git a/server/simile/conversation/temptation.py b/server/simile/conversation/temptation.py index c7c94026..1d15ab83 100644 --- a/server/simile/conversation/temptation.py +++ b/server/simile/conversation/temptation.py @@ -42,6 +42,7 @@ class Kind(StrEnum): WORK = "work" COVER = "cover" COMPLAINT = "complaint" + FACT = "fact" CUES: dict[Kind, str] = { @@ -59,6 +60,11 @@ class Kind(StrEnum): Kind.COVER: r"cover for me|back me up|alibi|(?:say|tell \w+) (?:that )?I was", Kind.COMPLAINT: r"blame|fault|fell out|falling out|grudge|what you did|complain\w*|fed up|" r"sick of|in the way|blocking|for days|since \w+day", + Kind.FACT: r"aris|the mechanic|steel hand|(?:her|aris'?s?) workshop|what happened to her|" + r"what happened (?:at|in) the (?:workshop|maker ?space)|" + r"(?:smash\w*|wreck\w*|trash\w*) (?:up )?the (?:workshop|maker ?space)|" + r"broke into the (?:workshop|maker ?space)|the break-?in|the attack|(?:attacked|hurt) her|" + r"she was attacked|(?:she|aris) (?:disappeared|vanished|went missing)", } """Words that touch each kind, besides the words of what it's about.""" @@ -139,6 +145,8 @@ class Temptation: known_by: frozenset[Id] = frozenset() """Whose own memories already hold the truth.""" admits: str = ADMITS + fact_id: Id | None = None + """The incident's fact it hides, for one about the incident.""" def direction(self, name: str | None = None) -> str: """Told to them, or with `name`, about them.""" @@ -158,6 +166,8 @@ class Lie: """The liar's memory of telling it.""" known: bool """Whether the listener's own memories already hold the truth.""" + fact_id: Id | None = None + """The incident's fact it hides, for one about the incident.""" class Store(Protocol): @@ -422,6 +432,7 @@ def lie(temptation: Temptation, listener: Id, names: Mapping[Id, str]) -> Lie: temptation.subject, blanks(temptation.admits, told), listener in temptation.known_by, + temptation.fact_id, ) diff --git a/server/simile/memory/store.py b/server/simile/memory/store.py index 47f7a093..323c3dc2 100644 --- a/server/simile/memory/store.py +++ b/server/simile/memory/store.py @@ -60,6 +60,8 @@ type Record = InteractionEvent | Memory | Flag | Relationship +FACT = "json_extract(data, '$.fact_id')" + def default_path() -> Path: """`$SIMILE_DB`, or `server/data/simile.db`.""" @@ -200,8 +202,8 @@ def recall( k: int = 5, half_life: timedelta = HALF_LIFE, ) -> list[Memory]: - """`owner`'s top `k` memories by salience × recency, best first.""" - query = "SELECT data FROM memories WHERE owner = :owner" + """`owner`'s top `k` memories by salience × recency, best first, without `facts`.""" + query = f"SELECT data FROM memories WHERE owner = :owner AND {FACT} IS NULL" if kind is not None: query += " AND kind = :kind" if about is not None: @@ -223,6 +225,20 @@ def recall( ) return [Memory.model_validate_json(data) for (data,) in rows] + def facts(self, owner: str) -> list[Memory]: + """`owner`'s memories of the incident's facts, oldest first.""" + rows = self._fetch( + f"SELECT data FROM memories WHERE owner = :owner AND {FACT} IS NOT NULL" + " ORDER BY time, id", + {"owner": owner}, + ) + return [Memory.model_validate_json(data) for (data,) in rows] + + def forget_facts(self) -> int: + """Forget everyone's memories of the incident's facts, and return how many there were.""" + with self._lock, self._db: + return self._db.execute(f"DELETE FROM memories WHERE {FACT} IS NOT NULL").rowcount + def shared_history( self, a: str, b: str, now: datetime, *, k: int = 10, half_life: timedelta = HALF_LIFE ) -> SharedHistory: diff --git a/server/simile/reflection/outcome.py b/server/simile/reflection/outcome.py index 1ed604cc..41bde1a2 100644 --- a/server/simile/reflection/outcome.py +++ b/server/simile/reflection/outcome.py @@ -188,6 +188,7 @@ def _remembered(lie: Lie, context: Context, config: ReflectionConfig) -> list[Me about=[lie.listener], topics=["lie"], conversation_id=context.conversation.id, + fact_id=lie.fact_id, ) ) if lie.known and lie.listener in context.npcs: @@ -202,6 +203,7 @@ def _remembered(lie: Lie, context: Context, config: ReflectionConfig) -> list[Me about=[lie.liar], topics=["lied to me"], conversation_id=context.conversation.id, + fact_id=lie.fact_id, ) ) return memories @@ -271,4 +273,12 @@ def _memory(npc: Id, reflection: Reflection, context: Context, config: Reflectio about=context.others(npc), topics=list(dict.fromkeys(topics))[: config.topics], conversation_id=context.conversation.id, + fact_id=_hidden(npc, context), + ) + + +def _hidden(npc: Id, context: Context) -> Id | None: + """The incident fact `npc` was set up to lie about here, so their summary stays with it.""" + return next( + (lie.fact_id for lie in context.lies.values() if lie.liar == npc and lie.fact_id), None ) diff --git a/server/simile/story/README.md b/server/simile/story/README.md new file mode 100644 index 00000000..ece3c27b --- /dev/null +++ b/server/simile/story/README.md @@ -0,0 +1,106 @@ +# simile.story + +S12: Aris, who is missing, and the night she was attacked. Each world makes its own incident once, +when it's created: who attacked her and why, who saw what, and who knows it. The facts live in the +sim, in S2's `Incident` (`investigation_of(app).incident`), and residents only voice them. B5's +conversations with the player tell, lie about or keep back what each resident knows (see +[*Telling*](../conversation/README.md#telling) in the conversation README). + +## Aris and her workshop + +[`content/story/story.toml`](../../../content/story/story.toml) has Aris, for the motive's writer, +and her workshop, the maker space. Its two clues are cells on the workshop's ground floor, so the +map stays as it is: `aris_notes`, her notebook on the workbench, with a few pages of her own besides +the one that matters, and `door_frame`, where the attackers left a scrap of cloth. A fact's +`found_in` is one of them, and `load_story().clue(id)` says where it is and what the journal calls +it. + +## The attackers + +Each grievance in the content is something Aris did that someone can't forgive (a repair that +failed them, what she does to her own body, the rules she breaks, a deal she cost them, a public +humiliation), with the core values that make it rankle. A resident's fit to one is the sum of each +weight times how far that value is from 0.5, over the sum of the weights, from -0.5 to 0.5. Their +hostility is their best fit plus `harm` times how far their Compassion is below 0.5. + +- Everyone at `hostile_from` (0.2) or more is a suspect. With today's residents that's Henrietta, + Raymond, Marguerite and Priya. +- One, two or three of them attack her (`attackers`: 40%, 35%, 25%, while there are enough), drawn + by how far each is past the bar. If nobody reaches it, the most hostile attacks alone. +- Each attacker's grievance is drawn from those within `grievance_spread` (0.15) of their best. + +## The motive + +One `structured` call with Sonnet (`purpose="story-motive"`) writes the backstory from the +attackers' names, jobs, personas, values and grievances, and where several of them met the evening +before (one of the content's `meeting_places`): the motive, and for each attacker what Aris +did to them in the third person and in their own words, their memory of the night, the alibi, +deflection and denial they give instead of the truth, and the line Aris wrote about them, naming +them by their initial. Every alibi, deflection and denial stays vague and points at nobody, neither +a resident nor a stranger, since #283's bounds rule out making up facts about people. A reply that +doesn't validate gets one retry, as L1 does. A call that fails, +times out after `write_for` (180 s) or runs on the fake LLM leaves it to the content's templates, so +a world always has an incident. + +## The facts + +Placed a week before the world starts (`night_before`, 23:00 to 23:20), with Aris's workshop found +open the day before it starts (`gone_before`): + +| Fact | Who knows it | Leads to | +|---|---|---| +| `gone`: she was attacked, wouldn't say who, and a week later vanished | everyone, told to anyone | `notes`, `shouting` | +| `notes`: what she wrote about one attacker (each, if nobody else lives there), by their initial | found in `aris_notes` | that attacker's `grudge_`, `shouting` | +| `cloth`: a scrap of one attacker's top colour on the door frame | found in `door_frame` | `seen`, or with nobody to see, their `there_` | +| `shouting`: heard from the workshop that night | a witness | `seen` | +| `seen`: someone who looked like that attacker, and any others, leaving the lane | a witness | that attacker's `there_`, `meeting` | +| `meeting`: the attackers together the evening before, with two or more | a witness | each `there_` | +| `grudge_`: the attacker rowed with Aris, and why | the attacker, and a witness | their `there_` | +| `there_`: the attacker was in the lane, not where they say | the attacker | | +| `attack`: who did it | the attackers | | + +Every lead goes from what the player can find or ask about towards an attacker, so each attacker's +`there_` can be reached from the notes, the cloth or `gone`. Witnesses are residents other than the +attackers, each once before anyone twice. + +How willing each knower is: + +- **A witness** tells anyone they trust at least 0.5 plus 0.3 × Security, 0.2 × Peace of Mind, + -0.3 × Justice and -0.2 × Honesty, each from 0.5 (`tells_from`), up to `most_wary` (0.9). Below + that they keep quiet, or, below `witness_lies_below` (0.4) Honesty, lie with a cover story like + "I didn't see anything that night". +- **An attacker** lies about `attack` and their `there_` with their deflection and alibi, and owns + up only to someone they trust `owns_up_to` (0.95), and only if their Honesty is at least + `owns_up_from` (0.6). They own up to their grudge from `admits_grudge_from` (0.75) trust, and + deny it below. +- **Everyone** tells `gone` to anyone. + +Each knower gets one `observation` memory of each fact they know, in their own words, with its +`fact_id`, dated when it happened, with `salience` and `valence` by how they know it. A resident +hostile enough to be a suspect remembers her going as good riddance. R2's `recall` leaves these +out, so nobody brings the incident up in a scene or makes a work about it until the player asks: +only `MemoryStore.facts` returns them, for telling the player. Once a resident has told the player +something, B3's memory of that conversation holds it (see +[*Telling*](../conversation/README.md#telling)). + +## When it's made + +The lifespan makes it on startup when the world has no incident: a new world, a fresh start, every +`SIMILE_DB=:memory:` run, and once for a save from before it. A world with no residents gets none. +A cast world (P9) draws it from the cast's seed, and any other world at random. It runs after +persistence has restored the save. First it forgets any memories with a `fact_id`, which can only +be left from a start that stored them and then died before saving the incident. Then it stores +the new memories, and only then sets the incident and saves the world, so the incident is made at +most once and its memories are never lost. The log says only that it was made, not who did it. + +A save from before the incident gets one rather than none, since without it an old village has no +story short of `simile reset`. It's dated a week before the save's own time, which can fall within +what the village has already lived through, but its memories stay out of `recall`, so nobody brings +it up until the player asks. + +```bash +cd server +uv run simile story incident --seed 3 --facts # one Sonnet call, saves nothing +``` + +Every constant is in `StoryConfig`. diff --git a/server/simile/story/__init__.py b/server/simile/story/__init__.py new file mode 100644 index 00000000..56dff336 --- /dev/null +++ b/server/simile/story/__init__.py @@ -0,0 +1,15 @@ +from simile.story.config import DEFAULT_CONFIG, StoryConfig +from simile.story.content import DOOR, NOTES, Clue, StoryContent, load_story +from simile.story.incident import Made, make_incident + +__all__ = [ + "DEFAULT_CONFIG", + "DOOR", + "NOTES", + "Clue", + "Made", + "StoryConfig", + "StoryContent", + "load_story", + "make_incident", +] diff --git a/server/simile/story/begin.py b/server/simile/story/begin.py new file mode 100644 index 00000000..a73c4d14 --- /dev/null +++ b/server/simile/story/begin.py @@ -0,0 +1,41 @@ +import logging +import random + +from starlette.applications import Starlette + +from simile.behaviour import behaviour_of +from simile.casting.store import load_cast +from simile.memory import memory_of +from simile.persistence import investigation_of +from simile.schema import Incident +from simile.sim import world_of +from simile.story.config import DEFAULT_CONFIG, StoryConfig +from simile.story.incident import make_incident + +logger = logging.getLogger(__name__) + + +async def begin( + app: Starlette, *, config: StoryConfig = DEFAULT_CONFIG, rng: random.Random | None = None +) -> Incident | None: + """Make the world's incident if it has none, storing its memories before saving it.""" + investigation = investigation_of(app) + if investigation.incident is not None: + return None + clock, memory = world_of(app).clock, memory_of(app) + if left := memory.forget_facts(): + logger.warning("%s Forgot %d memories of an incident that was never saved", clock, left) + npcs = list(behaviour_of(app).npcs.values()) + if not npcs: + return None + if rng is None: + cast = load_cast() + rng = random.Random(f"story/{cast.seed}") if cast is not None else random.Random() + llm = getattr(app.state, "llm", None) + made = await make_incident(npcs, clock.now, llm, rng=rng, config=config) + memory.add(*made.memories) + investigation.incident = made.incident + if (saver := getattr(app.state, "persistence", None)) is not None: + await saver.save() + logger.info("%s Made the incident%s", clock, "" if made.written else " from templates") + return made.incident diff --git a/server/simile/story/cli.py b/server/simile/story/cli.py new file mode 100644 index 00000000..6eed70bb --- /dev/null +++ b/server/simile/story/cli.py @@ -0,0 +1,43 @@ +from typing import Annotated + +import typer + + +def register(app: typer.Typer) -> None: + story = typer.Typer(help="Aris and the incident.", no_args_is_help=True) + + @story.command() + def incident( + seed: Annotated[int | None, typer.Option(help="Seed for who attacks and who saw.")] = None, + facts: Annotated[bool, typer.Option(help="List the facts and who knows them.")] = False, + ) -> None: + """Make and print an incident as a new world would, with one Sonnet call, saving nothing.""" + import asyncio + import logging + import os + import random + from datetime import UTC, datetime + + from simile.llm import LLMClient + from simile.llm.lifespan import FAKE_ENV, CannedProvider + from simile.npc import load_npcs + from simile.story.incident import make_incident + + logging.basicConfig(level=logging.INFO, format="%(message)s") + llm = LLMClient(CannedProvider() if os.environ.get(FAKE_ENV) == "fake" else None) + now = datetime.now(UTC) + made = asyncio.run(make_incident(load_npcs(), now, llm, rng=random.Random(seed))) + found = made.incident + typer.echo(f"Attackers: {', '.join(found.attackers)}") + typer.echo(f"Motive{'' if made.written else ' (from templates)'}: {found.motive}") + if facts: + for fact in found.facts: + knowers = ", ".join( + f"{k.id} ({k.how}, tells from {k.tells_from}, cover {k.cover!r})" + for k in fact.known_by + ) + typer.echo(f"\n{fact.id}: {fact.what}\n known by: {knowers or 'nobody'}") + for lead in fact.leads: + typer.echo(f" -> {lead.fact_id}: {lead.text}") + + app.add_typer(story, name="story") diff --git a/server/simile/story/config.py b/server/simile/story/config.py new file mode 100644 index 00000000..efcde2b2 --- /dev/null +++ b/server/simile/story/config.py @@ -0,0 +1,87 @@ +from datetime import timedelta + +from pydantic import BaseModel, ConfigDict, Field + +from simile.llm import Model +from simile.schema import CoreValue, HowKnown, Unit + + +class StoryConfig(BaseModel): + """Every tunable constant of the incident.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + model: str = Field(default=Model.SONNET, description="The model that writes the motive.") + write_for: float = Field( + default=180, + gt=0, + description="Real seconds to wait for the motive before writing it from templates.", + ) + attackers: dict[int, float] = Field( + default={1: 0.4, 2: 0.35, 3: 0.25}, + description="How likely each number of attackers is, while enough residents are hostile.", + ) + hostile_from: float = Field( + default=0.2, + description="The least hostility to Aris an attacker has: their grievance's fit plus " + "`harm`. If nobody has it, the most hostile resident attacks her alone.", + ) + harm: float = Field( + default=1.0, + ge=0, + description="How much low Compassion adds to hostility: this times (0.5 - compassion).", + ) + grievance_spread: float = Field( + default=0.15, + ge=0, + description="An attacker's grievance is drawn from those that fit them within this of " + "their best.", + ) + night_before: timedelta = Field( + default=timedelta(days=8), description="How long before the world starts she was attacked." + ) + night_at: tuple[int, int] = Field( + default=(23, 20), description="The hour, and the latest minute past it, of the attack." + ) + gone_before: timedelta = Field( + default=timedelta(hours=26), + description="How long before the world starts her workshop was found open.", + ) + tells_from: dict[CoreValue, float] = Field( + default={ + CoreValue.SECURITY: 0.3, + CoreValue.PEACE_OF_MIND: 0.2, + CoreValue.JUSTICE: -0.3, + CoreValue.HONESTY: -0.2, + }, + description="How wary a witness is: the trust they need in whoever asks is 0.5 plus " + "each weight times (value - 0.5), from 0 to `most_wary`.", + ) + most_wary: Unit = 0.9 + witness_lies_below: Unit = Field( + default=0.4, + description="A witness less honest than this lies to those they don't trust enough, " + "rather than keeping quiet.", + ) + owns_up_from: Unit = Field( + default=0.6, + description="An attacker at least this honest owns up to someone they trust " + "`owns_up_to`. Anyone else never does.", + ) + owns_up_to: Unit = 0.95 + admits_grudge_from: Unit = Field( + default=0.75, + description="The trust an attacker needs in whoever asks to own up to their grudge.", + ) + salience: dict[HowKnown, Unit] = Field( + default={HowKnown.DID: 0.9, HowKnown.WITNESSED: 0.7, HowKnown.TOLD: 0.5}, + description="How much a seeded memory matters to its owner, by how they know the fact.", + ) + valence: dict[HowKnown, float] = Field( + default={HowKnown.DID: -0.4, HowKnown.WITNESSED: -0.5, HowKnown.TOLD: -0.3}, + description="How its owner felt about it. A hostile resident is glad she's gone.", + ) + relieved: float = Field(default=0.2, description="How a hostile resident feels she's gone.") + + +DEFAULT_CONFIG = StoryConfig() diff --git a/server/simile/story/content.py b/server/simile/story/content.py new file mode 100644 index 00000000..8c747187 --- /dev/null +++ b/server/simile/story/content.py @@ -0,0 +1,84 @@ +import tomllib +from functools import cache +from pathlib import Path + +from pydantic import BaseModel, ConfigDict, Field + +from simile.schema import CoreValue, Id, Key + +STORY = Path(__file__).resolve().parents[3] / "content" / "story" / "story.toml" + + +class Authored(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + +class Aris(Authored): + name: str + workshop: Id = Field(description="Her workshop's location id.") + street: Key = Field(description="The street outside it, a map id.") + description: str + + +class Clue(Authored): + """Something in Aris's workshop the player can find a fact in: a fact's `found_in`.""" + + id: Id + label: str = Field(description="As the journal names it.") + map: Key + x: int + y: int + pages: list[str] = Field(default=[], description="What it says besides the fact, if anything.") + + +class Place(Authored): + id: Id + name: str + + +class Grievance(Authored): + """Something Aris did that an attacker can't forgive, and the values that make it rankle.""" + + values: dict[CoreValue, float] + told: str = Field(description="What it is, for whoever writes the motive.") + grievance: str = Field(description="In the third person, with `{name}`.") + resents: str = Field(description="In the attacker's own words.") + note: str = Field(description="What Aris wrote about it, with the attacker's `{initial}`.") + + +class Texts(Authored): + """What stands in when nobody wrote it.""" + + remembers: str = Field(description="An attacker's memory of the night, with `{with}`.") + alibi: str + deflection: str + denial: str + alone: str = Field(description="The motive's end for one attacker, with `{name}`.") + together: str = Field(description="The motive's end for several.") + relieved: str = Field(description="How a hostile resident ends their memory of her going.") + covers: dict[Id, str] = Field( + description="What a witness who'd rather lie says instead, by the fact they saw." + ) + + +class StoryContent(Authored): + aris: Aris + clues: list[Clue] + meeting_places: list[Place] = Field(min_length=1) + grievances: dict[Id, Grievance] = Field(min_length=1) + texts: Texts + + def clue(self, clue_id: Id) -> Clue: + return next(clue for clue in self.clues if clue.id == clue_id) + + +NOTES = "aris_notes" +"""The clue that holds Aris's notes.""" +DOOR = "door_frame" +"""The clue the attackers left behind.""" + + +@cache +def load_story(path: Path = STORY) -> StoryContent: + with path.open("rb") as file: + return StoryContent.model_validate(tomllib.load(file)) diff --git a/server/simile/story/incident.py b/server/simile/story/incident.py new file mode 100644 index 00000000..0c0c7522 --- /dev/null +++ b/server/simile/story/incident.py @@ -0,0 +1,465 @@ +import random +from collections.abc import Iterator, Sequence +from dataclasses import dataclass, field +from datetime import datetime, timedelta + +from simile.llm import LLMClient +from simile.schema import ( + ARIS, + CoreValue, + Fact, + HowKnown, + Id, + Incident, + Knower, + Lead, + Memory, + MemoryKind, + Npc, +) +from simile.story.config import DEFAULT_CONFIG, StoryConfig +from simile.story.content import DOOR, NOTES, Place, StoryContent, load_story +from simile.story.looks import a, figure, top +from simile.story.suspects import Suspect, attackers, hostility +from simile.story.writing import Backstory, initial, write + +OTHERS = ("", " and someone else", " and two others") +GONE = "gone" +NOTED = "notes" +CLOTH = "cloth" +SHOUTING = "shouting" +SEEN = "seen" +MEETING = "meeting" +ATTACK = "attack" +TOPIC = "aris" + + +GRUDGE = "grudge_" +THERE = "there_" +ASKED = { + ATTACK: "who attacked Aris", + SHOUTING: "whether {they} heard anything the night Aris was attacked", + SEEN: "whether {they} saw anyone the night Aris was attacked", + MEETING: "who {they} saw together the evening before Aris was attacked", +} + + +def grudge(npc: Id) -> Id: + return f"{GRUDGE}{npc}" + + +def there(npc: Id) -> Id: + return f"{THERE}{npc}" + + +def question(fact_id: Id, how: HowKnown) -> str: + """What someone has to ask before a knower gives their cover story for a fact.""" + if fact_id.startswith(THERE): + return "where {they}'d been the night Aris was attacked" + if fact_id.startswith(GRUDGE): + did = how is HowKnown.DID + return "how {they} got on with Aris" if did else "whether {they} saw anyone row with Aris" + return ASKED.get(fact_id, "what happened to Aris") + + +@dataclass(frozen=True) +class Made: + """An incident and every resident's memories of it, which the store doesn't hold yet.""" + + incident: Incident + memories: list[Memory] + written: bool + """Whether the motive's writer wrote the backstory, rather than the templates.""" + + +@dataclass +class _Building: + npcs: dict[Id, Npc] + content: StoryContent + config: StoryConfig + facts: list[Fact] = field(default_factory=list) + remembered: dict[tuple[Id, Id], str] = field(default_factory=dict) + + def name(self, npc: Id) -> str: + return self.npcs[npc].name.split()[0] + + def names(self, npcs: Sequence[Id]) -> str: + named = [self.name(npc) for npc in npcs] + return named[0] if len(named) == 1 else f"{', '.join(named[:-1])} and {named[-1]}" + + def add(self, fact: Fact, remembered: dict[Id, str]) -> Fact: + self.facts.append(fact) + for npc, text in remembered.items(): + self.remembered[(fact.id, npc)] = text + return fact + + def witness(self, npc: Id, part: Id) -> Knower: + """A witness, as wary as their values make them, who lies if they're dishonest.""" + config, values = self.config, self.npcs[npc].values + wary = 0.5 + sum(w * (values[value] - 0.5) for value, w in config.tells_from.items()) + honest = values[CoreValue.HONESTY] >= config.witness_lies_below + return Knower( + id=npc, + how=HowKnown.WITNESSED, + tells_from=round(min(max(wary, 0.0), config.most_wary), 2), + cover=None if honest else self.content.texts.covers[part], + ) + + def attacker(self, npc: Id, cover: str, tells_from: float | None = None) -> Knower: + """An attacker, who lies with `cover` below `tells_from`, or owns up only if honest.""" + honest = self.npcs[npc].values[CoreValue.HONESTY] >= self.config.owns_up_from + if tells_from is None and honest: + tells_from = self.config.owns_up_to + return Knower(id=npc, how=HowKnown.DID, tells_from=tells_from, cover=cover) + + +def build( + npcs: Sequence[Npc], + suspects: Sequence[Suspect], + backstory: Backstory, + now: datetime, + rng: random.Random, + content: StoryContent, + config: StoryConfig = DEFAULT_CONFIG, + *, + met: Place | None = None, + written: bool = False, +) -> Made: + """The incident around `suspects` a week before `now`: its facts, clues and leads.""" + making = _Building({npc.id: npc for npc in npcs}, content, config) + ids = [found.npc.id for found in suspects] + sides = {side.id: side for side in backstory.attackers} + hour, latest = config.night_at + night = (now - config.night_before).replace( + hour=hour, minute=rng.randint(0, latest), second=0, microsecond=0 + ) + gone = (now - config.gone_before).replace(minute=0, second=0, microsecond=0) + workshop, street = content.aris.workshop, content.aris.street + others = [npc.id for npc in npcs if npc.id not in ids] + bystanders = _bystanders(others, rng) + noted = [rng.choice(ids)] if others else ids + marked = rng.choice(ids) + + witnesses = { + SHOUTING: next(bystanders, None), + SEEN: next(bystanders, None), + **{grudge(npc): next(bystanders, None) for npc in ids}, + MEETING: next(bystanders, None) if len(ids) > 1 else None, + } + + for npc in ids: + making.add( + Fact( + id=there(npc), + what=f"{making.name(npc)} was in the lane behind the maker space when Aris was " + "attacked.", + when=night, + where=workshop, + about=[npc, ARIS], + known_by=[making.attacker(npc, sides[npc].alibi)], + ), + {npc: "I was in the lane behind the maker space the night Aris was attacked."}, + ) + + for npc in ids: + name, side = making.name(npc), sides[npc] + rowed_by = witnesses[grudge(npc)] + row = f"{name} rowed with Aris at the maker space a few days before she was attacked." + knowers = [making.attacker(npc, side.denial, config.admits_grudge_from)] + remembered = {npc: side.resents} + if rowed_by is not None: + knowers.append(making.witness(rowed_by, "grudge")) + remembered[rowed_by] = ( + f"A few days before Aris was attacked, I was at the maker space when {name} " + f"rowed with her. {side.grievance}" + ) + making.add( + Fact( + id=grudge(npc), + what=f"{row} {side.grievance}", + when=(night - timedelta(days=rng.randint(2, 5))).replace( + hour=rng.randint(10, 17), minute=rng.choice((0, 15, 30, 45)) + ), + where=workshop, + about=[npc, ARIS], + known_by=knowers, + leads=[ + Lead( + fact_id=there(npc), + text=f"{name} had it in for Aris. Ask where {name} was the night she " + "was attacked.", + ) + ], + ), + remembered, + ) + + attack = making.add( + Fact( + id=ATTACK, + what=f"{making.names(ids)} smashed up the maker space and attacked Aris in the lane " + "behind it, tearing her steel hand half off.", + when=night, + where=workshop, + about=[*ids, ARIS], + known_by=[making.attacker(npc, sides[npc].deflection) for npc in ids], + ), + {npc: sides[npc].remembers for npc in ids}, + ) + + if (met_by := witnesses[MEETING]) is not None: + place = met or content.meeting_places[0] + together = making.names(ids) + making.add( + Fact( + id=MEETING, + what=f"{together} met at {place.name} the evening before the attack and talked " + "about sorting out the maker space once and for all.", + when=(night - timedelta(days=1)).replace(hour=19, minute=30), + where=place.id, + about=[*ids, ARIS], + known_by=[making.witness(met_by, MEETING)], + leads=[ + Lead( + fact_id=there(npc), + text=f"{making.name(npc)} was at that meeting. Ask where " + f"{making.name(npc)} was the night Aris was attacked.", + ) + for npc in ids + ], + ), + { + met_by: f"The evening before Aris was attacked, I saw {together} with their " + f"heads together at {place.name}, talking about sorting out the maker space " + "once and for all." + }, + ) + + marked_name, looks = making.name(marked), making.npcs[marked].appearance + if (seen_by := witnesses[SEEN]) is not None: + who = figure(looks) + group = f"{who}{OTHERS[len(ids) - 1]}" + leads = [ + Lead( + fact_id=there(marked), + text=f"{_capital(who)}: that sounds like {marked_name}. Ask where {marked_name} " + "was that night.", + ) + ] + if met_by is not None: + leads.append( + Lead( + fact_id=MEETING, + text=f"They weren't alone. {making.name(met_by)} saw people with their heads " + f"together the evening before: ask {making.name(met_by)} who.", + ) + ) + making.add( + Fact( + id=SEEN, + what=f"{making.name(seen_by)} saw {group} hurry out of the lane behind the " + "maker space just after Aris was attacked.", + when=night + timedelta(minutes=8), + where=street, + about=list(ids), + known_by=[making.witness(seen_by, SEEN)], + leads=leads, + ), + { + seen_by: f"Late on the night Aris was attacked, I saw {group} hurry out of the " + "lane behind the maker space." + }, + ) + + if (heard_by := witnesses[SHOUTING]) is not None: + leads = [] + if seen_by is not None: + leads.append( + Lead( + fact_id=SEEN, + text=f"{making.name(seen_by)} was out on the street that night too. Ask " + f"what {making.name(seen_by)} saw.", + ) + ) + making.add( + Fact( + id=SHOUTING, + what=f"{making.name(heard_by)} heard shouting and breaking glass from the maker " + "space late on the night Aris was attacked.", + when=night + timedelta(minutes=3), + where=street, + about=[ARIS], + known_by=[making.witness(heard_by, SHOUTING)], + leads=leads, + ), + { + heard_by: "Late on the night Aris was attacked, I heard shouting and breaking " + "glass from the maker space." + }, + ) + + colour = top(looks) + making.add( + Fact( + id=CLOTH, + what=f"A scrap of {colour} cloth, torn from someone's top, was caught on the maker " + "space's splintered door frame.", + when=night, + where=workshop, + about=[marked], + known_by=[], + found_in=DOOR, + leads=[ + Lead( + fact_id=SEEN, + text=f"Someone in {a(colour)} top was at the maker space that night. " + f"{making.name(seen_by)} was out on the street: ask what " + f"{making.name(seen_by)} saw.", + ) + ] + if seen_by is not None + else [ + Lead( + fact_id=there(marked), + text=f"{marked_name} wears {a(colour)} top. Ask where {marked_name} was " + "that night.", + ) + ], + ), + {}, + ) + + notes = making.add( + Fact( + id=NOTED, + what="Aris wrote in her notebook: " + " ".join(f'"{sides[npc].note}"' for npc in noted), + when=(night - timedelta(days=1)).replace(hour=21, minute=0), + where=workshop, + about=[ARIS, *noted], + known_by=[], + found_in=NOTES, + leads=[ + *(_ask_about_row(making, npc, witnesses[grudge(npc)]) for npc in noted), + *([_ask_about_night(making, heard_by)] if heard_by is not None else []), + ], + ), + {}, + ) + + everyone = [npc.id for npc in npcs] + relieved = { + npc.id for npc in npcs if hostility(npc, content.grievances, config) >= config.hostile_from + } + village = ( + "Aris was attacked in the lane behind the maker space one night, her steel hand torn half " + "off, and wouldn't say who did it. A week later her workshop was found standing open, " + "her tea cold, and she hasn't been seen since." + ) + making.add( + Fact( + id=GONE, + what=village, + when=gone, + where=workshop, + about=[ARIS], + known_by=[ + Knower(id=npc, how=HowKnown.TOLD, tells_from=0.0, cover=None) for npc in everyone + ], + leads=[ + Lead( + fact_id=notes.id, + text="Her notes are still on the workbench at the maker space. Look through " + "them.", + ), + *([_ask_about_night(making, heard_by)] if heard_by is not None else []), + ], + ), + { + npc: f"{village} {content.texts.relieved}" if npc in relieved else village + for npc in everyone + }, + ) + + incident = Incident( + attackers=ids, + motive=backstory.motive, + night=attack.when, + facts=making.facts[::-1], + ) + return Made(incident, _memories(making, relieved), written) + + +def _ask_about_row(making: _Building, npc: Id, seen_by: Id | None) -> Lead: + who = initial(making.npcs[npc].name) + if seen_by is None: + return Lead( + fact_id=grudge(npc), + text=f"Who's {who}? Find out, and ask how they got on with Aris.", + ) + return Lead( + fact_id=grudge(npc), + text=f"Who's {who}? {making.name(seen_by)} was at the maker space when someone rowed " + f"with Aris. Ask {making.name(seen_by)} about it.", + ) + + +def _ask_about_night(making: _Building, heard_by: Id) -> Lead: + return Lead( + fact_id=SHOUTING, + text=f"{making.name(heard_by)} was up late the night Aris was attacked. Ask whether " + f"{making.name(heard_by)} heard anything.", + ) + + +def _memories(making: _Building, relieved: set[Id]) -> list[Memory]: + config = making.config + memories = [] + for fact in making.facts: + for knower in fact.known_by: + glad = fact.id == GONE and knower.id in relieved + memories.append( + Memory( + owner=knower.id, + kind=MemoryKind.OBSERVATION, + content=making.remembered[(fact.id, knower.id)], + salience=config.salience[knower.how], + valence=config.relieved if glad else config.valence[knower.how], + timestamp=fact.when, + about=[who for who in fact.about if who != knower.id], + topics=[TOPIC], + fact_id=fact.id, + ) + ) + return memories + + +def _bystanders(npcs: list[Id], rng: random.Random) -> Iterator[Id]: + """Residents to witness each part of the night, each once before anyone twice.""" + if not npcs: + return + while True: + round_ = sorted(npcs) + rng.shuffle(round_) + yield from round_ + + +def _capital(text: str) -> str: + return text[:1].upper() + text[1:] + + +async def make_incident( + npcs: Sequence[Npc], + now: datetime, + llm: LLMClient | None, + *, + rng: random.Random | None = None, + content: StoryContent | None = None, + config: StoryConfig = DEFAULT_CONFIG, +) -> Made: + """A new incident among `npcs`, with its motive written in one call, and their memories.""" + rng = rng or random.Random() + content = content or load_story() + found = attackers(npcs, content.grievances, rng, config) + met = rng.choice(content.meeting_places) + backstory, written = await write(llm, found, content, config, met=met) + return build(npcs, found, backstory, now, rng, content, config, met=met, written=written) diff --git a/server/simile/story/lifespan.py b/server/simile/story/lifespan.py new file mode 100644 index 00000000..c9799d99 --- /dev/null +++ b/server/simile/story/lifespan.py @@ -0,0 +1,12 @@ +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from starlette.applications import Starlette + + +@asynccontextmanager +async def lifespan(app: Starlette) -> AsyncIterator[None]: + from simile.story.begin import begin + + await begin(app) + yield diff --git a/server/simile/story/looks.py b/server/simile/story/looks.py new file mode 100644 index 00000000..b8220c5f --- /dev/null +++ b/server/simile/story/looks.py @@ -0,0 +1,82 @@ +from simile.schema import Accessory, Appearance, HairStyle + +HAIR = { + "dark": "#1c1820", + "brown": "#733e39", + "grey": "#8b9bb4", + "white": "#d4dbe6", + "fair": "#e8c170", + "red": "#b5502a", + "blue": "#3a6ad8", + "green": "#3e8948", + "pink": "#e05a9a", + "purple": "#7a4aa0", +} +CLOTHES = { + "red": "#a22633", + "dark red": "#6b1c24", + "orange": "#f77622", + "amber": "#feae34", + "yellow": "#fee761", + "green": "#3e8948", + "dark green": "#265c42", + "turquoise": "#2ce8f5", + "teal": "#2a8a8a", + "blue": "#124e89", + "light blue": "#5a9bd8", + "navy": "#1c2a4a", + "purple": "#68386c", + "pink": "#f6757a", + "brown": "#733e39", + "tan": "#c28569", + "grey": "#8b9bb4", + "black": "#181425", + "white": "#ead4aa", +} +SEEN_IN_THE_DARK = { + Accessory.SUNGLASSES: "sunglasses", + Accessory.GLASSES: "glasses", + Accessory.CAP: "a cap", + Accessory.BEANIE: "a beanie", + Accessory.SCARF: "a scarf", + Accessory.HEADPHONES: "headphones", + Accessory.BACKPACK: "a backpack", +} +COVERED = {Accessory.CAP, Accessory.BEANIE} + + +def nearest(colour: str, names: dict[str, str]) -> str: + """The name in `names` whose colour is closest to `colour`.""" + + def rgb(hex_colour: str) -> tuple[int, ...]: + return tuple(int(hex_colour[i : i + 2], 16) for i in (1, 3, 5)) + + here = rgb(colour) + return min( + names, + key=lambda name: sum((a - b) ** 2 for a, b in zip(rgb(names[name]), here, strict=True)), + ) + + +def top(looks: Appearance) -> str: + return nearest(looks.top_colour, CLOTHES) + + +def a(word: str) -> str: + return f"an {word}" if word[:1] in "aeiou" else f"a {word}" + + +def figure(looks: Appearance) -> str: + """How someone looks across a dark lane: "a grey-haired figure in a red top and sunglasses".""" + worn = [item for item in looks.accessories if item in SEEN_IN_THE_DARK] + if looks.hair_style is HairStyle.BALD: + head = "a bald figure" + elif any(item in COVERED for item in looks.accessories): + head = "a figure" + else: + head = f"a {nearest(looks.hair_colour, HAIR)}-haired figure" + dressed = f"{head} in {a(top(looks))} top" + if not worn: + return dressed + joined = ", with" if worn[0] is Accessory.BACKPACK else " and" + return f"{dressed}{joined} {SEEN_IN_THE_DARK[worn[0]]}" diff --git a/server/simile/story/suspects.py b/server/simile/story/suspects.py new file mode 100644 index 00000000..f78b0b6f --- /dev/null +++ b/server/simile/story/suspects.py @@ -0,0 +1,68 @@ +import random +from collections.abc import Mapping, Sequence +from dataclasses import dataclass + +from simile.schema import CoreValue, Id, Npc +from simile.story.config import DEFAULT_CONFIG, StoryConfig +from simile.story.content import Grievance + + +@dataclass(frozen=True) +class Suspect: + npc: Npc + grievance: Id + hostility: float + + +def fit(npc: Npc, grievance: Grievance) -> float: + """How much `npc`'s values make `grievance` rankle, from -0.5 to 0.5.""" + weights = grievance.values + total = sum(abs(weight) for weight in weights.values()) or 1.0 + return sum(weight * (npc.values[value] - 0.5) for value, weight in weights.items()) / total + + +def hostility( + npc: Npc, grievances: Mapping[Id, Grievance], config: StoryConfig = DEFAULT_CONFIG +) -> float: + """How hostile `npc` is to Aris: their best grievance's fit, plus `harm` from low Compassion.""" + best = max(fit(npc, grievance) for grievance in grievances.values()) + return best + config.harm * (0.5 - npc.values[CoreValue.COMPASSION]) + + +def suspect( + npc: Npc, + grievances: Mapping[Id, Grievance], + rng: random.Random, + config: StoryConfig = DEFAULT_CONFIG, +) -> Suspect: + """`npc` with a grievance drawn from those that fit them within `grievance_spread`.""" + fits = {kind: fit(npc, grievance) for kind, grievance in sorted(grievances.items())} + best = max(fits.values()) + near = [kind for kind, score in fits.items() if score >= best - config.grievance_spread] + return Suspect(npc, rng.choice(near), hostility(npc, grievances, config)) + + +def attackers( + npcs: Sequence[Npc], + grievances: Mapping[Id, Grievance], + rng: random.Random, + config: StoryConfig = DEFAULT_CONFIG, +) -> list[Suspect]: + """One to three hostile residents drawn by hostility, or the most hostile alone.""" + ranked = sorted( + (suspect(npc, grievances, rng, config) for npc in sorted(npcs, key=lambda n: n.id)), + key=lambda found: -found.hostility, + ) + hostile = [found for found in ranked if found.hostility >= config.hostile_from] + if not hostile: + return ranked[:1] + counts = sorted(config.attackers) + count = min(rng.choices(counts, [config.attackers[n] for n in counts])[0], len(hostile)) + chosen: list[Suspect] = [] + pool = list(hostile) + while len(chosen) < count: + weights = [found.hostility - config.hostile_from + 0.05 for found in pool] + pick = rng.choices(pool, weights)[0] + chosen.append(pick) + pool.remove(pick) + return chosen diff --git a/server/simile/story/writing.py b/server/simile/story/writing.py new file mode 100644 index 00000000..235a5aa9 --- /dev/null +++ b/server/simile/story/writing.py @@ -0,0 +1,186 @@ +import asyncio +import logging +from collections.abc import Sequence + +from pydantic import BaseModel, Field + +from simile.llm import LLMClient, LLMError, StructuredOutputError +from simile.npc.values import ranked +from simile.story.config import DEFAULT_CONFIG, StoryConfig +from simile.story.content import Place, StoryContent +from simile.story.suspects import Suspect + +logger = logging.getLogger(__name__) + +PURPOSE = "story-motive" + +SYSTEM = """You write the hidden backstory of a village mystery game. The game has already \ +decided who attacked Aris and roughly why. You write the specifics, which the residents will \ +later voice: why they did it, and what each of them remembers and says. + +Make it credible: concrete, specific to each attacker's life, job and values, and on a small \ +village's scale, like a slight, a loss or a fear that festered. No cartoon villainy. They meant to \ +frighten or punish her, not to kill her, and it went further than they meant. Don't invent other \ +people, places or events beyond what the grievances need. Keep to the game's tone: no gore beyond \ +what's given, no slurs. Plain, everyday English. + +Every alibi, deflection and denial is something the speaker says to put people off, and it \ +stays vague: it names nobody, accuses nobody and hints at no one in particular, neither a \ +resident nor a stranger, an outsider or people from out of town.""" + + +class Written(BaseModel): + """One attacker's side of it.""" + + id: str = Field(description="The attacker's id, as given.") + grievance: str = Field( + min_length=1, + max_length=320, + description="What Aris did to them, in one sentence in the third person, naming them.", + ) + resents: str = Field( + min_length=1, max_length=320, description="The same, in one sentence in their own words." + ) + remembers: str = Field( + min_length=1, + max_length=400, + description="Their own memory of that night, in one or two sentences in the first " + "person, as they'd put it to themselves.", + ) + alibi: str = Field( + min_length=1, + max_length=160, + description="What they tell anyone who asks where they were that night, in their own " + "voice: one short sentence that names nobody.", + ) + deflection: str = Field( + min_length=1, + max_length=160, + description="What they say if asked who attacked Aris, in their own voice: one short " + "sentence that stays vague and points at nobody: no resident, stranger or outsider.", + ) + denial: str = Field( + min_length=1, + max_length=160, + description="What they say if asked how they got on with Aris, in their own voice: one " + "short sentence that names nobody.", + ) + note: str = Field( + min_length=1, + max_length=240, + description="A line Aris wrote in her workshop notebook a day or two before, about " + "them and their grievance, in her voice, naming them only by their initial.", + ) + + +class Backstory(BaseModel): + motive: str = Field( + min_length=1, + max_length=900, + description="Why they attacked her, in two or three sentences in the third person and " + "the past tense. With several attackers, what brought them together.", + ) + attackers: list[Written] = Field(description="One for each attacker, in the order given.") + + +def prompt( + suspects: Sequence[Suspect], + content: StoryContent, + met: Place | None = None, +) -> str: + aris = content.aris + together = ( + f"The {len(suspects)} of them went together." if len(suspects) > 1 else "They went alone." + ) + if len(suspects) > 1 and met is not None: + together += ( + f" They met at {met.name} the evening before and talked about sorting out the maker " + "space once and for all." + ) + people = "\n".join(_described(found, content) for found in suspects) + return ( + f"Aris: {aris.description}\n\n" + "The night: someone smashed up her workshop, the maker space, and attacked her in the " + "lane behind it, tearing her steel hand half off. She survived and wouldn't say who did " + "it. A week later her workshop was found standing open, her tea cold, and she hasn't " + "been seen since. Nobody official has come asking.\n\n" + f"Who attacked her:\n{people}\n\n{together}\n\n" + "Write the motive, and each attacker's side of it." + ) + + +def _described(found: Suspect, content: StoryContent) -> str: + npc = found.npc + order = [value.value.replace("_", " ") for value, _ in ranked(npc.values)] + most, least = ", ".join(order[:4]), ", ".join(reversed(order[-3:])) + persona = npc.persona.split(". ")[0].rstrip(".") + return ( + f"- {npc.id}: {npc.name}, {npc.age}, {npc.occupation}. {persona}. Cares most about " + f"{most}; least about {least}. Their initial: {initial(npc.name)} What Aris did: " + f"{content.grievances[found.grievance].told}" + ) + + +def initial(name: str) -> str: + return f"{name[0].upper()}." + + +def drafted(suspects: Sequence[Suspect], content: StoryContent) -> Backstory: + """The backstory from the content's templates, for when nobody writes it.""" + texts = content.texts + names = [found.npc.name.split()[0] for found in suspects] + sides = [] + for found, name in zip(suspects, names, strict=True): + grievance = content.grievances[found.grievance] + others = [other for other in names if other != name] + sides.append( + Written( + id=found.npc.id, + grievance=grievance.grievance.format(name=name), + resents=grievance.resents, + remembers=texts.remembers.replace( + "{with}", f" with {' and '.join(others)}" if others else "" + ), + alibi=texts.alibi, + deflection=texts.deflection, + denial=texts.denial, + note=grievance.note.format(initial=initial(found.npc.name)[0]), + ) + ) + end = texts.alone.format(name=names[0]) if len(names) == 1 else texts.together + motive = " ".join([*(side.grievance for side in sides), end]) + return Backstory(motive=motive, attackers=sides) + + +async def write( + llm: LLMClient | None, + suspects: Sequence[Suspect], + content: StoryContent, + config: StoryConfig = DEFAULT_CONFIG, + *, + met: Place | None = None, +) -> tuple[Backstory, bool]: + """The backstory from one `structured` call, or the templates, and whether it was written.""" + fallback = drafted(suspects, content) + if llm is None: + return fallback, False + try: + async with asyncio.timeout(config.write_for): + written = await llm.structured( + prompt(suspects, content, met), + Backstory, + system=SYSTEM, + model=config.model, + purpose=PURPOSE, + ) + except (LLMError, StructuredOutputError, TimeoutError) as error: + logger.warning("Couldn't write the incident's motive, so it's from templates: %s", error) + return fallback, False + sides = {side.id: side for side in written.attackers} + return ( + Backstory( + motive=written.motive, + attackers=[sides.get(side.id, side) for side in fallback.attackers], + ), + True, + ) diff --git a/server/tests/conversation/test_telling.py b/server/tests/conversation/test_telling.py new file mode 100644 index 00000000..ff4c4456 --- /dev/null +++ b/server/tests/conversation/test_telling.py @@ -0,0 +1,323 @@ +import asyncio +from datetime import timedelta + +import pytest + +from simile.conversation import ConversationConfig +from simile.conversation.telling import KEEP, SUBJECT, UNASKED, Stance, asked +from simile.llm import FakeProvider +from simile.reflection import Extraction, Reflection, Reflector +from simile.schema import ( + PLAYER_ID, + Fact, + HowKnown, + Incident, + Knower, + Memory, + MemoryKind, +) +from simile.sim import ConversationEnded, ConversationStarted + +from .test_player import ANA, BEN, START, Village, feeling + +pytestmark = pytest.mark.anyio + +NIGHT = START - timedelta(days=8) +SAW = "I saw someone in a red top hurry out of the lane behind the maker space." +HEARD = "I heard shouting and breaking glass from the maker space." +MET = "I saw Ben and Cal with their heads together at the noodle bar." +DID = "I went round with Cal to make Aris back down, and it got out of hand." +WHERE = "I was in the lane behind the maker space that night." +GONE = "Aris was attacked, and a week later she was gone." +ALIBI = "I was at home all night." +DEFLECTION = "No idea, I'm afraid." +SECRETS = (SAW, HEARD, MET, DID, WHERE) +LONG_AGO = ConversationConfig(leave_below=0.0) + + +def fact(id: str, *known_by: Knower) -> Fact: + return Fact( + id=id, + what=f"What happened: {id}.", + when=NIGHT, + where="maker_space", + about=["aris", "ben"], + known_by=list(known_by), + ) + + +def knower(id: str, tells_from: float | None, cover: str | None = None) -> Knower: + return Knower(id=id, how=HowKnown.WITNESSED, tells_from=tells_from, cover=cover) + + +def did(cover: str) -> Knower: + return Knower(id="ben", how=HowKnown.DID, tells_from=None, cover=cover) + + +INCIDENT = Incident( + attackers=["ben"], + motive="Ben blamed Aris for a deal that fell through.", + night=NIGHT, + facts=[ + fact("gone", knower("ana", 0.0), knower("ben", 0.0)), + fact("seen", knower("ana", 0.3)), + fact("shouting", knower("ana", 0.9)), + fact("meeting", knower("ana", 0.9)), + fact("attack", did(DEFLECTION)), + fact("there_ben", did(ALIBI)), + ], +) +MEMORIES = [ + Memory( + owner=owner, + kind=MemoryKind.OBSERVATION, + content=content, + salience=0.9, + valence=-0.5, + timestamp=NIGHT, + about=["aris"], + fact_id=fact_id, + ) + for owner, fact_id, content in [ + ("ana", "gone", GONE), + ("ben", "gone", GONE), + ("ana", "seen", SAW), + ("ana", "shouting", HEARD), + ("ana", "meeting", MET), + ("ben", "attack", DID), + ("ben", "there_ben", WHERE), + ] +] + + +def investigating(*, incident: Incident | None = INCIDENT, **kwargs: object) -> Village: + village = Village(["Hmm. [values: honesty]"], **kwargs) # type: ignore[arg-type] + village.store.add(*MEMORIES) + village.engine.incident = lambda: incident + return village + + +def started(village: Village) -> str: + return village.of(ConversationStarted)[0].conversation.id + + +def test_the_incident_is_touched_by_a_mention_of_aris_or_the_attack() -> None: + for said in ( + "Did you know Aris?", + "Where were you the night Aris was attacked?", + "Who smashed up the workshop?", + "What happened at the workshop?", + "Did you hear about the break-in?", + "Who smashed up her workshop?", + "What happened to her?", + "Have you seen the mechanic lately?", + "Tell me about the attack.", + ): + assert asked(said), said + for said in ( + "Lovely morning, isn't it?", + "My uncle had a heart attack last year.", + "Where were you yesterday?", + "Where were you that night?", + "That night at the pub was a laugh.", + "I'm off to the workshop.", + "Ingrid's at the maker space all day.", + ): + assert not asked(said), said + + +async def test_a_witness_tells_what_they_trust_the_player_with_and_keeps_back_the_rest() -> None: + village = investigating() + + village.players.say("Did you see anything the night Aris was attacked?", "ana") + await village.run() + + prompt = village.script.prompts[-1] + assert f'trust Visitor enough to tell them if they ask: "{SAW}"' in prompt + assert f'"{GONE}"' in prompt + assert prompt.count(KEEP.format(other="Visitor")) == 1 + assert HEARD not in prompt and MET not in prompt + talk = village.players.talk() + assert talk is not None + stances = {id: telling.stance for id, telling in talk.tellings.items()} + assert stances == { + "gone": Stance.TELL, + "seen": Stance.TELL, + "shouting": Stance.KEEP, + "meeting": Stance.KEEP, + } + assert talk.lies == {} and UNASKED not in prompt + assert village.engine.lies(started(village)) == [] + + +async def test_trust_opens_up_what_they_kept_back() -> None: + village = investigating(relationships=[feeling(ANA, PLAYER_ID, trust=0.95)]) + + village.players.say("What happened to Aris?", "ana") + await village.run() + + prompt = village.script.prompts[-1] + assert f'"{HEARD}"' in prompt and f'"{MET}"' in prompt + assert KEEP.format(other="Visitor") not in prompt + + +async def test_an_attacker_gives_each_cover_only_to_its_own_question() -> None: + village = investigating() + village.walk_to("ben", 1) + + village.players.say("Where were you the night Aris was attacked?", "ben") + await village.run() + + prompt = village.script.prompts[-1] + assert ( + "You won't say where you'd been the night Aris was attacked. Only if asked that, say " + f'exactly: "{ALIBI}" Use that cover story and nothing else' + ) in prompt + assert ( + f'You won\'t say who attacked Aris. Only if asked that, say exactly: "{DEFLECTION}"' + ) in prompt + assert prompt.count(UNASKED) == 1 + assert not any(secret in prompt for secret in SECRETS) + talk = village.players.talk() + assert talk is not None and talk.tellings["attack"].stance is Stance.LIE + assert talk.lies["fact/there_ben"].fact_id == "there_ben" + lies = village.engine.lies(started(village)) + assert {(lie.liar, lie.listener, lie.subject) for lie in lies} == {("ben", PLAYER_ID, SUBJECT)} + assert {lie.fact_id for lie in lies} == {"attack", "there_ben"} + assert not any(secret in lie.said for lie in lies for secret in SECRETS) + + +async def test_nothing_is_set_up_until_the_player_asks_and_then_it_stays() -> None: + village = investigating(config=LONG_AGO) + village.walk_to("ben", 1) + + for said in ("Lovely morning, isn't it?", "Did you know Aris?", "What do you do for fun?"): + village.players.say(said, "ben") + await village.run() + + first, second, third = village.script.prompts + assert ALIBI not in first and GONE not in first + assert ALIBI in second and ALIBI in third + assert len(village.engine.lies(started(village))) == 2 + + +async def test_without_an_incident_nobody_knows_anything() -> None: + village = investigating(incident=None) + + village.players.say("What happened to Aris?", "ana") + await village.run() + + talk = village.players.talk() + assert talk is not None and talk.tellings == {} + assert SAW not in village.script.prompts[-1] + + +async def test_what_they_know_never_comes_up_in_a_scene_or_on_their_mind() -> None: + village = investigating(npcs=(ANA, BEN)) + + village.engine.start("ana", "ben") + await village.run(60) + village.players.say("Nice weather.", "ana") + await village.run() + + assert "Ana has just walked up to Ben" in village.script.prompts[0] + assert "Say your next line to Visitor" in village.script.prompts[-1] + for prompt in village.script.prompts: + for remembered in (*SECRETS, GONE, ALIBI): + assert remembered not in prompt + + +ADMITTED = "I told Visitor I was home all night, though I wasn't." +RESENTS = "She cost me the best deal I ever had." +DENIAL = "We got on fine." +NOTICED = "I didn't notice who was in." +GRUDGES = Incident( + attackers=["ben"], + motive="Ben blamed Aris for a deal that fell through.", + night=NIGHT, + facts=[ + fact("grudge_ben", Knower(id="ben", how=HowKnown.DID, tells_from=0.75, cover=DENIAL)), + fact("meeting", knower("ana", 0.9, NOTICED)), + ], +) + + +def remembered(owner: str, fact_id: str, content: str) -> Memory: + return Memory( + owner=owner, + kind=MemoryKind.OBSERVATION, + content=content, + salience=0.9, + valence=-0.5, + timestamp=NIGHT, + about=["aris"], + fact_id=fact_id, + ) + + +def reflecting(village: Village) -> Reflector: + reflector = Reflector( + village.store, + lambda: village.behaviour.npcs, + lambda: village.llm, + lies=village.engine.lies, + ) + village.world.bus.subscribe(ConversationEnded, reflector.on_ended) + return reflector + + +async def ask_and_leave(village: Village, reflector: Reflector, npc: str, said: str) -> None: + village.players.say(said, npc) + await village.run(4) + village.players.say("Bye!", npc) + await village.run(4) + await asyncio.gather(*reflector.tasks) + + +def admits(npc: str, summary: str) -> Extraction: + return Extraction( + reflections=[Reflection(npc=npc, summary=summary, valence=-0.1, salience=0.4)], + lies=["L1", "L2"], + ) + + +async def test_b3_never_sees_the_truth_and_files_the_lie_and_its_summary_under_the_fact() -> None: + provider = FakeProvider([f"{ALIBI} [values: honesty]", "Bye now!", admits("ben", ADMITTED)]) + village = investigating(provider=provider, config=LONG_AGO) + village.walk_to("ben", 1) + reflector = reflecting(village) + + await ask_and_leave(village, reflector, "ben", "Where were you the night Aris was attacked?") + + b3 = provider.requests[2].messages[-1].content + assert ALIBI in b3 and not any(secret in b3 for secret in SECRETS) + assert village.store.recall("ben", village.clock.now, k=100) == [] + filed = {m.content: m.fact_id for m in village.store.facts("ben") if m.timestamp > NIGHT} + assert filed[ADMITTED] in {"attack", "there_ben"} + assert filed["I lied to Visitor about what happened to Aris."] in {"attack", "there_ben"} + + +@pytest.mark.parametrize( + ("npc", "remembers", "cover"), + [("ben", RESENTS, DENIAL), ("ana", MET, NOTICED)], +) +async def test_someone_who_lied_tells_what_they_remember_once_they_trust_the_player( + npc: str, remembers: str, cover: str +) -> None: + provider = FakeProvider( + [f"{cover} [values: honesty]", "Bye now!", admits(npc, "I put Visitor off."), "Well."] + ) + village = investigating(incident=GRUDGES, provider=provider, config=LONG_AGO) + village.store.add(remembered("ben", "grudge_ben", RESENTS)) + village.walk_to(npc, 1) + reflector = reflecting(village) + await ask_and_leave(village, reflector, npc, "Did Aris have words with anyone?") + assert f'say exactly: "{cover}"' in provider.requests[0].messages[-1].content + village.store.add(feeling(ANA if npc == "ana" else BEN, PLAYER_ID, trust=0.95)) + + village.players.say("What happened to Aris?", npc) + await village.run() + + prompt = provider.requests[-1].messages[-1].content + assert f'trust Visitor enough to tell them if they ask: "{remembers}"' in prompt + assert "I lied to Visitor" not in prompt and "I put Visitor off." not in prompt diff --git a/server/tests/memory/test_facts.py b/server/tests/memory/test_facts.py new file mode 100644 index 00000000..af9cce08 --- /dev/null +++ b/server/tests/memory/test_facts.py @@ -0,0 +1,46 @@ +from datetime import timedelta + +from simile.memory import MemoryStore +from simile.schema import Memory, MemoryKind + +from .test_store import NOW, memory + + +def of_fact(content: str, fact_id: str, *, owner: str = "mei", days_ago: float = 8) -> Memory: + return Memory( + owner=owner, + kind=MemoryKind.OBSERVATION, + content=content, + salience=1.0, + valence=-0.5, + timestamp=NOW - timedelta(days=days_ago), + about=["aris", "kofi"], + fact_id=fact_id, + ) + + +def test_recall_leaves_out_memories_of_the_incident(store: MemoryStore) -> None: + store.add( + of_fact("I heard shouting at the maker space.", "shouting", days_ago=0), + memory("Kofi showed me his mural.", about=("kofi",), salience=0.1), + ) + + assert [m.content for m in store.recall("mei", NOW)] == ["Kofi showed me his mural."] + assert [m.content for m in store.recall("mei", NOW, about="kofi")] == [ + "Kofi showed me his mural." + ] + assert store.recall("mei", NOW, kind=MemoryKind.OBSERVATION) == [] + + +def test_facts_are_an_owners_memories_of_the_incident_oldest_first(store: MemoryStore) -> None: + seen = of_fact("I saw someone in a red top.", "seen", days_ago=8) + gone = of_fact("Her workshop was found open.", "gone", days_ago=1) + store.add( + gone, + memory("Kofi showed me his mural."), + seen, + of_fact("I was there.", "there_kofi", owner="kofi"), + ) + + assert store.facts("mei") == [seen, gone] + assert store.facts("ben") == [] diff --git a/server/tests/persistence/test_lifespan.py b/server/tests/persistence/test_lifespan.py index 515aca7f..2cbf32a7 100644 --- a/server/tests/persistence/test_lifespan.py +++ b/server/tests/persistence/test_lifespan.py @@ -16,7 +16,7 @@ from simile.llm.fake import scene from simile.memory import MemoryStore, memory_of from simile.news import news_of -from simile.persistence import SavedWorld, WorldStore, capture +from simile.persistence import SavedWorld, WorldStore, capture, investigation_of from simile.persistence.lifespan import FRESH_ENV from simile.reflection import SYSTEM, Deed, Extraction, Reflection from simile.schema import ( @@ -91,7 +91,9 @@ async def remember(app: Starlette) -> None: first = await run_village(timedelta(hours=1, minutes=10), after=remember) stopped = saved(database) assert stopped.now == START + timedelta(hours=1, minutes=10) - assert stopped == capture(behaviour_of(first), economy_of(first)) + assert stopped == capture( + behaviour_of(first), economy_of(first), investigation=investigation_of(first) + ) async def resumed(app: Starlette) -> None: assert world_of(app).clock.now == stopped.now diff --git a/server/tests/persistence/test_story.py b/server/tests/persistence/test_story.py index b34b6f46..559a4716 100644 --- a/server/tests/persistence/test_story.py +++ b/server/tests/persistence/test_story.py @@ -113,35 +113,40 @@ async def follow_a_lead(app: Starlette) -> None: @pytest.mark.anyio -async def test_a_save_from_before_the_investigation_starts_without_one(database: Path) -> None: +async def test_a_save_from_before_the_investigation_starts_with_an_empty_journal( + database: Path, +) -> None: save_from_before_the_player(database) async def empty(app: Starlette) -> None: - assert investigation_of(app) == Investigation() + assert investigation_of(app).journal == [] await run_village(timedelta(minutes=1), before=empty) - assert saved(database).investigation == Investigation() + assert saved(database).investigation.journal == [] @pytest.mark.anyio -async def test_a_fresh_start_has_no_investigation( +async def test_a_fresh_start_forgets_the_investigation( database: Path, monkeypatch: pytest.MonkeyPatch ) -> None: await run_village(timedelta(minutes=1), after=investigate) assert saved(database).investigation == UNDERWAY monkeypatch.setenv(FRESH_ENV, "1") - async def empty(app: Starlette) -> None: - assert investigation_of(app) == Investigation() + async def forgotten(app: Starlette) -> None: + investigation = investigation_of(app) + assert investigation.journal == [] and investigation.incident != INCIDENT - await run_village(timedelta(minutes=1), before=empty) - assert saved(database).investigation == Investigation() + await run_village(timedelta(minutes=1), before=forgotten) + assert saved(database).investigation.journal == [] + assert saved(database).investigation.incident != INCIDENT @pytest.mark.anyio -async def test_with_an_in_memory_database_the_investigation_is_empty_and_unsaved() -> None: +async def test_with_an_in_memory_database_the_investigation_starts_afresh_and_unsaved() -> None: async def check(app: Starlette) -> None: assert not hasattr(app.state, "persistence") - assert investigation_of(app) is investigation_of(app) == Investigation() + assert investigation_of(app) is investigation_of(app) + assert investigation_of(app).journal == [] await run_village(timedelta(minutes=1), before=check) diff --git a/server/tests/story/test_begin.py b/server/tests/story/test_begin.py new file mode 100644 index 00000000..3d3c63ca --- /dev/null +++ b/server/tests/story/test_begin.py @@ -0,0 +1,204 @@ +import json +import random +import sqlite3 +from collections.abc import Awaitable, Callable +from datetime import UTC, datetime, timedelta +from pathlib import Path +from types import SimpleNamespace + +import pytest +from starlette.applications import Starlette + +from simile.app import create_app +from simile.behaviour import behaviour_of +from simile.behaviour.mind import create_behaviour +from simile.casting.history import seeded +from simile.conversation import conversations_of, create_engine +from simile.memory import MemoryStore, memory_of +from simile.persistence import WorldStore, investigation_of +from simile.persistence.lifespan import FRESH_ENV +from simile.schema import Incident, Memory, Npc +from simile.sim import Clock, FakeWallClock, World, world_of +from simile.story import begin as story_begin +from simile.story.begin import begin +from simile.story.incident import ATTACK, make_incident + +pytestmark = pytest.mark.anyio + +START = datetime(2026, 1, 1, 8, tzinfo=UTC) + +type Step = Callable[[Starlette], Awaitable[None]] + + +async def nothing(app: Starlette) -> None: + pass + + +async def run_village(during: Step = nothing, minutes: float = 1) -> Starlette: + """Start every module on `SIMILE_DB`, do `during`, run for a minute and shut down.""" + app = create_app() + world = World(Clock(START), FakeWallClock()) + app.state.world = world + app.state.headless = True + app.state.behaviour = create_behaviour(app, seed=0) + app.state.conversation = create_engine(app, seed=0) + async with app.router.lifespan_context(app): + await during(app) + await world.loop.run(timedelta(minutes=minutes)) + return app + + +@pytest.fixture +def database(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + path = tmp_path / "data" / "simile.db" + monkeypatch.setenv("SIMILE_DB", str(path)) + return path + + +def saved(database: Path) -> Incident | None: + with MemoryStore(database) as memory, WorldStore(memory) as store: + world = store.load() + assert world is not None + return world.investigation.incident + + +def remembered(database: Path) -> list[Memory]: + with MemoryStore(database) as memory: + return [m for npc in ("desmond", "henrietta", "raymond") for m in memory.facts(npc)] + + +async def test_a_new_world_makes_its_incident_and_its_residents_remember_it() -> None: + async def check(app: Starlette) -> None: + incident = investigation_of(app).incident + assert incident is not None + assert conversations_of(app).incident() is incident + memory, now = memory_of(app), world_of(app).clock.now + for npc in incident.attackers: + assert ATTACK in {m.fact_id for m in memory.facts(npc)} + assert all(m.fact_id is None for m in memory.recall(npc, now, k=100)) + + await run_village(check) + + +async def test_the_incident_is_made_once_and_kept_across_restarts(database: Path) -> None: + await run_village() + first, memories = saved(database), remembered(database) + assert first is not None and memories + + await run_village() + + assert saved(database) == first + assert remembered(database) == memories + + +async def test_a_save_from_before_the_incident_gets_one_on_its_next_start( + database: Path, +) -> None: + await run_village() + with sqlite3.connect(database) as db: + [(data,)] = db.execute("SELECT data FROM saved_world").fetchall() + old = {key: value for key, value in json.loads(data).items() if key != "investigation"} + db.execute("UPDATE saved_world SET data = ?", (json.dumps(old),)) + db.execute("DELETE FROM memories WHERE json_extract(data, '$.fact_id') IS NOT NULL") + assert saved(database) is None and remembered(database) == [] + + await run_village() + made, memories = saved(database), remembered(database) + await run_village() + + assert made is not None and memories + assert saved(database) == made and remembered(database) == memories + + +async def test_a_fresh_start_makes_a_new_incident_in_place_of_the_old( + database: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + await run_village() + monkeypatch.setenv(FRESH_ENV, "1") + + await run_village() + + incident = saved(database) + assert incident is not None + knowers = [ + (k.id, fact.id) + for fact in incident.facts + for k in fact.known_by + if k.id in ("desmond", "henrietta", "raymond") + ] + assert sorted((m.owner, m.fact_id) for m in remembered(database)) == sorted(knowers) + + +class Saver: + """Persistence's saver, noting the incident and how many of its memories were stored.""" + + def __init__(self, app: Starlette) -> None: + self.app = app + self.held: list[tuple[Incident | None, int]] = [] + + async def save(self) -> None: + stored = sum(len(memory_of(self.app).facts(npc)) for npc in behaviour_of(self.app).npcs) + self.held.append((investigation_of(self.app).incident, stored)) + + +def starting(npcs: list[Npc] | None = None) -> Starlette: + """An app with a world, a store, residents and a saver, before the story begins.""" + app = Starlette() + app.state.world = World(Clock(START), FakeWallClock()) + app.state.memory = MemoryStore(":memory:") + app.state.behaviour = create_behaviour(app, seed=0, npcs=npcs) + app.state.persistence = Saver(app) + return app + + +async def test_the_memories_are_stored_before_the_incident_is_saved() -> None: + app = starting() + + incident = await begin(app) + + assert incident is not None and investigation_of(app).incident is incident + knowers = sum(len(fact.known_by) for fact in incident.facts) + assert app.state.persistence.held == [(incident, knowers)] + assert await begin(app) is None and len(app.state.persistence.held) == 1 + + +async def test_memories_left_from_an_incident_never_saved_are_forgotten_first() -> None: + app = starting() + memory = memory_of(app) + left = await make_incident(list(behaviour_of(app).npcs.values()), START, None) + memory.add(*left.memories, *(m.model_copy(update={"fact_id": None}) for m in left.memories[:2])) + + incident = await begin(app, rng=random.Random(1)) + + assert incident is not None + stored = [m for npc in behaviour_of(app).npcs for m in memory.facts(npc)] + assert len(stored) == sum(len(fact.known_by) for fact in incident.facts) + everyday = [m for npc in behaviour_of(app).npcs for m in memory.recall(npc, START, k=100)] + assert len(everyday) == 2 + + +async def test_a_world_with_no_residents_has_no_incident() -> None: + app = starting(npcs=[]) + + assert await begin(app) is None + assert investigation_of(app).incident is None + assert app.state.persistence.held == [] + + +async def test_a_cast_world_draws_its_incident_from_the_casts_seed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(story_begin, "load_cast", lambda: SimpleNamespace(seed=42)) + first, second = starting(), starting() + + assert await begin(first) == await begin(second) + + +async def test_casting_never_takes_the_incidents_memories_for_a_village_that_ran() -> None: + app = starting() + + incident = await begin(app) + + assert incident is not None + residents = list(behaviour_of(app).npcs) + assert not seeded(memory_of(app), residents, START) diff --git a/server/tests/story/test_content.py b/server/tests/story/test_content.py new file mode 100644 index 00000000..55e39149 --- /dev/null +++ b/server/tests/story/test_content.py @@ -0,0 +1,45 @@ +from simile.npc import load_npcs +from simile.schema import Accessory, HairStyle +from simile.story import DOOR, NOTES, load_story +from simile.story.looks import figure +from simile.world.map import load_maps, with_every_location + +STORY = load_story() +MAPS = load_maps() +NPCS = {npc.id: npc for npc in load_npcs()} + + +def test_the_clues_lie_on_cells_you_can_stand_on_in_her_workshop() -> None: + workshop = with_every_location(MAPS).location(STORY.aris.workshop) + + assert {clue.id for clue in STORY.clues} == {NOTES, DOOR} + for clue in STORY.clues: + floor = MAPS[clue.map] + assert floor.building == workshop.id and floor.floor == 0, clue.id + assert floor.walkable(clue.x, clue.y), clue.id + assert STORY.clue(NOTES).pages + + +def test_the_places_named_are_on_the_maps() -> None: + town = with_every_location(MAPS) + + assert STORY.aris.street in MAPS + for place in STORY.meeting_places: + assert town.location(place.id) + + +def test_every_witness_who_might_lie_has_a_cover_story() -> None: + assert set(STORY.texts.covers) == {"shouting", "seen", "grudge", "meeting"} + + +def test_a_figure_is_what_you_could_make_out_in_a_dark_lane() -> None: + raymond = NPCS["raymond"].appearance + capped = raymond.model_copy(update={"accessories": [Accessory.CAP]}) + bald = raymond.model_copy(update={"hair_style": HairStyle.BALD, "accessories": []}) + packed = raymond.model_copy(update={"accessories": [Accessory.APRON, Accessory.BACKPACK]}) + + assert figure(raymond) == "a grey-haired figure in a red top and sunglasses" + assert figure(capped) == "a figure in a red top and a cap" + assert figure(bald) == "a bald figure in a red top" + assert figure(packed) == "a grey-haired figure in a red top, with a backpack" + assert figure(NPCS["kofi"].appearance) == "a dark-haired figure in an amber top and headphones" diff --git a/server/tests/story/test_incident.py b/server/tests/story/test_incident.py new file mode 100644 index 00000000..06168e6d --- /dev/null +++ b/server/tests/story/test_incident.py @@ -0,0 +1,151 @@ +import asyncio +import random +from collections import Counter +from collections.abc import Sequence +from datetime import UTC, datetime + +import pytest + +from simile.conversation.telling import Stance, tellings +from simile.npc import load_npcs +from simile.schema import CoreValue, HowKnown, Npc +from simile.social import meet +from simile.story import DEFAULT_CONFIG, DOOR, NOTES, Made, load_story, make_incident +from simile.story.incident import ATTACK, CLOTH, GONE, MEETING, NOTED, SEEN, SHOUTING +from simile.story.suspects import hostility + +NOW = datetime(2026, 1, 9, 8, tzinfo=UTC) +NPCS = load_npcs() +GRIEVANCES = load_story().grievances +SEEDS = range(120) + + +def made(seed: int, npcs: Sequence[Npc] = NPCS) -> Made: + return asyncio.run(make_incident(npcs, NOW, None, rng=random.Random(seed))) + + +def village(seed: int) -> list[Npc]: + """Two to eleven of today's residents, with values drawn at random.""" + rng = random.Random(seed) + chosen = rng.sample(NPCS, rng.randint(2, len(NPCS))) + return [ + npc.model_copy(update={"values": {value: rng.random() for value in CoreValue}}) + for npc in chosen + ] + + +WORLDS = [made(seed) for seed in SEEDS] +VILLAGES = [village(seed) for seed in SEEDS] +ELSEWHERE = [made(seed, npcs) for seed, npcs in zip(SEEDS, VILLAGES, strict=True)] + + +@pytest.mark.parametrize( + ("world", "npcs"), [*((w, NPCS) for w in WORLDS), *zip(ELSEWHERE, VILLAGES, strict=True)] +) +def test_one_to_three_attackers_whose_values_fit(world: Made, npcs: list[Npc]) -> None: + incident = world.incident + scores = {npc.id: hostility(npc, GRIEVANCES) for npc in npcs} + + assert 1 <= len(incident.attackers) <= 3 + fit = [scores[npc] >= DEFAULT_CONFIG.hostile_from for npc in incident.attackers] + most = max(scores, key=lambda npc: scores[npc]) + assert all(fit) or incident.attackers == [most] + + +def test_todays_village_gets_one_two_and_three_attackers_and_varied_ones() -> None: + counts = Counter(len(world.incident.attackers) for world in WORLDS) + who = Counter(npc for world in WORLDS for npc in world.incident.attackers) + + assert set(counts) == {1, 2, 3} + assert {"henrietta", "raymond", "marguerite", "priya"} <= set(who) + + +@pytest.mark.parametrize("world", [*WORLDS, *ELSEWHERE]) +def test_the_attackers_know_what_they_did_and_have_a_cover_story_for_it(world: Made) -> None: + incident = world.incident + by_id = {fact.id: fact for fact in incident.facts} + + for npc in incident.attackers: + for fact_id in (ATTACK, f"there_{npc}", f"grudge_{npc}"): + [knower] = [k for k in by_id[fact_id].known_by if k.id == npc] + assert knower.how is HowKnown.DID + assert knower.cover + + +@pytest.mark.parametrize("world", [*WORLDS, *ELSEWHERE]) +def test_every_fact_known_is_in_its_knowers_memories(world: Made) -> None: + remembered = Counter((memory.owner, memory.fact_id) for memory in world.memories) + known = Counter( + (knower.id, fact.id) for fact in world.incident.facts for knower in fact.known_by + ) + + assert remembered == known + for npc in world.incident.attackers: + assert (npc, ATTACK) in remembered + assert all(memory.timestamp < NOW for memory in world.memories) + + +@pytest.mark.parametrize("world", WORLDS) +def test_witnesses_saw_parts_of_the_night(world: Made) -> None: + incident = world.incident + by_id = {fact.id: fact for fact in incident.facts} + attackers = set(incident.attackers) + + for fact_id in (SHOUTING, SEEN, *(f"grudge_{npc}" for npc in attackers)): + witnesses = [k for k in by_id[fact_id].known_by if k.how is HowKnown.WITNESSED] + assert witnesses and not {k.id for k in witnesses} & attackers, fact_id + assert (MEETING in by_id) == (len(attackers) > 1) + assert set(by_id[SEEN].about) == attackers + assert by_id[NOTED].found_in == NOTES and by_id[CLOTH].found_in == DOOR + assert {k.id for k in by_id[GONE].known_by} == {npc.id for npc in NPCS} + + +@pytest.mark.parametrize("world", [*WORLDS, *ELSEWHERE]) +def test_the_trail_leads_from_the_clues_to_every_attacker(world: Made) -> None: + incident = world.incident + leads = {fact.id: [lead.fact_id for lead in fact.leads] for fact in incident.facts} + reached, frontier = set(), [NOTED, CLOTH, GONE] + while frontier: + fact_id = frontier.pop() + if fact_id not in reached: + reached.add(fact_id) + frontier += leads[fact_id] + + assert {f"there_{npc}" for npc in incident.attackers} <= reached + + +@pytest.mark.parametrize("world", WORLDS) +def test_the_night_is_a_week_before_the_world_starts(world: Made) -> None: + incident = world.incident + by_id = {fact.id: fact for fact in incident.facts} + + assert incident.night.date() == (NOW - DEFAULT_CONFIG.night_before).date() + assert incident.night.hour == 23 + assert incident.night < by_id[GONE].when < NOW + assert all(fact.when < NOW for fact in incident.facts) + + +def test_lies_are_decided_for_the_attackers_by_their_trust_in_whoever_asks() -> None: + npcs = {npc.id: npc for npc in NPCS} + for world in WORLDS: + incident = world.incident + memories = {npc: [m for m in world.memories if m.owner == npc] for npc in npcs} + for npc in incident.attackers: + stranger = meet(npcs[npc], "player").trust + told = {t.fact.id: t for t in tellings(incident, npc, stranger, memories[npc])} + for fact_id in (ATTACK, f"there_{npc}", f"grudge_{npc}"): + assert told[fact_id].stance is Stance.LIE + lie = told[fact_id].temptation() + assert lie.fact_id == fact_id + assert told[fact_id].knower.cover in lie.direction() + assert told[fact_id].remembers not in lie.direction() + assert told[GONE].stance is Stance.TELL + trusted = {t.fact.id: t.stance for t in tellings(incident, npc, 0.8)} + assert trusted[f"grudge_{npc}"] is Stance.TELL + assert trusted[ATTACK] is Stance.LIE + + +def test_the_same_seed_makes_the_same_incident() -> None: + assert made(7).incident == made(7).incident + assert made(7).memories == made(7).memories + assert made(7).incident != made(8).incident diff --git a/server/tests/story/test_suspects.py b/server/tests/story/test_suspects.py new file mode 100644 index 00000000..8cfd7ac9 --- /dev/null +++ b/server/tests/story/test_suspects.py @@ -0,0 +1,66 @@ +import random +from collections import Counter + +from simile.npc import load_npcs +from simile.schema import CoreValue, Npc +from simile.story import DEFAULT_CONFIG, load_story +from simile.story.suspects import attackers, fit, hostility, suspect + +GRIEVANCES = load_story().grievances +TEMPLATE = load_npcs()[0] + + +def resident(id: str, **values: float) -> Npc: + scores = {value: values.get(value.value, 0.5) for value in CoreValue} + return TEMPLATE.model_copy(update={"id": id, "name": f"{id.title()} Test", "values": scores}) + + +STERN = resident("stern", obedience=1.0, discipline=1.0, compassion=0.0, freedom=0.0) +KIND = resident("kind", compassion=1.0, open_mindedness=1.0, obedience=0.0, success=0.0) + + +def test_rules_rankle_with_the_obedient_and_a_hard_heart_makes_them_hostile() -> None: + assert fit(STERN, GRIEVANCES["rules"]) > 0.4 + assert hostility(STERN, GRIEVANCES) > 0.9 + assert hostility(KIND, GRIEVANCES) < 0 + + +def test_a_suspects_grievance_is_among_those_that_fit_them_best() -> None: + fits = {kind: fit(STERN, grievance) for kind, grievance in GRIEVANCES.items()} + + drawn = {suspect(STERN, GRIEVANCES, random.Random(seed)).grievance for seed in range(50)} + + assert drawn + assert all(fits[kind] >= max(fits.values()) - DEFAULT_CONFIG.grievance_spread for kind in drawn) + + +def test_one_to_three_hostile_residents_attack_in_every_world() -> None: + npcs = load_npcs() + counts: Counter[int] = Counter() + who: Counter[str] = Counter() + + for seed in range(300): + found = attackers(npcs, GRIEVANCES, random.Random(seed)) + counts[len(found)] += 1 + who.update(attacker.npc.id for attacker in found) + assert len({attacker.npc.id for attacker in found}) == len(found) + assert all(attacker.hostility >= DEFAULT_CONFIG.hostile_from for attacker in found) + + assert set(counts) == {1, 2, 3} + assert len(who) >= 3 + assert not {"desmond", "kofi", "mirela", "odalys", "tobias"} & set(who) + + +def test_with_nobody_hostile_the_most_hostile_attacks_alone() -> None: + gentle = [KIND, resident("mild", compassion=0.8), resident("calm", compassion=0.9)] + + found = attackers(gentle, GRIEVANCES, random.Random(1)) + + assert [attacker.npc.id for attacker in found] == ["mild"] + + +def test_there_are_never_more_attackers_than_hostile_residents() -> None: + village = [STERN, KIND, resident("mild", compassion=0.8)] + + for seed in range(40): + assert [a.npc.id for a in attackers(village, GRIEVANCES, random.Random(seed))] == ["stern"] diff --git a/server/tests/story/test_writing.py b/server/tests/story/test_writing.py new file mode 100644 index 00000000..25faf01c --- /dev/null +++ b/server/tests/story/test_writing.py @@ -0,0 +1,102 @@ +import random + +import pytest + +from simile.llm import CallRecord, FakeProvider, LLMClient, LLMError, Model +from simile.npc import load_npcs +from simile.story import load_story +from simile.story.suspects import Suspect, suspect +from simile.story.writing import PURPOSE, Backstory, Written, drafted, write + +pytestmark = pytest.mark.anyio + +STORY = load_story() +NPCS = {npc.id: npc for npc in load_npcs()} + + +def suspects(*ids: str) -> list[Suspect]: + rng = random.Random(0) + return [suspect(NPCS[npc], STORY.grievances, rng) for npc in ids] + + +def side(npc: str, alibi: str = "I was at the lodge till late.") -> Written: + return Written( + id=npc, + grievance=f"{npc.title()} lost a contract.", + resents="She cost me that contract.", + remembers="I went round to make her listen, and it got out of hand.", + alibi=alibi, + deflection="Kids from the city, I'd say.", + denial="We were on good terms.", + note=f"{npc[0].upper()}. came about the contract again.", + ) + + +async def test_sonnet_writes_the_motive_and_each_attackers_side_once() -> None: + found = suspects("henrietta", "raymond") + written = Backstory( + motive="They wanted her gone.", attackers=[side("henrietta"), side("raymond")] + ) + provider, records = FakeProvider([written]), list[CallRecord]() + + llm = LLMClient(provider, listeners=[records.append]) + + backstory, wrote = await write(llm, found, STORY, met=STORY.meeting_places[0]) + + assert wrote and backstory == written + [request], [record] = provider.requests, records + assert (request.model, record.purpose) == (Model.SONNET, PURPOSE) + prompt = request.messages[-1].content + for attacker in found: + assert attacker.npc.name in prompt + assert STORY.grievances[attacker.grievance].told in prompt + assert "The 2 of them went together. They met at the noodle bar the evening before" in prompt + assert "Their initial: H. What Aris did:" in prompt + assert '"motive"' in request.system + assert "it names nobody, accuses nobody and hints at no one" in request.system + assert "points at nobody: no resident, stranger or outsider" in request.system + assert "residents:" not in prompt + + +async def test_an_attacker_the_reply_leaves_out_is_written_from_templates() -> None: + found = suspects("henrietta", "raymond") + provider = FakeProvider([Backstory(motive="Spite.", attackers=[side("raymond")])]) + + backstory, wrote = await write(LLMClient(provider), found, STORY) + + templates = drafted(found, STORY) + assert wrote and backstory.motive == "Spite." + assert backstory.attackers == [templates.attackers[0], side("raymond")] + + +@pytest.mark.parametrize( + "replies", + [[LLMError("usage limit reached")], ["Morning!", "Still not JSON."], [TimeoutError()]], +) +async def test_without_a_reply_it_falls_back_to_the_templates(replies: list[object]) -> None: + found = suspects("raymond") + + backstory, wrote = await write(LLMClient(FakeProvider(replies)), found, STORY) + + assert not wrote and backstory == drafted(found, STORY) + + +async def test_without_an_llm_it_never_asks() -> None: + found = suspects("priya") + + assert await write(None, found, STORY) == (drafted(found, STORY), False) + + +def test_the_templates_name_each_attacker_and_bring_them_together() -> None: + found = suspects("henrietta", "raymond", "priya") + + backstory = drafted(found, STORY) + + for attacker, written in zip(found, backstory.attackers, strict=True): + name = attacker.npc.name.split()[0] + assert written.id == attacker.npc.id + assert name in written.grievance and written.grievance in backstory.motive + assert written.note.startswith(f"{name[0]}.") + assert "{" not in written.remembers + written.note + assert backstory.motive.endswith(STORY.texts.together) + assert "with Raymond and Priya" in backstory.attackers[0].remembers