From f669fd4c2ae71a476190ad96d98010df1332d1bf Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Fri, 23 Oct 2026 05:42:04 +1300 Subject: [PATCH 1/2] S11: The village reacts Each resident gets a tolerance for bionics from their values: Tradition and Security count against, Adventure, Ambition and Open-mindedness for. Seeing a bionic on the player for the first time is an R1 event weighted by it, and a memory that records it, so each resident counts each bionic once and word of it spreads through the memories residents already bring to scenes. What a resident can see of the player starts B5's prompt. Uneasy residents keep away from the player through B1, don't greet them, and turn them down when spoken to, except at work. A whole player changes nothing. Co-Authored-By: Claude Opus 5.5 --- server/simile/behaviour/README.md | 8 + server/simile/behaviour/__init__.py | 2 + server/simile/behaviour/config.py | 5 + server/simile/behaviour/mind.py | 20 ++- server/simile/behaviour/scoring.py | 8 + server/simile/conversation/README.md | 21 ++- server/simile/conversation/config.py | 5 + server/simile/conversation/engagement.py | 3 + server/simile/conversation/engine.py | 4 + server/simile/conversation/lifespan.py | 2 + server/simile/conversation/player.py | 38 +++-- server/simile/conversation/prompt.py | 8 +- server/simile/social/README.md | 46 +++++- server/simile/social/__init__.py | 11 +- server/simile/social/bionics.py | 138 ++++++++++++++++ server/simile/social/config.py | 78 ++++++++- server/simile/social/lifespan.py | 20 +++ server/simile/social/sightings.py | 147 +++++++++++++++++ server/tests/behaviour/test_unease.py | 151 +++++++++++++++++ server/tests/conversation/test_bionics.py | 190 ++++++++++++++++++++++ server/tests/social/test_bionics.py | 123 ++++++++++++++ server/tests/social/test_lifespan.py | 53 ++++++ server/tests/social/test_sightings.py | 155 ++++++++++++++++++ 23 files changed, 1214 insertions(+), 22 deletions(-) create mode 100644 server/simile/social/bionics.py create mode 100644 server/simile/social/lifespan.py create mode 100644 server/simile/social/sightings.py create mode 100644 server/tests/behaviour/test_unease.py create mode 100644 server/tests/conversation/test_bionics.py create mode 100644 server/tests/social/test_bionics.py create mode 100644 server/tests/social/test_lifespan.py create mode 100644 server/tests/social/test_sightings.py diff --git a/server/simile/behaviour/README.md b/server/simile/behaviour/README.md index 1ba9fbd2..3edf8143 100644 --- a/server/simile/behaviour/README.md +++ b/server/simile/behaviour/README.md @@ -126,6 +126,12 @@ sets F3's snapshot to the NPCs' real positions. Connection. A place scores the affinity of everyone at or heading to it: friends attract, people they dislike repel, and a crowd draws high-Connection NPCs and puts off low ones. Nobody seeks someone asleep, at home, talking, out strolling or who refused them in the last 45 minutes. +- **The player's bionics.** The `unease` hook (S11's sightings) says how much what a resident has + seen of the player's bionics puts them off, 0 to 1. It takes `unease_weight` (1) times that off + their affinity for the player, so they stay away from where the player is, leave when the player + arrives, and don't seek them out. An uneasy resident also won't go into a place the player is in + to seek someone else there: that seek loses `avoid_weight` times their affinity for the player, + as the place would. Without bionics it's 0, and nothing changes. - **Intentions.** What a resident said they'd do (an `Intention`, which B3 extracts from conversations) is one of these options: make something (`create`), go to a place (`go`), look someone up (`seek`) or read the news (`news`). From when it's due until it's done or expires, @@ -234,6 +240,7 @@ restore() | `money(npc)` | With the `Npc` every think: what they hold, or `None` | `None`, so finances stay at 0 | | `price(npc, goal)` | With the `Npc` for each option: what it would cost them | 0 | | `earnings(npc, goal)` | With the `Npc` for staying on at work: what an hour pays | 0 | +| `unease(npc)` | With the `Npc` at each decision while the player's in the world: how much the player's bionics put them off, 0 to 1 | 0 | Hooks run inside a tick, so they mustn't block. One that raises is logged and counts as a decline. @@ -253,6 +260,7 @@ conversation engine calls `resume` on each. Otherwise the seeker hangs around fo | `at_home(npc)` | Whether they're at or heading home, to rest, sleep or make something there. | | `working(npc)` | Whether they're at their workplace working: there for work, or held there with a goal at it. | | `savings(npc)` | Their savings target, in money. | +| `unease(npc)` | The `unease` hook's answer, clamped to 0 to 1, or 0 if it fails. B2 reads it too. | | `restore(npc, goal, needs, since, until, cooldowns)` | Puts back a saved NPC (I3) after the clock is set: stops their walk and publishes one `GoalChosen`. With no `until` they decide on the next think. Everyone's needs rise from now. | | `goal(npc)` | Their `Goal`: `activity` and `target`, a location, entity or, for a mural, wall id. | | `needs(npc)` | `{Need: 0-1}`. | diff --git a/server/simile/behaviour/__init__.py b/server/simile/behaviour/__init__.py index 5360da3d..873d13c8 100644 --- a/server/simile/behaviour/__init__.py +++ b/server/simile/behaviour/__init__.py @@ -9,6 +9,7 @@ Mind, NewsInterest, NewsReader, + Unease, WallPicker, WorkMaker, behaviour_of, @@ -46,6 +47,7 @@ "Places", "Promise", "Situation", + "Unease", "WallPicker", "WorkMaker", "behaviour_of", diff --git a/server/simile/behaviour/config.py b/server/simile/behaviour/config.py index e14b346c..a3246423 100644 --- a/server/simile/behaviour/config.py +++ b/server/simile/behaviour/config.py @@ -333,6 +333,11 @@ class BehaviourConfig(BaseModel): ge=0, description="Score per unit of negative affinity for each person at a place.", ) + unease_weight: float = Field( + default=1.0, + ge=0, + description="Affinity for the player lost per unit of the `unease` their bionics cause.", + ) crowd_weight: float = Field( default=0.2, description="Score per person at a place, per unit of `connection - 0.5`." ) diff --git a/server/simile/behaviour/mind.py b/server/simile/behaviour/mind.py index 12b1119e..18b7e432 100644 --- a/server/simile/behaviour/mind.py +++ b/server/simile/behaviour/mind.py @@ -88,6 +88,7 @@ type WallPicker = Callable[[Npc], MuralSpot | None] type Purse = Callable[[Npc], float | None] type Pricing = Callable[[Npc, Goal], float] +type Unease = Callable[[Npc], float] NEUTRAL = Traits() AT_HOME = (Activity.HOME, Activity.SLEEP) @@ -143,6 +144,10 @@ def nothing(npc: Npc, goal: Goal) -> float: return 0.0 +def at_ease(npc: Npc) -> float: + return 0.0 + + class Hooks: """The interfaces other modules implement, stubbed until they do. See the README.""" @@ -157,6 +162,7 @@ def __init__(self) -> None: self.money: Purse = no_money self.price: Pricing = nothing self.earnings: Pricing = nothing + self.unease: Unease = at_ease def set( self, @@ -171,6 +177,7 @@ def set( money: Purse | None = None, price: Pricing | None = None, earnings: Pricing | None = None, + unease: Unease | None = None, ) -> Callable[[], None]: """Plug in the hooks given and return a function that puts back what they replaced.""" given = { @@ -184,6 +191,7 @@ def set( "money": money, "price": price, "earnings": earnings, + "unease": unease, } hooks = {name: hook for name, hook in given.items() if hook is not None} previous = {name: getattr(self, name) for name in hooks} @@ -599,6 +607,11 @@ def savings(self, npc: Id) -> float: """Their savings target, in money.""" return savings(self.npcs[npc].values, self.config) + def unease(self, npc: Id) -> float: + """How much the player's bionics put them off, 0 to 1, from the `unease` hook.""" + level = self._call(self.hooks.unease, self.npcs[npc]) + return _clamp(level) if isinstance(level, int | float) else 0.0 + def goal(self, npc: Id) -> Goal: return self.minds[npc].goal @@ -1016,13 +1029,18 @@ def _situation(self, mind: Mind) -> Situation: if other == npc.id or (other not in self.minds and other != PLAYER_ID): continue rel = as_of(known.get(other) or meet(npc, other), now, traits=self.traits_of(npc.id)) + fondness = affinity(rel, self.config) + uneasy = self.unease(npc.id) if other == PLAYER_ID else 0.0 + if uneasy: + fondness -= self.config.unease_weight * uneasy others.append( Other( other, position, - affinity(rel, self.config), + fondness, self._place_of(other, position), self._available(mind, other), + shunned=bool(uneasy), ) ) news = self._call(self.hooks.news_interest, npc) diff --git a/server/simile/behaviour/scoring.py b/server/simile/behaviour/scoring.py index 54e52510..ff214f29 100644 --- a/server/simile/behaviour/scoring.py +++ b/server/simile/behaviour/scoring.py @@ -150,6 +150,8 @@ class Other: """The location they're at or heading for.""" available: bool = True """Whether they can be sought out: not asleep, at home, talking or recently refused.""" + shunned: bool = False + """Whether the owner keeps away from wherever they are, even to seek someone there.""" @dataclass(frozen=True) @@ -335,6 +337,12 @@ def pull(goal: Goal, situation: Situation, places: Places, config: BehaviourConf if activity is Activity.SEEK: other = next(o for o in situation.others if o.id == goal.target) total += config.seek_weight * sociability(values, config) * other.affinity + if other.place is not None: + total += config.avoid_weight * sum( + min(o.affinity, 0.0) + for o in situation.others + if o.shunned and o is not other and o.place == other.place + ) elif at_a_place(goal, situation): assert goal.target is not None location = places.location(goal.target) diff --git a/server/simile/conversation/README.md b/server/simile/conversation/README.md index 1eeeb697..abdef84a 100644 --- a/server/simile/conversation/README.md +++ b/server/simile/conversation/README.md @@ -114,7 +114,7 @@ as in *Works* above. Nothing the player has read or seen reaches an NPC's prompt with the player as `player_relief` (half) of one with a resident towards their social need. - **Starting.** `player_say` to an NPC within `say_range` (4 tiles) starts a conversation, unless - they're asleep. One talking to another resident is pulled out of it (see *Interruptions*) and + they're asleep. One put off by the player's bionics turns them down (see *The player's bionics*). One talking to another resident is pulled out of it (see *Interruptions*) and answers once that conversation has ended and their goodbye's audio has played. From then on, the player's lines reach the NPC for as long as the conversation lasts. Saying something to a different NPC who is awake ends the one going on: the old partner says a short canned goodbye, and the new resident's first line waits until it ends. @@ -302,6 +302,25 @@ at the saved investigation, is the incident. 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. +## The player's bionics + +S11 ([`simile.social`](../social/README.md#bionics)) decides how residents take to the player's +bionics. The lifespan points `engine.looks` at its sightings. + +- **What they see.** The first paragraph of each reply's background ends with what the resident + can see of the player: each bionic fitted, which are new to them, and how they feel about + bionics ("Visitor has an infrared eye that glows faintly red in place of one of their own. The + infrared eye is new since you last saw them. Bionics make you uneasy."). A whole player gets + nothing, and the prompt is as it was. Scenes between residents have no player to see: word of a + sighting reaches them through the memory it leaves, on the sighter's mind like any other. +- **Keeping their distance.** B1's `unease` comes off the accept score for the player, + `accept_unease` (2) times it, so an uneasy resident doesn't greet them. One the player talks to + turns them down when that score declines, as it would an NPC's request: their one reply is cued + "You'd rather not talk to Visitor. Turn them down in a few words, and leave it there.", and the + conversation ends as after a goodbye. Only someone uneasy turns the player down, never at work, + and someone talking with another resident doesn't break off to do it: the player gets no answer. + A lower trust from a sighting also makes them tell less of the incident (*Telling* above). + ## 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/config.py b/server/simile/conversation/config.py index b0972b7f..d443daf4 100644 --- a/server/simile/conversation/config.py +++ b/server/simile/conversation/config.py @@ -65,6 +65,11 @@ class ConversationConfig(BaseModel): description="Taken off the accept score while one of the target's needs is at or past " "B1's critical level for it: enough to decline even a friend they were looking for.", ) + accept_unease: float = Field( + default=2.0, + ge=0, + description="Taken off the accept score per unit of B1's `unease` at the player's bionics.", + ) accept_temperature: float = Field( default=0.1, ge=0, diff --git a/server/simile/conversation/engagement.py b/server/simile/conversation/engagement.py index 18e029fc..00e838f5 100644 --- a/server/simile/conversation/engagement.py +++ b/server/simile/conversation/engagement.py @@ -17,11 +17,14 @@ def acceptance( config: ConversationConfig = DEFAULT_CONFIG, *, urgent: bool = False, + unease: float = 0.0, ) -> float: """How keen `rel.owner` is to talk when `rel.about` walks up: positive to accept. Someone with an `urgent` need declines.""" weights = config.accept_weights score = config.accept_base - (config.accept_urgent if urgent else 0.0) + if unease: + score -= config.accept_unease * unease score += sum(weights.get(aspect, 0.0) * (getattr(rel, aspect) - 0.5) for aspect in ASPECTS) score += weights.get("familiarity", 0.0) * rel.familiarity score += sum(w * (values[value] - 0.5) for value, w in config.accept_values.items()) diff --git a/server/simile/conversation/engine.py b/server/simile/conversation/engine.py index b999383c..596a63f7 100644 --- a/server/simile/conversation/engine.py +++ b/server/simile/conversation/engine.py @@ -236,6 +236,8 @@ def __init__( """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.looks: Callable[[Id], str | None] = lambda _: None + """S11's line on what a resident can see of the player, or None for nothing to see.""" self._talks: dict[str, Talk] = {} self._loans: dict[str, Loan] = {} self._lies: dict[str, list[Lie]] = {} @@ -270,6 +272,7 @@ def accept_score(self, a: Id, b: Id) -> float: self.behaviour.needs(b)[Need.SOCIAL], self.config, urgent=self.pressing(b).urgent, + unease=self.behaviour.unease(b) if a == PLAYER_ID else 0.0, ) def pressing(self, npc: Id) -> Pressing: @@ -723,6 +726,7 @@ def _party(self, npc: Id, other: Id, direction: Direction) -> Party: location.name if location else None, now, self._plans(npc), + looks=self.looks(npc) if other == PLAYER_ID else None, ), works=works, topics=topics, diff --git a/server/simile/conversation/lifespan.py b/server/simile/conversation/lifespan.py index 96852e70..191709f7 100644 --- a/server/simile/conversation/lifespan.py +++ b/server/simile/conversation/lifespan.py @@ -17,6 +17,7 @@ async def lifespan(app: Starlette) -> AsyncIterator[None]: from simile.protocol import PlayerSayMessage, hub_of from simile.schema import Id, NewsItem, Work from simile.sim import world_of + from simile.social.sightings import sightings_of from simile.works import works_of engine, hub, loop = conversations_of(app), hub_of(app), world_of(app).loop @@ -38,6 +39,7 @@ def missed(npc: Id) -> list[Missed]: engine.player_request, engine.article, engine.work = players.request, article, work engine.missed = missed engine.incident = lambda: investigation_of(app).incident + engine.looks = lambda npc: sightings_of(app).looks(npc) 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 5d2d3786..70534600 100644 --- a/server/simile/conversation/player.py +++ b/server/simile/conversation/player.py @@ -11,8 +11,8 @@ from pydantic import BaseModel, ConfigDict, Field -from simile.behaviour import Activity, Need -from simile.conversation.engagement import acceptance, accepts, engage, leaving +from simile.behaviour import Activity +from simile.conversation.engagement import accepts, engage, leaving from simile.conversation.engine import PLAYER, ConversationEngine, Talk, facing from simile.conversation.events import Direction, Facing, LineDrafted from simile.conversation.prompt import ( @@ -200,6 +200,8 @@ class PlayerTalk(Talk): """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.""" + refusing: bool = False + """Whether the NPC turns the player down, put off by their bionics.""" @property def npc(self) -> Id: @@ -258,12 +260,17 @@ def say(self, text: str, target: Id) -> Line | None: if not self._free(target, interrupting=True): self.engine._log("%s is busy, so the player gets no answer", target) return None + refusing = self._refuses(target) + if refusing and self.engine.talking(target): + self.engine._log("%s won't break off to talk to the player", target) + return None left = self.engine._talk_of(target) goodbyes = [ self._turned_away(talk, target) if talk is not None else None, self.engine.cut(target, PLAYER), ] joined = self._start(target, opener=PLAYER_ID) + joined.refusing = refusing joined.quiet_until = max([joined.quiet_until, *filter(None, goodbyes)]) if left is not None and left.conversation.id in self.engine._talks: joined.pulled_from = left.conversation.id @@ -342,6 +349,8 @@ def _hear(self, talk: PlayerTalk, text: str) -> Line: talk.turns += 1 if says_goodbye(text): self._write(talk, Cue.FAREWELL) + elif talk.refusing: + self._write(talk, Cue.REFUSE) elif leaving(party.engagement, talk.turns, engine.config): engine._log( "%s is leaving %s (engagement %.2f)", @@ -379,8 +388,9 @@ async def _reply(self, talk: PlayerTalk, cue: Cue, lines: Sequence[Line]) -> Dra return sentences.draft pressed = engine.pressing(npc) recalled = self._recalled(npc, lines) - self._tempt(talk, lines) - self._ask(talk, lines) + if not talk.refusing: + 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) @@ -553,8 +563,8 @@ def _replied(self, talk: PlayerTalk, task: asyncio.Task[Draft]) -> None: party.engagement, party.npc.values, party.like, talk.topics, engine.config, drain=drain ) talk.turns += 1 - if talk.cue in (Cue.GOODBYE, Cue.FAREWELL): - wait = self.config.farewell_wait if talk.cue is Cue.GOODBYE else 0 + if talk.cue in (Cue.GOODBYE, Cue.FAREWELL, Cue.REFUSE): + wait = self.config.farewell_wait if talk.cue is not Cue.FAREWELL else 0 talk.closing, talk.ends = True, talk.free_at + timedelta(seconds=wait) else: talk.speaker = PLAYER_ID @@ -621,18 +631,18 @@ def _greets(self, npc: Id, now: datetime) -> bool: if max(self._considered.get(npc, now), mind.cooldowns.get(PLAYER_ID, now)) > now: return False self._considered[npc] = now + timedelta(minutes=self.config.greet_cooldown) - score = acceptance( - mind.npc.values, - engine._relationship(npc, PLAYER_ID), - mind.goal, - mind.needs[Need.SOCIAL], - engine.config, - urgent=engine.pressing(npc).urgent, - ) + score = engine.accept_score(PLAYER_ID, npc) greets = accepts(score - self.config.greet_bar, self.rng, engine.config) engine._log("%s %s the player (score %.2f)", npc, "greets" if greets else "ignores", score) return greets + def _refuses(self, npc: Id) -> bool: + """Whether the player's bionics put `npc` off talking: only the uneasy, and not at work.""" + behaviour = self.engine.behaviour + if not behaviour.unease(npc) or behaviour.working(npc): + return False + return not accepts(self.engine.accept_score(PLAYER_ID, npc), self.rng, self.engine.config) + def _player_free(self) -> bool: return self.connected() and self._distance_known() and not self.engine.talking(PLAYER_ID) diff --git a/server/simile/conversation/prompt.py b/server/simile/conversation/prompt.py index 44c5ddcb..879920ab 100644 --- a/server/simile/conversation/prompt.py +++ b/server/simile/conversation/prompt.py @@ -33,6 +33,7 @@ class Cue(StrEnum): REPLY = "reply" GOODBYE = "goodbye" FAREWELL = "farewell" + REFUSE = "refuse" SETTING_OFF = "If you've said you'll go and do something now, say where you're off to." @@ -43,6 +44,8 @@ class Cue(StrEnum): Cue.GOODBYE: "You've had enough of this conversation. Wrap it up now with a brief, natural " f"goodbye to {{other}}. {SETTING_OFF}", Cue.FAREWELL: f"{{other}} is leaving. Say a brief goodbye back. {SETTING_OFF}", + Cue.REFUSE: "You'd rather not talk to {other}. Turn them down in a few words, and leave it " + "there.", } JOKE = "Work a joke into this line, in your own humour." PLAIN = "No joke in this line: say it plainly." @@ -239,6 +242,8 @@ def background( place: str | None, now: datetime, plans: str = "", + *, + looks: str | None = None, ) -> str: """What the speaker knows going in: the other person, their history, what they could plan to do and have said they will, and what's on their mind.""" @@ -249,7 +254,8 @@ def background( if isinstance(other, Player) else f"{other.name}, aged {other.age}, {other.occupation}" ) - parts = [f"It's {now:%H:%M}, {where}. You're with {name} ({who}).", describe(rel, name)] + meeting = f"It's {now:%H:%M}, {where}. You're with {name} ({who})." + parts = [f"{meeting} {looks}" if looks else meeting, describe(rel, name)] events = [ f"- {ago(event.timestamp, now)}, {'you' if event.actor == speaker.id else name}: " f"{event.action}" diff --git a/server/simile/social/README.md b/server/simile/social/README.md index 377c9393..cbd1c1b6 100644 --- a/server/simile/social/README.md +++ b/server/simile/social/README.md @@ -1,9 +1,9 @@ # simile.social R1's relationship model: how one NPC feels about another, as pure functions over the -`Relationship` in `simile.schema.social`. Nothing here stores anything or calls Claude. R2 keeps -the relationships, B3 applies each conversation's events, B1 and B2 read the scores, and B2's -prompts describe them. +`Relationship` in `simile.schema.social`. R2 keeps the relationships, B3 applies each +conversation's events, B1 and B2 read the scores, and B2's prompts describe them. Nothing here +calls Claude, and only S11's sightings (*Bionics* below) store anything, through R2. Each direction is its own `Relationship`: `like`, `trust` and `respect` from 0 to 1 with neutral at 0.5, `familiarity` from 0 (strangers) to 1, the `baseline` the scores drift back to, and `flags`. @@ -84,3 +84,43 @@ after "but". You "barely know" them below `barely_known` (0.2) familiarity, "kno `well_known` (0.6), and "a little" in between. Each open flag gets a sentence, oldest first. Every constant is in `RelationshipConfig`, passed as `config=` to the functions that use them. + +## Bionics + +S11: the village reacts to the player's bionics (`bionics.py`, `sightings.py`). Every constant is in +`BionicsConfig`. + +- **Tolerance.** `tolerance(values)` is how a resident takes to bionics, from -1 to 1: + `sum(weight * (value - 0.5))`, clamped. Tradition and Security weigh -1, and Adventure, Ambition + and Open-mindedness 2/3 each, so the two sides weigh the same. From `mildly` (0.15) away from 0 + a resident is against or for, and from `strongly` (0.5) strongly so. In today's village Tobias + (-1) and Henrietta (-0.62) are strongly against, Mirela and Raymond against, Marguerite for, and + Ingrid and Kofi strongly for. The rest barely mind. +- **Standing out.** The infrared eye catches the eye most (`stands_out` 1), then the legs (0.75) + and the datajack (0.5). All three are visible. +- **Sightings.** `Sightings` looks every `look_every` (5 sim s) for residents who are awake, on + the player's map and within `sight` (6 tiles along the street), and has each see each bionic + once. A sighting is one R1 event per bionic new to them, from the player, on like, trust and + respect, with valence their tolerance and magnitude `sighting` (0.3) times how much it stands + out, applied as one encounter. So Tobias seeing the eye takes his like from 0.52 to 0.29, Mirela + seeing the legs takes hers from 0.5 to 0.43, and Kofi's rises a little. It also leaves an + `observation` memory about the player, "I saw that Visitor has hydraulic legs where their own + legs were. It made me uneasy.", with topics `bionics` and each bionic's name, salience from + `salience` (0.3) plus `salience_feeling` (0.6) times the strength of their feeling and how much + the bionic stands out, and valence their tolerance. That memory is the record of what they've + seen, read back from R2 once per resident, so it's saved with the world and counts once across + restarts. +- **Unease.** `unease` is B1's hook: how much the bionics a resident has seen on the player put + them off, `max(-tolerance, 0)` times how much those still fitted stand out together, up to 1. + B1 keeps them away and B2 has them decline (see their READMEs). +- **What they see.** `looks(npc)` is the line B5's prompt starts with, or nothing for a whole + player: what's fitted, which is new to them (seen in the last `new_for`, an hour, or not yet), and + how they feel about bionics: "Visitor has hydraulic legs where their own legs were and an + infrared eye that glows faintly red in place of one of their own. The infrared eye is new since + you last saw them. Bionics on a person repel you: it isn't natural, and you'd rather keep your + distance." +- **Gossip.** Nothing more: the sighting's memory is on their mind in scenes with other residents + like any other salient memory, and B3 remembers what was said. + +`sightings_of(app)` is the server's `Sightings`, which the lifespan ticks and plugs into B1, and +B2's lifespan points `engine.looks` at. diff --git a/server/simile/social/__init__.py b/server/simile/social/__init__.py index 83d17d39..264ead69 100644 --- a/server/simile/social/__init__.py +++ b/server/simile/social/__init__.py @@ -1,4 +1,5 @@ -from simile.social.config import DEFAULT_CONFIG, RelationshipConfig +from simile.social.bionics import looks, sighting, stands_out, strongly_against, tolerance, unease +from simile.social.config import DEFAULT_BIONICS, DEFAULT_CONFIG, BionicsConfig, RelationshipConfig from simile.social.describe import describe from simile.social.loans import lent, loan, owed from simile.social.relationship import ( @@ -13,7 +14,9 @@ from simile.social.traits import cast_traits, trait_factor, traits_of __all__ = [ + "DEFAULT_BIONICS", "DEFAULT_CONFIG", + "BionicsConfig", "RelationshipConfig", "apply_event", "apply_events", @@ -23,10 +26,16 @@ "describe", "lent", "loan", + "looks", "meet", "open_flag", "owed", "resolve_flag", + "sighting", + "stands_out", + "strongly_against", + "tolerance", "trait_factor", "traits_of", + "unease", ] diff --git a/server/simile/social/bionics.py b/server/simile/social/bionics.py new file mode 100644 index 00000000..79c3a0b0 --- /dev/null +++ b/server/simile/social/bionics.py @@ -0,0 +1,138 @@ +from collections.abc import Mapping, Sequence +from datetime import datetime + +from simile.schema import ( + PLAYER_ID, + Aspect, + Bionic, + CoreValue, + InteractionEvent, + Memory, + MemoryKind, + Npc, +) +from simile.social.config import DEFAULT_BIONICS, BionicsConfig + +TOPIC = "bionics" +NAMES: dict[Bionic, str] = { + Bionic.HYDRAULIC_LEGS: "hydraulic legs", + Bionic.DATAJACK: "datajack", + Bionic.INFRARED_EYE: "infrared eye", +} +FITTED: dict[Bionic, str] = { + Bionic.HYDRAULIC_LEGS: "hydraulic legs", + Bionic.DATAJACK: "a datajack", + Bionic.INFRARED_EYE: "an infrared eye", +} +LOOKS: dict[Bionic, str] = { + Bionic.HYDRAULIC_LEGS: "hydraulic legs where their own legs were", + Bionic.DATAJACK: "a datajack socket in the back of their neck", + Bionic.INFRARED_EYE: "an infrared eye that glows faintly red in place of one of their own", +} +FEELS: dict[int, str] = { + -2: "Bionics on a person repel you: it isn't natural, and you'd rather keep your distance.", + -1: "Bionics make you uneasy.", + 0: "Bionics don't much bother you either way.", + 1: "You find bionics interesting.", + 2: "Bionics fascinate you.", +} +FELT: dict[int, str] = { + -2: "It isn't natural, and it turned my stomach.", + -1: "It made me uneasy.", + 0: "", + 1: "I'd like a closer look.", + 2: "Fascinating: I'd love to know how it works.", +} + + +def tolerance(values: Mapping[CoreValue, float], config: BionicsConfig = DEFAULT_BIONICS) -> float: + """How a resident takes to bionics, from -1 (strongly against) to 1, by their values.""" + weights = config.tolerance_weights + return _clamp(sum(w * (values[value] - 0.5) for value, w in weights.items()), -1.0, 1.0) + + +def level(tolerance: float, config: BionicsConfig = DEFAULT_BIONICS) -> int: + """-2 strongly against, -1 against, 0 barely minds, 1 for and 2 strongly for.""" + strength = 2 if abs(tolerance) >= config.strongly else int(abs(tolerance) >= config.mildly) + return strength if tolerance > 0 else -strength + + +def strongly_against(npc: Npc, config: BionicsConfig = DEFAULT_BIONICS) -> bool: + return level(tolerance(npc.values, config), config) == -2 + + +def stands_out(bionics: Sequence[Bionic], config: BionicsConfig = DEFAULT_BIONICS) -> float: + """How much `bionics` catch the eye together, up to 1.""" + return min(sum(config.stands_out.get(bionic, 0.0) for bionic in bionics), 1.0) + + +def unease( + tolerance: float, seen: Sequence[Bionic], config: BionicsConfig = DEFAULT_BIONICS +) -> float: + """How much the bionics they've seen on the player put them off, 0 to 1.""" + return max(-tolerance, 0.0) * stands_out(seen, config) + + +def looks( + name: str, + bionics: Sequence[Bionic], + tolerance: float, + new: Sequence[Bionic] = (), + config: BionicsConfig = DEFAULT_BIONICS, +) -> str | None: + """What a resident can see of the player, for a prompt, or None without bionics.""" + if not bionics: + return None + parts = [f"{name} has {_listed([LOOKS[bionic] for bionic in bionics])}."] + if new := [bionic for bionic in bionics if bionic in new]: + are = "is" if len(new) == 1 and new[0] is not Bionic.HYDRAULIC_LEGS else "are" + parts.append(f"The {_listed([NAMES[b] for b in new])} {are} new since you last saw them.") + parts.append(FEELS[level(tolerance, config)]) + return " ".join(parts) + + +def sighting( + npc: Npc, bionics: Sequence[Bionic], now: datetime, config: BionicsConfig = DEFAULT_BIONICS +) -> tuple[list[InteractionEvent], Memory]: + """An R1 event for each of `bionics` `npc` first sees, by tolerance, and the memory of them.""" + feeling = tolerance(npc.values, config) + events = [ + InteractionEvent( + actor=PLAYER_ID, + target=npc.id, + action=f"turned up with {FITTED[bionic]}", + aspects=list(Aspect), + valence=feeling, + magnitude=config.sighting * config.stands_out.get(bionic, 0.0), + timestamp=now, + ) + for bionic in bionics + ] + said = f"I saw that {config.player_name} has {_listed([LOOKS[b] for b in bionics])}." + eye_catching = max(config.stands_out.get(bionic, 0.0) for bionic in bionics) + memory = Memory( + owner=npc.id, + kind=MemoryKind.OBSERVATION, + content=" ".join(filter(None, [said, FELT[level(feeling, config)]])), + salience=min(config.salience + config.salience_feeling * abs(feeling) * eye_catching, 1.0), + valence=feeling, + timestamp=now, + about=[PLAYER_ID], + topics=[TOPIC, *(NAMES[bionic] for bionic in bionics)], + ) + return events, memory + + +def seen_in(memory: Memory) -> list[Bionic]: + """The bionics a memory of a sighting records.""" + if memory.kind is not MemoryKind.OBSERVATION or TOPIC not in memory.topics: + return [] + return [bionic for bionic, name in NAMES.items() if name in memory.topics] + + +def _listed(items: Sequence[str]) -> str: + return items[0] if len(items) == 1 else f"{', '.join(items[:-1])} and {items[-1]}" + + +def _clamp(value: float, low: float, high: float) -> float: + return min(max(value, low), high) diff --git a/server/simile/social/config.py b/server/simile/social/config.py index d0ea7fa3..ef77eb89 100644 --- a/server/simile/social/config.py +++ b/server/simile/social/config.py @@ -3,7 +3,7 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator -from simile.schema import Aspect, CoreValue, Signed +from simile.schema import Aspect, Bionic, CoreValue, Signed, Unit BASELINE_WEIGHTS: dict[Aspect, dict[CoreValue, float]] = { Aspect.LIKE: { @@ -132,3 +132,79 @@ def _ordered(self) -> Self: DEFAULT_CONFIG = RelationshipConfig() + + +TOLERANCE_WEIGHTS: dict[CoreValue, float] = { + CoreValue.TRADITION: -1.0, + CoreValue.SECURITY: -1.0, + CoreValue.ADVENTURE: 2 / 3, + CoreValue.AMBITION: 2 / 3, + CoreValue.OPEN_MINDEDNESS: 2 / 3, +} + +STANDS_OUT: dict[Bionic, float] = { + Bionic.INFRARED_EYE: 1.0, + Bionic.HYDRAULIC_LEGS: 0.75, + Bionic.DATAJACK: 0.5, +} + + +class BionicsConfig(BaseModel): + """Every constant of how residents take to the player's bionics.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + tolerance_weights: dict[CoreValue, float] = Field( + default=TOLERANCE_WEIGHTS, + description="How each core value moves a resident's tolerance for bionics: " + "sum(weight * (value - 0.5)), clamped to [-1, 1]. The two against weigh 2 in all, as do " + "the three for.", + ) + mildly: float = Field( + default=0.15, gt=0, lt=1, description="How far from 0 a tolerance is against or for." + ) + strongly: float = Field( + default=0.5, gt=0, le=1, description="How far from 0 a tolerance is strongly so." + ) + stands_out: dict[Bionic, Unit] = Field( + default=STANDS_OUT, + description="How much each bionic catches the eye: the eye most, the datajack least.", + ) + sighting: float = Field( + default=0.3, + gt=0, + le=1, + description="The magnitude of R1's event for seeing a bionic that stands out fully. Its " + "valence is the resident's tolerance, and it moves like, trust and respect.", + ) + salience: float = Field( + default=0.3, ge=0, le=1, description="The salience of the memory of a sighting." + ) + salience_feeling: float = Field( + default=0.6, + ge=0, + le=1, + description="Salience added per unit of |tolerance| times how much the most eye-catching " + "bionic seen stands out, so strong feelings get talked about.", + ) + sight: float = Field( + default=6.0, gt=0, description="How far a resident sees the player from, in tiles." + ) + look_every: float = Field( + default=5, gt=0, description="Sim seconds between looks for residents who can see them." + ) + new_for: timedelta = Field( + default=timedelta(hours=1), + ge=timedelta(0), + description="How long after first seeing a bionic a resident's prompt calls it new.", + ) + player_name: str = Field(default="Visitor", min_length=1) + + @model_validator(mode="after") + def _ordered(self) -> Self: + if self.strongly <= self.mildly: + raise ValueError("strongly must be more than mildly") + return self + + +DEFAULT_BIONICS = BionicsConfig() diff --git a/server/simile/social/lifespan.py b/server/simile/social/lifespan.py new file mode 100644 index 00000000..95e98945 --- /dev/null +++ b/server/simile/social/lifespan.py @@ -0,0 +1,20 @@ +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from starlette.applications import Starlette + + +@asynccontextmanager +async def lifespan(app: Starlette) -> AsyncIterator[None]: + from simile.behaviour import behaviour_of + from simile.sim import world_of + from simile.social.sightings import sightings_of + + sightings = sightings_of(app) + stops = [ + world_of(app).loop.register(sightings), + behaviour_of(app).hooks.set(unease=sightings.unease), + ] + yield + for stop in reversed(stops): + stop() diff --git a/server/simile/social/sightings.py b/server/simile/social/sightings.py new file mode 100644 index 00000000..a8c9c4e2 --- /dev/null +++ b/server/simile/social/sightings.py @@ -0,0 +1,147 @@ +import sys +from collections.abc import Callable, Mapping, Sequence +from datetime import datetime +from typing import Protocol + +from starlette.applications import Starlette + +from simile.schema import ( + PLAYER_ID, + Bionic, + Body, + Id, + InteractionEvent, + Memory, + MemoryKind, + Npc, + Relationship, +) +from simile.sim import Clock +from simile.social.bionics import looks, seen_in, sighting, tolerance, unease +from simile.social.config import DEFAULT_BIONICS, BionicsConfig +from simile.social.relationship import apply_events, as_of, meet +from simile.voicing.distance import apart +from simile.world.movement import Movement + + +class Store(Protocol): + def relationship(self, owner: str, about: str) -> Relationship | None: ... + + def recall( + self, + owner: str, + now: datetime, + *, + about: str | None = None, + kind: MemoryKind | None = None, + k: int = 5, + ) -> list[Memory]: ... + + def add(self, *records: InteractionEvent | Memory | Relationship) -> None: ... + + +class Sightings: + """Residents seeing the player's bionics, each one once. Tick it on the sim loop.""" + + def __init__( + self, + store: Store, + clock: Clock, + npcs: Mapping[Id, Npc], + movement: Movement, + body: Callable[[], Body], + awake: Callable[[Id], bool], + *, + config: BionicsConfig = DEFAULT_BIONICS, + ) -> None: + self.store = store + self.clock = clock + self.npcs = npcs + self.movement = movement + self.body = body + self.awake = awake + self.config = config + self._seen: dict[Id, dict[Bionic, datetime]] = {} + self._elapsed = 0.0 + + def tick(self, dt: float) -> None: + self._elapsed += dt + if self._elapsed >= self.config.look_every - 1e-9: + self._elapsed = 0.0 + self.look() + + def look(self) -> None: + """Each resident awake within sight of the player sees the bionics new to them.""" + fitted = self.body().bionics + entities = self.movement.entities() + if not fitted or PLAYER_ID not in entities: + return + player = entities[PLAYER_ID] + for npc in self.npcs: + here = entities.get(npc) + if here is None or apart(here, player) > self.config.sight or not self.awake(npc): + continue + if new := [bionic for bionic in fitted if bionic not in self.seen(npc)]: + self.see(npc, new) + + def see(self, npc: Id, bionics: Sequence[Bionic]) -> Relationship: + """`npc` sees `bionics` for the first time, and returns how they now feel about them.""" + now, resident = self.clock.now, self.npcs[npc] + events, memory = sighting(resident, bionics, now, self.config) + stored = self.store.relationship(npc, PLAYER_ID) + before = as_of(stored, now) if stored else meet(resident, PLAYER_ID) + after = apply_events(before, events, now) + self.store.add(*events, memory, after) + self.seen(npc).update(dict.fromkeys(bionics, now)) + return after + + def seen(self, npc: Id) -> dict[Bionic, datetime]: + """When `npc` first saw each bionic, read from their memories the first time it's asked.""" + if npc not in self._seen: + memories = self.store.recall( + npc, self.clock.now, about=PLAYER_ID, kind=MemoryKind.OBSERVATION, k=sys.maxsize + ) + found: dict[Bionic, datetime] = {} + for memory in sorted(memories, key=lambda memory: memory.timestamp): + for bionic in seen_in(memory): + found.setdefault(bionic, memory.timestamp) + self._seen[npc] = found + return self._seen[npc] + + def unease(self, npc: Npc) -> float: + """B1's `unease` hook: how much the bionics `npc` has seen on the player put them off.""" + fitted = self.body().bionics + if not fitted: + return 0.0 + seen = self.seen(npc.id) + known = [bionic for bionic in fitted if bionic in seen] + return unease(tolerance(npc.values, self.config), known, self.config) + + def looks(self, npc: Id) -> str | None: + """What `npc` can see of the player face to face, for a prompt, or None without bionics.""" + fitted = self.body().bionics + if not fitted: + return None + seen, now = self.seen(npc), self.clock.now + new = [b for b in fitted if b not in seen or now - seen[b] < self.config.new_for] + feeling = tolerance(self.npcs[npc].values, self.config) + return looks(self.config.player_name, fitted, feeling, new, self.config) + + +def sightings_of(app: Starlette) -> Sightings: + """The server's `Sightings`, made on first use.""" + if not hasattr(app.state, "sightings"): + from simile.behaviour import Activity, behaviour_of + from simile.memory import memory_of + from simile.persistence import player_of + + behaviour = behaviour_of(app) + app.state.sightings = Sightings( + memory_of(app), + behaviour.clock, + behaviour.npcs, + behaviour.movement, + lambda: player_of(app).body, + lambda npc: behaviour.goal(npc).activity is not Activity.SLEEP, + ) + return app.state.sightings diff --git a/server/tests/behaviour/test_unease.py b/server/tests/behaviour/test_unease.py new file mode 100644 index 00000000..a1fbce57 --- /dev/null +++ b/server/tests/behaviour/test_unease.py @@ -0,0 +1,151 @@ +import asyncio +from collections import Counter +from contextlib import AsyncExitStack +from datetime import UTC, datetime, timedelta + +from starlette.applications import Starlette + +from simile.behaviour import DEFAULT_CONFIG, Activity, Goal, scores +from simile.behaviour.mind import create_behaviour, no_conversations +from simile.memory import MemoryStore +from simile.schema import PLAYER_ID, Bionic, Body, MapTile, MemoryKind, Npc +from simile.sim import Clock, FakeWallClock, World +from simile.voicing.distance import apart + +from .test_mind import ANA, BEN, Village, at + +A = Activity +CAFE = MapTile(map="coffee_shop_floor_0", x=12, y=2) +WHOLE = Body() +BIONIC = Body(legs=Bionic.HYDRAULIC_LEGS, vertebra=Bionic.DATAJACK, left_eye=Bionic.INFRARED_EYE) + + +def uneasy(level: float, who: str = "ana"): # type: ignore[no-untyped-def] + return lambda npc: level if npc.id == who else 0.0 + + +def options(village: Village, npc: str = "ana") -> dict[Goal, float]: + behaviour = village.behaviour + return scores(behaviour._situation(behaviour.minds[npc]), behaviour.places, behaviour.config) + + +def at_the_cafe(*npcs: Npc) -> Village: + """Everyone heading for the coffee shop, and the player in it.""" + village = Village(list(npcs), start=at(11), placed={PLAYER_ID: CAFE}) + for npc in npcs: + village.behaviour.minds[npc.id].goal = Goal(A.COFFEE_SHOP, "coffee_shop") + return village + + +def test_unease_takes_from_the_players_affinity_alone() -> None: + village = at_the_cafe(ANA, BEN) + before = {o.id: o for o in village.behaviour._situation(village.behaviour.minds["ana"]).others} + + village.behaviour.hooks.set(unease=uneasy(0.5)) + after = {o.id: o for o in village.behaviour._situation(village.behaviour.minds["ana"]).others} + + assert after["ben"] == before["ben"] + lost = before[PLAYER_ID].affinity - after[PLAYER_ID].affinity + assert lost == DEFAULT_CONFIG.unease_weight * 0.5 + assert after[PLAYER_ID].shunned and not before[PLAYER_ID].shunned + + +def test_unease_is_zero_without_the_hook_and_clamped_with_it() -> None: + village = at_the_cafe(ANA) + assert village.behaviour.unease("ana") == 0 + + village.behaviour.hooks.set(unease=uneasy(3.0)) + assert village.behaviour.unease("ana") == 1 + + def broken(npc: Npc) -> float: + raise RuntimeError + + village.behaviour.hooks.set(unease=broken) + assert village.behaviour.unease("ana") == 0 + + +def test_an_uneasy_resident_keeps_away_from_the_player_and_where_they_are() -> None: + village = at_the_cafe(ANA, BEN) + before = options(village) + + village.behaviour.hooks.set(unease=uneasy(1.0)) + after = options(village) + + cafe, seek_player, seek_ben = ( + Goal(A.COFFEE_SHOP, "coffee_shop"), + Goal(A.SEEK, PLAYER_ID), + Goal(A.SEEK, "ben"), + ) + assert after[cafe] < before[cafe] - 0.9 + assert after[seek_player] < before[seek_player] + assert after[seek_ben] < before[seek_ben] - 0.9 + assert after[Goal(A.SQUARE, "square")] == before[Goal(A.SQUARE, "square")] + + +def test_unease_changes_nobody_else() -> None: + village = at_the_cafe(ANA, BEN) + before = options(village, "ben") + + village.behaviour.hooks.set(unease=uneasy(1.0)) + + assert options(village, "ben") == before + + +async def day(body: Body) -> tuple[Counter[str], set[str]]: + """Six hours of the village from noon with the player in the coffee shop: the minutes each + resident spends near them, and who saw them.""" + from simile.behaviour.lifespan import lifespan as behaviour + from simile.economy.lifespan import lifespan as economy + from simile.memory.lifespan import lifespan as memory + from simile.persistence import player_of + from simile.social.lifespan import lifespan as social + from simile.world.lifespan import lifespan as world_lifespan + + app = Starlette() + world = World(Clock(datetime(2026, 1, 1, 12, tzinfo=UTC)), FakeWallClock()) + app.state.world, app.state.headless = world, True + app.state.memory = store = MemoryStore(":memory:") + app.state.behaviour = village = create_behaviour(app, seed=0) + player_of(app).body = body + near: Counter[str] = Counter() + + class Count: + elapsed = 0.0 + + def tick(self, dt: float) -> None: + self.elapsed += dt + if self.elapsed >= 60: + self.elapsed = 0.0 + entities = village.movement.entities() + near.update( + npc for npc in village.npcs if apart(entities[npc], entities[PLAYER_ID]) <= 6 + ) + + async with AsyncExitStack() as stack: + for hook in (behaviour, economy, memory, world_lifespan, social): + await stack.enter_async_context(hook(app)) + village.movement.place(PLAYER_ID, CAFE) + village.hooks.set( + request_conversation=lambda a, b: False, + news_interest=lambda npc: 0.6, + conversations=no_conversations, + ) + world.loop.register(Count()) + await world.loop.run(timedelta(hours=6)) + saw = { + npc + for npc in village.npcs + if store.recall(npc, world.clock.now, about=PLAYER_ID, kind=MemoryKind.OBSERVATION) + } + return near, saw + + +def test_a_bionic_player_drives_the_intolerant_away_and_draws_the_curious() -> None: + whole, nobody = asyncio.run(day(WHOLE)) + bionic, saw = asyncio.run(day(BIONIC)) + + against, curious = ("tobias", "raymond"), ("kofi", "ingrid") + assert not nobody and set(against) <= saw + assert sum(bionic[npc] for npc in against) < sum(whole[npc] for npc in against) / 4 + assert sum(bionic[npc] for npc in curious) >= sum(whole[npc] for npc in curious) * 0.75 + assert bionic["mirela"] > 0.9 * whole["mirela"] diff --git a/server/tests/conversation/test_bionics.py b/server/tests/conversation/test_bionics.py new file mode 100644 index 00000000..f05755e8 --- /dev/null +++ b/server/tests/conversation/test_bionics.py @@ -0,0 +1,190 @@ +from collections.abc import Callable +from datetime import timedelta + +import pytest + +from simile.conversation import DEFAULT_CONFIG +from simile.conversation.player import DEFAULT_PLAYER_CONFIG, PlayerConfig +from simile.conversation.prompt import CUES, Cue +from simile.schema import PLAYER_ID, Bionic, Body, Npc +from simile.sim import ConversationStarted +from simile.social.sightings import Sightings + +from .test_player import ANA, BEN, Village, make_npc +from .test_telling import INCIDENT, MEMORIES + +pytestmark = pytest.mark.anyio + +LEGS = Body(legs=Bionic.HYDRAULIC_LEGS) +WARY = make_npc("ana", "house_1", tradition=1.0, security=1.0) +REFUSE = CUES[Cue.REFUSE].format(other="Visitor") +OPENING = "You're with Visitor (a newcomer to the village)." + + +def watched( + village: Village, + body: Body = LEGS, + npcs: tuple[Npc, ...] = (WARY, BEN), + awake: Callable[[str], bool] = lambda npc: True, +) -> Sightings: + """S11 wired into the village as the server's lifespans wire it.""" + sightings = Sightings( + village.store, + village.clock, + {npc.id: npc for npc in npcs}, + village.movement, + lambda: body, + awake, + ) + village.engine.looks = sightings.looks + village.behaviour.hooks.set(unease=sightings.unease) + village.world.loop.register(sightings) + return sightings + + +async def test_what_they_see_of_the_player_starts_the_prompt() -> None: + village = Village(npcs=(BEN,)) + watched(village, npcs=(BEN,)) + + village.players.say("Hello!", "ben") + await village.run() + + first = village.script.prompts[-1].split("\n\n")[0] + assert first.endswith( + f"{OPENING} Visitor has hydraulic legs where their own legs were. The hydraulic legs are " + "new since you last saw them. Bionics don't much bother you either way." + ) + assert "I saw that Visitor has hydraulic legs" not in village.script.prompts[-1] + + +async def test_a_whole_player_looks_like_anyone_and_the_prompt_is_as_it_was() -> None: + plain, whole = Village(npcs=(BEN,)), Village(npcs=(BEN,)) + watched(whole, Body(), npcs=(BEN,)) + + for village in (plain, whole): + village.players.say("Hello!", "ben") + await village.run() + + assert whole.script.prompts == plain.script.prompts + assert plain.script.prompts[-1].split("\n\n")[0].endswith(OPENING) + + +async def test_a_sighting_stays_on_their_mind_for_the_next_talk() -> None: + village = Village(npcs=(BEN,)) + watched(village, npcs=(BEN,)) + await village.run() + + village.clock.now += timedelta(hours=2) + village.players.say("Hello!", "ben") + await village.run() + + prompt = village.script.prompts[-1] + assert "Visitor has hydraulic legs where their own legs were. Bionics don't" in prompt + assert "new since" not in prompt + assert "On your mind:\n- I saw that Visitor has hydraulic legs" in prompt + + +async def test_someone_put_off_by_the_players_bionics_turns_them_down() -> None: + village = Village(["No. Not today. [values: tradition]"], npcs=(WARY, BEN)) + watched(village) + await village.run() + + village.players.say("Hello!", "ana") + await village.run(5 + DEFAULT_PLAYER_CONFIG.farewell_wait) + + [prompt] = village.script.prompts + assert prompt.endswith(REFUSE) + assert "Bionics on a person repel you" in prompt + [ended] = village.ended + assert [line.speaker for line in ended.conversation.lines] == [PLAYER_ID, "ana", "ana"] + + +async def test_turning_the_player_down_sets_up_no_telling_or_lies() -> None: + told = {} + for body in (Body(), LEGS): + village = Village(["No. [values: tradition]"], npcs=(WARY, BEN)) + village.store.add(*MEMORIES) + village.engine.incident = lambda: INCIDENT + watched(village, body) + await village.run() + + village.players.say("Where were you the night Aris was attacked?", "ana") + await village.run() + + talk = village.players.talk() + assert talk is not None + told[talk.refusing] = talk.tellings, talk.lies + + assert told[False][0] and told[True] == ({}, {}) + + +async def test_the_tolerant_and_the_whole_player_get_a_conversation() -> None: + for npc, body in (("ben", LEGS), ("ana", Body())): + village = Village(npcs=(WARY, BEN)) + watched(village, body) + village.walk_to(npc, 1) + await village.run() + + village.players.say("Hello!", npc) + await village.run() + + assert not village.script.prompts[-1].endswith(REFUSE) + assert village.ended == [] + + +async def test_nobody_turns_the_player_down_at_work(monkeypatch: pytest.MonkeyPatch) -> None: + village = Village(npcs=(WARY, BEN)) + watched(village) + await village.run() + assert village.behaviour.unease("ana") + + monkeypatch.setattr(village.behaviour, "working", lambda npc: npc == "ana") + village.players.say("A flat white, please.", "ana") + await village.run() + + assert not village.script.prompts[-1].endswith(REFUSE) + + +async def test_someone_who_would_turn_the_player_down_doesnt_break_off_a_talk_for_them() -> None: + village = Village(npcs=(WARY, BEN)) + watched(village) + await village.run() + village.engine.start("ana", "ben") + + assert village.players.say("Hello!", "ana") is None + + assert village.engine.talking("ana") + assert [e.conversation.participants for e in village.of(ConversationStarted)] == [ + ["ana", "ben"] + ] + + +async def test_unease_is_taken_off_the_accept_score_so_they_dont_greet() -> None: + plain, uneasy = (Village(npcs=(ANA,), player=PlayerConfig(greet_cooldown=0)) for _ in "ab") + before = uneasy.engine.accept_score(PLAYER_ID, "ana") + + uneasy.behaviour.hooks.set(unease=lambda npc: 0.5) + + lost = before - uneasy.engine.accept_score(PLAYER_ID, "ana") + assert lost == pytest.approx(DEFAULT_CONFIG.accept_unease * 0.5) + for village in (plain, uneasy): + await village.run(DEFAULT_PLAYER_CONFIG.look_every * 3) + assert len(plain.of(ConversationStarted)) == 1 + assert uneasy.of(ConversationStarted) == [] + + +async def test_word_of_a_sighting_comes_up_with_someone_who_hasnt_seen_it() -> None: + village = Village(npcs=(WARY, BEN)) + watched(village, awake=lambda npc: npc != "ben") + await village.run() + + village.engine.start("ana", "ben") + await village.run(1) + + ana, ben = village.script.prompts[-1].split("## Ben") + mind = ana.split("On Ana's mind, which only Ana knows:\n")[1] + assert mind.startswith( + "- I saw that Visitor has hydraulic legs where their own legs were. It isn't natural, and " + "it turned my stomach." + ) + assert "hydraulic" not in ben.split("## Between them")[0] diff --git a/server/tests/social/test_bionics.py b/server/tests/social/test_bionics.py new file mode 100644 index 00000000..6628e5e6 --- /dev/null +++ b/server/tests/social/test_bionics.py @@ -0,0 +1,123 @@ +from datetime import UTC, datetime + +import pytest + +from simile.npc import load_npcs +from simile.schema import PLAYER_ID, Aspect, Bionic, CoreValue, MemoryKind, Npc +from simile.social import ( + apply_events, + looks, + meet, + sighting, + strongly_against, + tolerance, + unease, +) +from simile.social.bionics import level, seen_in + +NOW = datetime(2026, 1, 1, 10, tzinfo=UTC) +TEMPLATE = load_npcs()[0] +EYE, LEGS, JACK = Bionic.INFRARED_EYE, Bionic.HYDRAULIC_LEGS, Bionic.DATAJACK + + +def resident(id: str = "ana", **values: float) -> Npc: + scores = {value: values.get(value.value, 0.5) for value in CoreValue} + return TEMPLATE.model_copy(update={"id": id, "values": scores}) + + +def test_tradition_and_security_count_against_and_adventure_ambition_and_openness_for() -> None: + assert tolerance(resident().values) == 0 + for value in ("tradition", "security"): + assert tolerance(resident(**{value: 0.9}).values) < 0 + assert tolerance(resident(**{value: 0.1}).values) > 0 + for value in ("adventure", "ambition", "open_mindedness"): + assert tolerance(resident(**{value: 0.9}).values) > 0 + assert tolerance(resident(**{value: 0.1}).values) < 0 + assert tolerance(resident(honesty=1.0, connection=0.0).values) == 0 + + +def test_tolerance_runs_from_strongly_against_to_strongly_for() -> None: + against = dict(tradition=1.0, security=1.0, adventure=0.0, ambition=0.0, open_mindedness=0.0) + for_ = {value: 1 - score for value, score in against.items()} + + assert tolerance(resident(**against).values) == -1 + assert tolerance(resident(**for_).values) == 1 + assert [level(t) for t in (-1, -0.3, 0, 0.3, 1)] == [-2, -1, 0, 1, 2] + + +def test_some_of_the_village_are_strongly_against_and_some_strongly_for() -> None: + village = {npc.id: tolerance(npc.values) for npc in load_npcs()} + + assert {npc.id for npc in load_npcs() if strongly_against(npc)} == {"tobias", "henrietta"} + assert {id for id, t in village.items() if level(t) == 2} == {"ingrid", "kofi"} + assert {id for id, t in village.items() if level(t) == 0} >= {"desmond", "odalys"} + + +def feelings(npc: Npc, *bionics: Bionic) -> tuple[float, float, float]: + before = meet(npc, PLAYER_ID).model_copy(update={"like": 0.5, "trust": 0.5, "respect": 0.5}) + events, _ = sighting(npc, list(bionics), NOW) + after = apply_events(before, events, NOW) + return after.like, after.trust, after.respect + + +def test_a_sighting_moves_like_trust_and_respect_by_tolerance() -> None: + hostile = resident(tradition=1.0, security=1.0) + wary = resident(tradition=0.8, security=0.8) + neutral = resident() + curious = resident(adventure=1.0, open_mindedness=1.0) + + assert all(score < 0.5 for score in feelings(wary, EYE)) + assert all(a < b for a, b in zip(feelings(hostile, EYE), feelings(wary, EYE), strict=True)) + assert feelings(neutral, EYE) == (0.5, 0.5, 0.5) + assert all(score > 0.5 for score in feelings(curious, EYE)) + assert 0.5 - feelings(wary, EYE)[0] > feelings(curious, EYE)[0] - 0.5 + + +def test_the_eye_stands_out_most_and_the_datajack_least() -> None: + hostile = resident(tradition=1.0, security=1.0) + + eye, legs, jack = (feelings(hostile, bionic)[0] for bionic in (EYE, LEGS, JACK)) + + assert eye < legs < jack < 0.5 + assert feelings(hostile, LEGS, JACK)[0] < legs + + +def test_a_sighting_is_one_event_per_bionic_and_one_memory_that_records_them() -> None: + hostile = resident(tradition=1.0, security=1.0) + + events, memory = sighting(hostile, [LEGS, EYE], NOW) + + assert [(e.actor, e.target) for e in events] == [(PLAYER_ID, "ana")] * 2 + assert all(set(e.aspects) == set(Aspect) for e in events) + assert all(e.valence == tolerance(hostile.values) for e in events) + assert memory.kind is MemoryKind.OBSERVATION and memory.about == [PLAYER_ID] + assert memory.content.startswith("I saw that Visitor has hydraulic legs") + assert "infrared eye" in memory.content and "turned my stomach" in memory.content + assert seen_in(memory) == [LEGS, EYE] + assert memory.salience > sighting(resident(), [LEGS], NOW)[1].salience + + +def test_unease_is_for_the_intolerant_and_grows_with_what_they_have_seen() -> None: + assert unease(0.8, [EYE]) == 0 + assert unease(-0.5, []) == 0 + assert unease(-0.5, [JACK]) < unease(-0.5, [EYE]) == unease(-0.5, [EYE, LEGS]) == 0.5 + assert unease(-1, [LEGS, JACK]) == 1 + + +def test_what_they_see_names_each_bionic_what_is_new_and_how_they_feel() -> None: + line = looks("Visitor", [LEGS, EYE], -1, new=[EYE]) + + assert line is not None + assert line.startswith("Visitor has hydraulic legs where their own legs were and an infrared") + assert "The infrared eye is new since you last saw them." in line + assert line.endswith("you'd rather keep your distance.") + assert looks("Visitor", [], -1) is None + assert "fascinate" in (looks("Visitor", [JACK], 1) or "") + assert "new" not in (looks("Visitor", [JACK], 0) or "") + + +@pytest.mark.parametrize("bionics", [[LEGS], [JACK], [EYE], [LEGS, JACK, EYE]]) +def test_every_bionic_reads_back_from_its_memory(bionics: list[Bionic]) -> None: + _, memory = sighting(resident(), bionics, NOW) + + assert seen_in(memory) == bionics diff --git a/server/tests/social/test_lifespan.py b/server/tests/social/test_lifespan.py new file mode 100644 index 00000000..00c2ec3a --- /dev/null +++ b/server/tests/social/test_lifespan.py @@ -0,0 +1,53 @@ +from collections.abc import Callable +from datetime import UTC, datetime + +import pytest +from starlette.applications import Starlette +from starlette.testclient import TestClient + +from simile.app import create_app +from simile.behaviour import behaviour_of +from simile.conversation import conversations_of +from simile.memory import memory_of +from simile.persistence import player_of +from simile.schema import PLAYER_ID, Bionic, Body, MemoryKind +from simile.sim import Clock, World +from simile.social.sightings import sightings_of +from simile.world.movement import Point + +MakePackage = Callable[[dict[str, str]], str] + +START = datetime(2026, 1, 1, 10, tzinfo=UTC) + + +@pytest.fixture +def app(make_package: MakePackage) -> Starlette: + modules = ("behaviour", "conversation", "llm", "memory", "protocol", "social", "world") + package = make_package( + { + f"{module}/lifespan.py": f"from simile.{module}.lifespan import lifespan\n" + for module in modules + } + ) + app = create_app(package) + app.state.world = World(Clock(START)) + return app + + +def test_plugs_unease_into_b1_and_what_they_see_into_b2_until_shutdown(app: Starlette) -> None: + with TestClient(app) as client: + behaviour, sightings = behaviour_of(app), sightings_of(app) + assert behaviour.hooks.unease == sightings.unease + assert conversations_of(app).looks("tobias") is None + + player_of(app).body = Body(left_eye=Bionic.INFRARED_EYE) + here = behaviour.movement.position("tobias") + behaviour.movement.track(PLAYER_ID, Point(here.x + 1, here.y, here.map)) + client.portal.call(sightings.look) + + assert (conversations_of(app).looks("tobias") or "").startswith("Visitor has an infrared") + assert behaviour.unease("tobias") == 1 + [seen] = memory_of(app).recall("tobias", START, kind=MemoryKind.OBSERVATION) + assert seen.about == [PLAYER_ID] + + assert behaviour.hooks.unease != sightings.unease diff --git a/server/tests/social/test_sightings.py b/server/tests/social/test_sightings.py new file mode 100644 index 00000000..514ef96e --- /dev/null +++ b/server/tests/social/test_sightings.py @@ -0,0 +1,155 @@ +from datetime import UTC, datetime, timedelta +from pathlib import Path + +from simile.memory import MemoryStore +from simile.npc import load_npcs +from simile.schema import PLAYER_ID, Bionic, Body, CoreValue, Id, MemoryKind, Npc +from simile.sim import Clock, EventBus +from simile.social import tolerance +from simile.social.sightings import Sightings +from simile.world import Movement, Point +from simile.world.map import load_maps + +MAPS = load_maps() +START = datetime(2026, 1, 1, 10, tzinfo=UTC) +TEMPLATE = load_npcs()[0] +EYE, LEGS = Bionic.INFRARED_EYE, Bionic.HYDRAULIC_LEGS + + +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}) + + +ANA = resident("ana", tradition=1.0, security=1.0) +BEN = resident("ben", adventure=1.0, ambition=1.0, open_mindedness=1.0) + + +class Street: + """Ana and Ben in the coffee shop, the player beside Ana, everyone awake.""" + + def __init__( + self, store: MemoryStore | None = None, *, body: Body | None = None, player: bool = True + ) -> None: + self.clock = Clock(START) + self.store = store or MemoryStore(":memory:") + self.movement = Movement(MAPS, EventBus()) + for npc in (ANA, BEN): + self.movement.place(npc.id, "coffee_shop") + if player: + self.player_at(ANA.id, 1) + self.body = body or Body(legs=LEGS) + self.asleep: set[Id] = set() + self.sightings = Sightings( + self.store, + self.clock, + {npc.id: npc for npc in (ANA, BEN)}, + self.movement, + lambda: self.body, + lambda npc: npc not in self.asleep, + ) + + def player_at(self, npc: Id, tiles: float, map: str | None = None) -> None: + here = self.movement.position(npc) + self.movement.track(PLAYER_ID, Point(here.x + tiles, here.y, map or here.map)) + + def look(self) -> None: + self.sightings.tick(self.sightings.config.look_every) + + def sightings_of(self, npc: Id) -> list[str]: + memories = self.store.recall(npc, self.clock.now, about=PLAYER_ID, k=100) + return [m.content for m in memories if m.kind is MemoryKind.OBSERVATION] + + +def test_a_resident_in_sight_sees_each_bionic_once() -> None: + street = Street() + + street.look() + street.look() + + assert len(street.sightings_of("ana")) == 1 + assert set(street.sightings.seen("ana")) == {LEGS} + rel = street.store.relationship("ana", PLAYER_ID) + assert rel is not None and rel.like < 0.5 + events = street.store.shared_history("ana", PLAYER_ID, START).events + assert [event.action for event in events] == ["turned up with hydraulic legs"] + + +def test_a_new_bionic_is_seen_on_its_own() -> None: + street = Street() + street.look() + + street.body = Body(legs=LEGS, left_eye=EYE) + street.clock.now += timedelta(hours=1) + street.look() + + assert len(street.sightings_of("ana")) == 2 + assert street.sightings.seen("ana") == {LEGS: START, EYE: START + timedelta(hours=1)} + + +def test_nobody_sees_the_player_asleep_out_of_sight_or_through_a_wall() -> None: + street = Street() + street.asleep.add("ana") + street.look() + assert not street.sightings_of("ana") + + street.asleep.clear() + street.player_at("ana", street.sightings.config.sight + 1) + street.look() + assert not street.sightings_of("ana") + + street.player_at("ana", 1, map="high_street") + street.look() + assert not street.sightings_of("ana") + + +def test_a_whole_player_or_one_not_in_the_world_changes_nothing() -> None: + street = Street(body=Body()) + street.look() + assert street.store.relationship("ana", PLAYER_ID) is None + + away = Street(player=False) + away.look() + assert away.store.relationship("ana", PLAYER_ID) is None + + +def test_what_they_have_seen_outlasts_a_restart(tmp_path: Path) -> None: + with MemoryStore(tmp_path / "simile.db") as store: + Street(store).look() + with MemoryStore(tmp_path / "simile.db") as store: + street = Street(store) + street.clock.now += timedelta(days=1) + street.look() + + assert len(street.sightings_of("ana")) == 1 + assert street.sightings.seen("ana") == {LEGS: START} + + +def test_unease_is_what_an_intolerant_resident_has_seen() -> None: + street = Street(body=Body(legs=LEGS, left_eye=EYE)) + assert street.sightings.unease(ANA) == 0 + + street.look() + + assert street.sightings.unease(ANA) == -tolerance(ANA.values) == 1 + assert street.sightings.unease(BEN) == 0 + street.body = Body() + assert street.sightings.unease(ANA) == 0 + + +def test_what_they_see_calls_a_bionic_new_for_an_hour() -> None: + street = Street() + assert "new since" in (street.sightings.looks("ana") or "") + + street.look() + street.clock.now += timedelta(minutes=30) + assert "new since" in (street.sightings.looks("ana") or "") + street.clock.now += timedelta(minutes=31) + looks = street.sightings.looks("ana") or "" + + assert looks.startswith("Visitor has hydraulic legs") and "new since" not in looks + assert street.sightings.looks("ben") == ( + "Visitor has hydraulic legs where their own legs were. Bionics fascinate you." + ) + street.body = Body() + assert street.sightings.looks("ana") is None From 70f146760d028288e83bdee959f5c2e0a8d44e47 Mon Sep 17 00:00:00 2001 From: James Crosswell Date: Fri, 23 Oct 2026 06:39:59 +1300 Subject: [PATCH 2/2] Review: only the uneasy refuse, refusals give covers, and see before greeting - Unease is 0 within `mildly` of neutral and rises from there, so residents who barely mind never turn the player down. - Unease counts the bionics a resident can see now, so B2 doesn't greet on a first approach before the next look records the sighting. - A refusal takes the incident's lies, but tells nothing: an attacker asked about the night gives their cover as they turn the player down, recorded as S12 records it, so the eye can read it. - The strongly against say why they refuse in one curt line, and the rest stay polite. - A sighting's events move how residents feel but aren't stored, so they stay out of the shared history. Co-Authored-By: Claude Opus 5.5 --- server/simile/conversation/README.md | 12 ++- server/simile/conversation/player.py | 11 ++- server/simile/conversation/prompt.py | 11 ++- server/simile/social/README.md | 12 ++- server/simile/social/bionics.py | 5 +- server/simile/social/sightings.py | 29 ++++--- server/tests/behaviour/test_unease.py | 3 +- server/tests/conversation/test_bionics.py | 93 +++++++++++++++++++---- server/tests/social/test_bionics.py | 15 +++- server/tests/social/test_sightings.py | 14 ++-- 10 files changed, 155 insertions(+), 50 deletions(-) diff --git a/server/simile/conversation/README.md b/server/simile/conversation/README.md index abdef84a..f5c4627b 100644 --- a/server/simile/conversation/README.md +++ b/server/simile/conversation/README.md @@ -317,9 +317,15 @@ bionics. The lifespan points `engine.looks` at its sightings. `accept_unease` (2) times it, so an uneasy resident doesn't greet them. One the player talks to turns them down when that score declines, as it would an NPC's request: their one reply is cued "You'd rather not talk to Visitor. Turn them down in a few words, and leave it there.", and the - conversation ends as after a goodbye. Only someone uneasy turns the player down, never at work, - and someone talking with another resident doesn't break off to do it: the player gets no answer. - A lower trust from a sighting also makes them tell less of the incident (*Telling* above). + conversation ends as after a goodbye. Someone strongly against bionics is told to say why in one + curt line, and anyone else to stay polite and keep their reason to themselves. Only someone + uneasy turns the player down, never at work, and someone talking with another resident doesn't + break off to do it: the player gets no answer. +- **Lies in a refusal.** Turning the player down sets up the lies of #285 and of the incident as + usual, but tells nothing: a resident asked about the incident who'd lie about a fact still gets + its cover (*Telling* above), and the cue adds "If they asked what one of your cover stories + answers, give it as you turn them down." So the lie is in `talk.lies` and `engine.lies` for the + eye to read. A lower trust from a sighting also makes them tell less of the incident. ## Only promising what's possible diff --git a/server/simile/conversation/player.py b/server/simile/conversation/player.py index 70534600..ddb93eb7 100644 --- a/server/simile/conversation/player.py +++ b/server/simile/conversation/player.py @@ -34,6 +34,7 @@ from simile.news.reactions import mentioned from simile.schema import PLAYER_ID, Conversation, CoreValue, Id, Line, Memory, NewsItem from simile.sim import ConversationStarted, LineSpoken +from simile.social import strongly_against from simile.voice import speaking_rate from simile.voicing.distance import between @@ -388,9 +389,8 @@ async def _reply(self, talk: PlayerTalk, cue: Cue, lines: Sequence[Line]) -> Dra return sentences.draft pressed = engine.pressing(npc) recalled = self._recalled(npc, lines) - if not talk.refusing: - self._tempt(talk, lines) - self._ask(talk, lines) + self._tempt(talk, lines) + self._ask(talk, lines, lies_only=talk.refusing) seed = f"{talk.conversation.id}/{npc}/{len(lines)}" joke = jokes(party.npc.humour, seed, after_joke=talk.joked) talk.joked = bool(joke) @@ -413,6 +413,7 @@ async def _reply(self, talk: PlayerTalk, cue: Cue, lines: Sequence[Line]) -> Dra *([UNASKED] if any(t.stance is Stance.LIE for t in talk.tellings.values()) else []), ], joke=joke, + blunt=talk.refusing and strongly_against(party.npc), ) stream = engine.llm().stream( prompt, @@ -453,13 +454,15 @@ 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: + def _ask(self, talk: PlayerTalk, lines: Sequence[Line], *, lies_only: bool = False) -> 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): + if lies_only and telling.stance is not Stance.LIE: + continue talk.tellings[telling.fact.id] = telling if telling.stance is Stance.LIE: temptation = telling.temptation() diff --git a/server/simile/conversation/prompt.py b/server/simile/conversation/prompt.py index 879920ab..119a4b22 100644 --- a/server/simile/conversation/prompt.py +++ b/server/simile/conversation/prompt.py @@ -47,6 +47,9 @@ class Cue(StrEnum): Cue.REFUSE: "You'd rather not talk to {other}. Turn them down in a few words, and leave it " "there.", } +BLUNT = "Tell them why in one curt line: you don't hold with bionics." +POLITE = "Keep it polite, and keep your reason to yourself." +COVERED = "If they asked what one of your cover stories answers, give it as you turn them down." JOKE = "Work a joke into this line, in your own humour." PLAIN = "No joke in this line: say it plainly." OFF_TO = ( @@ -295,6 +298,7 @@ def turn_prompt( facts: Sequence[str] = (), lies: Sequence[str] = (), joke: bool | None = None, + blunt: bool = False, ) -> str: """The speaker's next line, with their mood, article `detail`, `facts`, `lies` and `joke`.""" recent = lines[-config.transcript_lines :] @@ -324,8 +328,11 @@ def turn_prompt( if joke is not None: parts.append(JOKE if joke else PLAIN) parts.append(f"Say one or two sentences, at most {config.max_words} words.") - off_to = cue is Cue.GOODBYE and errand - asked = OFF_TO.format(errand=errand, other=other) if off_to else CUES[cue].format(other=other) + asked = CUES[cue].format(other=other) + if cue is Cue.GOODBYE and errand: + asked = OFF_TO.format(errand=errand, other=other) + elif cue is Cue.REFUSE: + asked = " ".join([asked, BLUNT if blunt else POLITE, *([COVERED] if lies else [])]) return "\n\n".join([*parts, asked]) diff --git a/server/simile/social/README.md b/server/simile/social/README.md index cbd1c1b6..0e41d79e 100644 --- a/server/simile/social/README.md +++ b/server/simile/social/README.md @@ -103,16 +103,20 @@ S11: the village reacts to the player's bionics (`bionics.py`, `sightings.py`). once. A sighting is one R1 event per bionic new to them, from the player, on like, trust and respect, with valence their tolerance and magnitude `sighting` (0.3) times how much it stands out, applied as one encounter. So Tobias seeing the eye takes his like from 0.52 to 0.29, Mirela - seeing the legs takes hers from 0.5 to 0.43, and Kofi's rises a little. It also leaves an + seeing the legs takes hers from 0.5 to 0.43, and Kofi's rises a little. The events move how they + feel but aren't stored, so they stay out of the history B2's prompts list. It also leaves an `observation` memory about the player, "I saw that Visitor has hydraulic legs where their own legs were. It made me uneasy.", with topics `bionics` and each bionic's name, salience from `salience` (0.3) plus `salience_feeling` (0.6) times the strength of their feeling and how much the bionic stands out, and valence their tolerance. That memory is the record of what they've seen, read back from R2 once per resident, so it's saved with the world and counts once across restarts. -- **Unease.** `unease` is B1's hook: how much the bionics a resident has seen on the player put - them off, `max(-tolerance, 0)` times how much those still fitted stand out together, up to 1. - B1 keeps them away and B2 has them decline (see their READMEs). +- **Unease.** `unease` is B1's hook: how much the bionics a resident has seen on the player, or + can see now, put them off. It's how far their tolerance is below `-mildly`, as a share of the + rest of the way to -1, times how much those bionics stand out together, up to 1: none for anyone + who barely minds, 0.35 for Mirela and 1 for Tobias seeing the eye. Counting what's in sight now + means it's there before the next look records the sighting. B1 keeps them away and B2 has them + decline (see their READMEs). - **What they see.** `looks(npc)` is the line B5's prompt starts with, or nothing for a whole player: what's fitted, which is new to them (seen in the last `new_for`, an hour, or not yet), and how they feel about bionics: "Visitor has hydraulic legs where their own legs were and an diff --git a/server/simile/social/bionics.py b/server/simile/social/bionics.py index 79c3a0b0..1c5909cc 100644 --- a/server/simile/social/bionics.py +++ b/server/simile/social/bionics.py @@ -69,8 +69,9 @@ def stands_out(bionics: Sequence[Bionic], config: BionicsConfig = DEFAULT_BIONIC def unease( tolerance: float, seen: Sequence[Bionic], config: BionicsConfig = DEFAULT_BIONICS ) -> float: - """How much the bionics they've seen on the player put them off, 0 to 1.""" - return max(-tolerance, 0.0) * stands_out(seen, config) + """How much the bionics they've seen put them off: none within `mildly` of 0, then up to 1.""" + against = max(-tolerance - config.mildly, 0.0) / (1 - config.mildly) + return against * stands_out(seen, config) def looks( diff --git a/server/simile/social/sightings.py b/server/simile/social/sightings.py index a8c9c4e2..fe966386 100644 --- a/server/simile/social/sightings.py +++ b/server/simile/social/sightings.py @@ -10,7 +10,6 @@ Bionic, Body, Id, - InteractionEvent, Memory, MemoryKind, Npc, @@ -37,7 +36,7 @@ def recall( k: int = 5, ) -> list[Memory]: ... - def add(self, *records: InteractionEvent | Memory | Relationship) -> None: ... + def add(self, *records: Memory | Relationship) -> None: ... class Sightings: @@ -73,17 +72,22 @@ def tick(self, dt: float) -> None: def look(self) -> None: """Each resident awake within sight of the player sees the bionics new to them.""" fitted = self.body().bionics - entities = self.movement.entities() - if not fitted or PLAYER_ID not in entities: + if not fitted: return - player = entities[PLAYER_ID] for npc in self.npcs: - here = entities.get(npc) - if here is None or apart(here, player) > self.config.sight or not self.awake(npc): + if not self.in_sight(npc): continue if new := [bionic for bionic in fitted if bionic not in self.seen(npc)]: self.see(npc, new) + def in_sight(self, npc: Id) -> bool: + """Whether `npc` is awake and can see the player now.""" + try: + here, player = self.movement.position(npc), self.movement.position(PLAYER_ID) + except KeyError: + return False + return apart(here, player) <= self.config.sight and self.awake(npc) + def see(self, npc: Id, bionics: Sequence[Bionic]) -> Relationship: """`npc` sees `bionics` for the first time, and returns how they now feel about them.""" now, resident = self.clock.now, self.npcs[npc] @@ -91,7 +95,7 @@ def see(self, npc: Id, bionics: Sequence[Bionic]) -> Relationship: stored = self.store.relationship(npc, PLAYER_ID) before = as_of(stored, now) if stored else meet(resident, PLAYER_ID) after = apply_events(before, events, now) - self.store.add(*events, memory, after) + self.store.add(memory, after) self.seen(npc).update(dict.fromkeys(bionics, now)) return after @@ -109,13 +113,16 @@ def seen(self, npc: Id) -> dict[Bionic, datetime]: return self._seen[npc] def unease(self, npc: Npc) -> float: - """B1's `unease` hook: how much the bionics `npc` has seen on the player put them off.""" + """B1's `unease` hook: how much the bionics `npc` has seen or sees now put them off.""" fitted = self.body().bionics if not fitted: return 0.0 - seen = self.seen(npc.id) + feeling = tolerance(npc.values, self.config) + if not unease(feeling, fitted, self.config): + return 0.0 + seen = fitted if self.in_sight(npc.id) else self.seen(npc.id) known = [bionic for bionic in fitted if bionic in seen] - return unease(tolerance(npc.values, self.config), known, self.config) + return unease(feeling, known, self.config) def looks(self, npc: Id) -> str | None: """What `npc` can see of the player face to face, for a prompt, or None without bionics.""" diff --git a/server/tests/behaviour/test_unease.py b/server/tests/behaviour/test_unease.py index a1fbce57..0821b571 100644 --- a/server/tests/behaviour/test_unease.py +++ b/server/tests/behaviour/test_unease.py @@ -92,8 +92,7 @@ def test_unease_changes_nobody_else() -> None: async def day(body: Body) -> tuple[Counter[str], set[str]]: - """Six hours of the village from noon with the player in the coffee shop: the minutes each - resident spends near them, and who saw them.""" + """Minutes each resident spends near the player in the coffee shop, and who saw them.""" from simile.behaviour.lifespan import lifespan as behaviour from simile.economy.lifespan import lifespan as economy from simile.memory.lifespan import lifespan as memory diff --git a/server/tests/conversation/test_bionics.py b/server/tests/conversation/test_bionics.py index f05755e8..09e90dca 100644 --- a/server/tests/conversation/test_bionics.py +++ b/server/tests/conversation/test_bionics.py @@ -3,15 +3,17 @@ import pytest +from simile.behaviour import Need from simile.conversation import DEFAULT_CONFIG from simile.conversation.player import DEFAULT_PLAYER_CONFIG, PlayerConfig -from simile.conversation.prompt import CUES, Cue +from simile.conversation.prompt import BLUNT, COVERED, CUES, POLITE, Cue +from simile.conversation.telling import Stance from simile.schema import PLAYER_ID, Bionic, Body, Npc from simile.sim import ConversationStarted from simile.social.sightings import Sightings from .test_player import ANA, BEN, Village, make_npc -from .test_telling import INCIDENT, MEMORIES +from .test_telling import ALIBI, GONE, INCIDENT, MEMORIES pytestmark = pytest.mark.anyio @@ -93,29 +95,88 @@ async def test_someone_put_off_by_the_players_bionics_turns_them_down() -> None: await village.run(5 + DEFAULT_PLAYER_CONFIG.farewell_wait) [prompt] = village.script.prompts - assert prompt.endswith(REFUSE) + assert prompt.endswith(f"{REFUSE} {BLUNT}") assert "Bionics on a person repel you" in prompt [ended] = village.ended assert [line.speaker for line in ended.conversation.lines] == [PLAYER_ID, "ana", "ana"] -async def test_turning_the_player_down_sets_up_no_telling_or_lies() -> None: - told = {} - for body in (Body(), LEGS): - village = Village(["No. [values: tradition]"], npcs=(WARY, BEN)) - village.store.add(*MEMORIES) - village.engine.incident = lambda: INCIDENT - watched(village, body) +async def test_only_the_strongly_against_say_why_they_turn_the_player_down() -> None: + uneasy = make_npc("ana", "house_1", tradition=0.7, security=0.7, connection=0.0) + village = Village(npcs=(uneasy, BEN)) + watched(village, npcs=(uneasy, BEN)) + village.behaviour.minds["ana"].needs[Need.SOCIAL] = 0.2 + await village.run() + + village.players.say("Hello!", "ana") + await village.run() + + talk = village.players.talk() + assert talk is not None and talk.refusing + assert village.script.prompts[-1].endswith(f"{REFUSE} {POLITE}") + assert "Bionics make you uneasy." in village.script.prompts[-1] + + +async def test_someone_who_barely_minds_never_turns_the_player_down() -> None: + shy = make_npc("ana", "house_1", tradition=0.55, security=0.55, connection=0.0) + for social in (0.0, 0.3, 0.5): + village = Village(npcs=(shy, BEN)) + watched(village, Body(legs=Bionic.HYDRAULIC_LEGS, left_eye=Bionic.INFRARED_EYE)) + village.behaviour.minds["ana"].needs[Need.SOCIAL] = social await village.run() + assert village.engine.accept_score(PLAYER_ID, "ana") < 0 - village.players.say("Where were you the night Aris was attacked?", "ana") + village.players.say("Hello!", "ana") await village.run() talk = village.players.talk() - assert talk is not None - told[talk.refusing] = talk.tellings, talk.lies + assert talk is not None and not talk.refusing + assert REFUSE not in village.script.prompts[-1] + + +async def test_an_attacker_who_turns_the_player_down_still_gives_their_cover() -> None: + attacker = make_npc("ben", "house_2", tradition=1.0, security=1.0) + village = Village(["Home all night. Now leave me be. [values: security]"], npcs=(attacker,)) + village.store.add(*MEMORIES) + village.engine.incident = lambda: INCIDENT + watched(village, npcs=(attacker,)) + await village.run() + + village.players.say("Where were you the night Aris was attacked?", "ben") + await village.run() + + talk = village.players.talk() + assert talk is not None and talk.refusing + prompt = village.script.prompts[-1] + assert prompt.endswith(f"{REFUSE} {BLUNT} {COVERED}") + assert f'Only if asked that, say exactly: "{ALIBI}"' in prompt + assert "trust Visitor enough to tell" not in prompt and GONE not in prompt + assert {fact: t.stance for fact, t in talk.tellings.items()} == { + "attack": Stance.LIE, + "there_ben": Stance.LIE, + } + assert talk.lies["fact/there_ben"].fact_id == "there_ben" + lies = village.engine.lies(talk.conversation.id) + assert {(lie.liar, lie.listener, lie.fact_id) for lie in lies} == { + ("ben", PLAYER_ID, "attack"), + ("ben", PLAYER_ID, "there_ben"), + } + + +async def test_a_resident_who_sees_the_bionics_on_first_approach_doesnt_greet() -> None: + sociable = make_npc("ana", "house_1", connection=1.0, tradition=1.0, security=1.0) + greeted: dict[bool, bool] = {} + for body in (Body(), LEGS): + village = Village(npcs=(sociable,), player=PlayerConfig(greet_cooldown=0)) + sightings = watched(village, body, npcs=(sociable,)) + village.walk_to("ana", 2) + + await village.run(DEFAULT_PLAYER_CONFIG.look_every + 0.5) + + greeted[bool(body.bionics)] = bool(village.of(ConversationStarted)) + assert set(sightings.seen("ana")) == set(body.bionics) - assert told[False][0] and told[True] == ({}, {}) + assert greeted == {False: True, True: False} async def test_the_tolerant_and_the_whole_player_get_a_conversation() -> None: @@ -128,7 +189,7 @@ async def test_the_tolerant_and_the_whole_player_get_a_conversation() -> None: village.players.say("Hello!", npc) await village.run() - assert not village.script.prompts[-1].endswith(REFUSE) + assert REFUSE not in village.script.prompts[-1] assert village.ended == [] @@ -142,7 +203,7 @@ async def test_nobody_turns_the_player_down_at_work(monkeypatch: pytest.MonkeyPa village.players.say("A flat white, please.", "ana") await village.run() - assert not village.script.prompts[-1].endswith(REFUSE) + assert REFUSE not in village.script.prompts[-1] async def test_someone_who_would_turn_the_player_down_doesnt_break_off_a_talk_for_them() -> None: diff --git a/server/tests/social/test_bionics.py b/server/tests/social/test_bionics.py index 6628e5e6..32766e71 100644 --- a/server/tests/social/test_bionics.py +++ b/server/tests/social/test_bionics.py @@ -100,10 +100,23 @@ def test_a_sighting_is_one_event_per_bionic_and_one_memory_that_records_them() - def test_unease_is_for_the_intolerant_and_grows_with_what_they_have_seen() -> None: assert unease(0.8, [EYE]) == 0 assert unease(-0.5, []) == 0 - assert unease(-0.5, [JACK]) < unease(-0.5, [EYE]) == unease(-0.5, [EYE, LEGS]) == 0.5 + assert unease(-0.5, [JACK]) < unease(-0.5, [EYE]) == unease(-0.5, [EYE, LEGS]) + assert unease(-0.575, [EYE]) == pytest.approx(0.5) assert unease(-1, [LEGS, JACK]) == 1 +def test_nobody_who_barely_minds_is_uneasy() -> None: + assert unease(-0.15, [EYE]) == 0 + assert unease(-0.16, [EYE]) > 0 + village = {npc.id: unease(tolerance(npc.values), [EYE]) for npc in load_npcs()} + assert {id for id, level in village.items() if level} == { + "tobias", + "henrietta", + "mirela", + "raymond", + } + + def test_what_they_see_names_each_bionic_what_is_new_and_how_they_feel() -> None: line = looks("Visitor", [LEGS, EYE], -1, new=[EYE]) diff --git a/server/tests/social/test_sightings.py b/server/tests/social/test_sightings.py index 514ef96e..bdddf83d 100644 --- a/server/tests/social/test_sightings.py +++ b/server/tests/social/test_sightings.py @@ -71,8 +71,7 @@ def test_a_resident_in_sight_sees_each_bionic_once() -> None: assert set(street.sightings.seen("ana")) == {LEGS} rel = street.store.relationship("ana", PLAYER_ID) assert rel is not None and rel.like < 0.5 - events = street.store.shared_history("ana", PLAYER_ID, START).events - assert [event.action for event in events] == ["turned up with hydraulic legs"] + assert street.store.shared_history("ana", PLAYER_ID, START).events == [] def test_a_new_bionic_is_seen_on_its_own() -> None: @@ -125,13 +124,18 @@ def test_what_they_have_seen_outlasts_a_restart(tmp_path: Path) -> None: assert street.sightings.seen("ana") == {LEGS: START} -def test_unease_is_what_an_intolerant_resident_has_seen() -> None: +def test_unease_is_what_an_intolerant_resident_has_seen_or_sees_now() -> None: street = Street(body=Body(legs=LEGS, left_eye=EYE)) + street.player_at("ana", street.sightings.config.sight + 1) assert street.sightings.unease(ANA) == 0 - street.look() - + street.player_at("ana", 1) assert street.sightings.unease(ANA) == -tolerance(ANA.values) == 1 + assert street.sightings.seen("ana") == {} + + street.look() + street.player_at("ana", street.sightings.config.sight + 1) + assert street.sightings.unease(ANA) == 1 assert street.sightings.unease(BEN) == 0 street.body = Body() assert street.sightings.unease(ANA) == 0