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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions server/simile/behaviour/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.

Expand All @@ -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}`. |
Expand Down
2 changes: 2 additions & 0 deletions server/simile/behaviour/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
Mind,
NewsInterest,
NewsReader,
Unease,
WallPicker,
WorkMaker,
behaviour_of,
Expand Down Expand Up @@ -46,6 +47,7 @@
"Places",
"Promise",
"Situation",
"Unease",
"WallPicker",
"WorkMaker",
"behaviour_of",
Expand Down
5 changes: 5 additions & 0 deletions server/simile/behaviour/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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`."
)
Expand Down
20 changes: 19 additions & 1 deletion server/simile/behaviour/mind.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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."""

Expand All @@ -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,
Expand All @@ -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 = {
Expand All @@ -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}
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions server/simile/behaviour/scoring.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
27 changes: 26 additions & 1 deletion server/simile/conversation/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -302,6 +302,31 @@ 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. 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

B3 turns what a resident says they'll do into an intention that B1 acts on (see *Intentions* in
Expand Down
5 changes: 5 additions & 0 deletions server/simile/conversation/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions server/simile/conversation/engagement.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
4 changes: 4 additions & 0 deletions server/simile/conversation/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]] = {}
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions server/simile/conversation/lifespan.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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),
Expand Down
41 changes: 27 additions & 14 deletions server/simile/conversation/player.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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

Expand Down Expand Up @@ -200,6 +201,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:
Expand Down Expand Up @@ -258,12 +261,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
Expand Down Expand Up @@ -342,6 +350,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)",
Expand Down Expand Up @@ -380,7 +390,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)
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)
Expand All @@ -403,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,
Expand Down Expand Up @@ -443,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()
Expand Down Expand Up @@ -553,8 +566,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
Expand Down Expand Up @@ -621,18 +634,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)

Expand Down
Loading
Loading