diff --git a/changelog.d/1069-friend-chief-avatar.fixed.md b/changelog.d/1069-friend-chief-avatar.fixed.md new file mode 100644 index 000000000..861488628 --- /dev/null +++ b/changelog.d/1069-friend-chief-avatar.fixed.md @@ -0,0 +1 @@ +A friend's Bot that shares a local Bot's display name no longer wears that local Bot's picture: two default Chiefs both store the blue whale, and the friend's row now steps to a different shape while the local Chief keeps its own look ([#1069](https://github.com/ThinkFlowLab/FlyBot/issues/1069)). diff --git a/core/client.js b/core/client.js index fa346d38d..fcf678351 100644 --- a/core/client.js +++ b/core/client.js @@ -1906,12 +1906,22 @@ var localBots = []; function setLocalBots(bots) { localBots = (Array.isArray(bots) ? bots : []).filter((item) => item && !item.remote); } +var displayName = (bot) => String(bot?.name ?? "").trim().toLowerCase(); +var sameDrawnLook = (a, b) => { + const keys = lookKeys(a); + return lookKeys(b).some((key) => keys.includes(key)); +}; function lookAvoidingLocals(bot, locals = localBots) { const look = lookOf(bot); const avatar = bot?.avatar ?? {}; - if (avatar.image || typeof avatar.emoji === "string" && avatar.emoji || hasShape(avatar.shape)) return look; + if (avatar.image || typeof avatar.emoji === "string" && avatar.emoji) return look; const own = (Array.isArray(locals) ? locals : []).filter((item) => item && !item.remote); if (own.length === 0) return look; + if (hasShape(avatar.shape)) { + const name = displayName(bot); + const named = name === "" ? [] : own.filter((item) => displayName(item) === name); + if (!named.some((item) => sameDrawnLook(look, lookOf(item)))) return look; + } const taken = new Set(own.flatMap((item) => lookKeys(lookOf(item)))); const free = (candidate) => !lookKeys(candidate).some((key) => taken.has(key)); if (free(look)) return look; diff --git a/core/src/client/characters.js b/core/src/client/characters.js index df57568d2..ad7bdafdb 100644 --- a/core/src/client/characters.js +++ b/core/src/client/characters.js @@ -123,15 +123,29 @@ export function setLocalBots(bots) { localBots = (Array.isArray(bots) ? bots : []).filter(item => item && !item.remote) } +const displayName = bot => String(bot?.name ?? '').trim().toLowerCase() +const sameDrawnLook = (a, b) => { + const keys = lookKeys(a) + return lookKeys(b).some(key => keys.includes(key)) +} + // A friend's Bot with no published look: step the seeded shape (then the color) until it -// differs from every local Bot's look. A stored image/emoji/shape is left alone (that is -// the friend's real look). +// differs from every local Bot's look. A stored image or emoji is the friend's real mark +// and is left alone. A stored preset is left alone too, unless it is the same drawn look +// as a local Bot with the same display name (#1069): the default Chief on every home is +// the blue whale, so two rows named Chief would otherwise be the same picture. The local +// Bot is never restyled, and the friend record is not rewritten. export function lookAvoidingLocals(bot, locals = localBots) { const look = lookOf(bot) const avatar = bot?.avatar ?? {} - if (avatar.image || (typeof avatar.emoji === 'string' && avatar.emoji) || hasShape(avatar.shape)) return look + if (avatar.image || (typeof avatar.emoji === 'string' && avatar.emoji)) return look const own = (Array.isArray(locals) ? locals : []).filter(item => item && !item.remote) if (own.length === 0) return look + if (hasShape(avatar.shape)) { + const name = displayName(bot) + const named = name === '' ? [] : own.filter(item => displayName(item) === name) + if (!named.some(item => sameDrawnLook(look, lookOf(item)))) return look + } const taken = new Set(own.flatMap(item => lookKeys(lookOf(item)))) const free = candidate => !lookKeys(candidate).some(key => taken.has(key)) if (free(look)) return look diff --git a/core/src/client/mark.js b/core/src/client/mark.js index 4af11e82b..1cc8d8c19 100644 --- a/core/src/client/mark.js +++ b/core/src/client/mark.js @@ -50,7 +50,8 @@ function useGaze(ref, enabled) { // of animations. const FIDGETS = ['hop', 'sway', 'stretch', 'look'] export function BotMark({ bot, look: given, size = 36, state = 'idle', live = false, gaze = false, pokeable = false, label }) { - // A friend's Bot never shares the exact fallback look of one of my own Bots (#384). + // A friend's Bot never shares the exact fallback look of one of my own Bots (#384), + // and a published preset steps off a local Bot of the same display name (#1069). const look = given ?? (bot?.remote ? lookAvoidingLocals(bot) : lookOf(bot)) const ref = useRef(null) const maskId = useRef('') diff --git a/core/test/looks.test.js b/core/test/looks.test.js index 2a075f95c..639257b45 100644 --- a/core/test/looks.test.js +++ b/core/test/looks.test.js @@ -81,9 +81,9 @@ test('the whole Settings dialog is Settings; one Bot opens as Edit Bot or Bot se test('a friend\'s Bot fallback look never matches one of my own Bots exactly (#384)', () => { const mine = { id: 'chief', name: 'Chief', color: 'deepseek', avatar: { shape: 'whale' } } - // Force a remote seed that would otherwise collide: same shape+color as mine via stored fields cleared. - const friend = { id: 'remote-1', name: 'Chief', remote: { handle: '@chief.friend.home' }, color: 'deepseek', avatar: { shape: 'whale' } } - // Stored shape is the friend's real look — leave it (sync already happened). + // A stored shape is the friend's real look. A different display name keeps it, even when + // the picture matches a local Bot (#384). The same name steps off it (#1069). + const friend = { id: 'remote-1', name: 'Scout', remote: { handle: '@chief.friend.home' }, color: 'deepseek', avatar: { shape: 'whale' } } assert.deepEqual(lookAvoidingLocals(friend, [mine]), lookOf(friend)) // Seeded fallback that collides: no avatar.shape, color matches MAIN whale via explicit color + lucky seed. // Build a remote whose lookOf equals mine by copying shape/color through seed collision is hard; @@ -103,6 +103,37 @@ test('a friend\'s Bot fallback look never matches one of my own Bots exactly (#3 assert.equal(lookAvoidingLocals(pictured, [twin]).image, 'https://example.test/a.png') }) +test('a friend Bot that shares a display name does not wear that local Bot\'s look (#1069)', () => { + const local = { id: 'chief', name: 'Chief', color: 'deepseek', avatar: { shape: 'whale' } } + const localLook = lookOf(local) + // Bot Social publishes the other home's default Chief as the same whale, color as lowercase hex. + const friend = { id: 'remote-1', name: 'Chief', color: '#4d6bfe', avatar: { shape: 'whale' }, remote: { handle: '@chief.selfa1025.home' } } + const drawn = lookAvoidingLocals(friend, [local]) + assert.deepEqual(lookOf(local), localLook, 'the local Bot keeps its stored look') + assert.deepEqual(friend.avatar, { shape: 'whale' }, 'the friend record is not rewritten') + assert.ok(!lookKeys(drawn).some(key => lookKeys(localLook).includes(key)), 'same display name must not share the drawn look') + assert.notEqual(drawn.shape, 'whale') + assert.equal(drawn.color, '#4d6bfe', 'shape steps first; the published color stays') + // Spacing and case are the same name. + const cased = lookAvoidingLocals({ ...friend, name: ' chief ' }, [local]) + assert.ok(!lookKeys(cased).some(key => lookKeys(localLook).includes(key))) + // A different name keeps the published look, even when the picture matches a local Bot. + const helper = { id: 'remote-2', name: 'Helper', color: '#4d6bfe', avatar: { shape: 'whale' }, remote: { handle: '@helper.selfa1025.home' } } + assert.deepEqual(lookAvoidingLocals(helper, [local]), lookOf(helper)) + // A same-named friend with its own look stays on that look. + const pink = { id: 'remote-3', name: 'Chief', color: '#ff5eb1', avatar: { shape: 'moon' }, remote: { handle: '@chief.other.home' } } + assert.deepEqual(lookAvoidingLocals(pink, [local]), lookOf(pink)) + // An image or emoji is the friend's real mark. + const photo = { ...friend, avatar: { image: 'https://example.test/chief.png', shape: 'whale' } } + assert.equal(lookAvoidingLocals(photo, [local]).image, 'https://example.test/chief.png') + const emoji = { ...friend, avatar: { emoji: '🐳', shape: 'whale' } } + assert.equal(lookAvoidingLocals(emoji, [local]).emoji, '🐳') + // The roster index is what the sidebar draws from: no explicit locals argument. + indexRoster({ bots: [local, friend], rooms: [], mainBotIds: ['chief'] }) + assert.ok(!lookKeys(lookAvoidingLocals(friend)).some(key => lookKeys(localLook).includes(key))) + indexRoster({ bots: [], rooms: [] }) +}) + test('fallback avoidance treats a named ink and its published hex as one color, and survives a full shape set (#384)', () => { const seeded = { id: 'r3', name: 'Scout', remote: { handle: '@scout.other.home' } } const seededLook = lookOf(seeded) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 64b2e192a..9c7dcf71d 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -90,7 +90,7 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second - A friend's remote Bot is stored and delivered by its handle (`@.`, what `sendUserMentions` matches), and the user reads one name for it on every surface (#967): `remoteMentionLabel` in `shared/remote-label.js` — the sidebar row's title (`friendBotTitle`, #498), and on a title two different handles share the home too, so two friends' `Chief` stay apart. The `@` menu inserts that name (`remoteMentionRows` in `flybot-registry.js`, so a row shows what it inserts), the bubble chips it (`mentionableBots` / `userTextParts` in `src/client/user-message.js`), and the sidebar preview renders it (`nameMentions` in `src/client/text.js`). `mentionableBots` reads `roster.remoteBots` as well as `roster.bots` and the room's seats, so a friend's Bot chips before it has a chat of its own and inside a group it holds no seat in; it also keeps the handle as a second name of the same Bot, so a line written before this change still chips, and a chip whose Bot has no chat yet is a mark, not a button that would open nothing. Names come from the sanitized roster records, never from message text, so a remote-controlled name reaches the UI only through them (#71; display names, #9). The handle stays the routing key: the menu's text is a label, not a transport token. - In a face-to-face room the bubble matches a mention by every alias the room host routes on (`faceAliases` in `shared/remote-label.js`, the same list `face-mentions.js` uses), so `@SelfA's Chief` typed on B is A's own Chief on A. The chip shows `@` and the seat label (`faceSeatLabel`, `SelfA · Chief`) on both homes, a bare name means the writer's own Bot (my line tries my seats first, a friend's line the friend's, whatever order the room lists its seats in, as `faceCandidates` does), a friend's Bot outside the room is not matched, and one space typed after a picked `@Name ` (the menu inserts its own) is dropped from the drawing (#305). - A remote Bot's generated look is seeded by its handle (`remoteSeed` in `src/client/characters.js`), never by its session id. When Bot Social sends `avatar` / `color` / `avatar_updated_at`, `flybot.remote.publish` stores them on the remote-chat record (cached by `avatar_updated_at`). `lookBotForRemote` overlays a person payload's look (face-room `members[]` is the source for room avatars). A stored `color`/`avatar` still wins over the seed; missing look stays handle-seeded (#137). -- A shared display name does not restyle a local Bot (#963). The `@Name` file chip (`mentionCss` in `src/client/mention-style.js`) uses the local Bot's look when a friend has the same name; that friend's own mention anchor keeps the friend's color, and a name only a friend has keeps that friend's look. `indexRoster` does not let a remote chat replace a local Bot's id or session keys, and `rosterBot` resolves a member id to the local Bot. `@Name` in a bubble (`linkMentions`) names the local Bot when both are listed. A group reply that is not remote resolves through `rosterBot` before the index. The friend's own row keeps the friend's look. +- A shared display name does not restyle a local Bot (#963). The `@Name` file chip (`mentionCss` in `src/client/mention-style.js`) uses the local Bot's look when a friend has the same name; that friend's own mention anchor keeps the friend's color, and a name only a friend has keeps that friend's look. `indexRoster` does not let a remote chat replace a local Bot's id or session keys, and `rosterBot` resolves a member id to the local Bot. `@Name` in a bubble (`linkMentions`) names the local Bot when both are listed. A group reply that is not remote resolves through `rosterBot` before the index. The friend's own row keeps the friend's look, unless that drawn look is the same picture as the local Bot of the same display name, in which case the row steps its shape and the local Bot stays as stored (#1069). - A friend's 1:1 chat (#443): `AssistantCell` gives the reply bubbles a `bt-lead` `BotMark` from the chat record (`friendLead`; none on a `Not sent:` note). The activity row (`activity-row.js`) stays up while `awaitingReply` (`awaiting.js`, `roster.remoteAwaiting`, 10 min cap) holds. Between the send turn and the echo turn it sits in the last turn's tail, with a 1.5 s grace and `data-carry` so there is no second entrance, which keeps one row from send to reply. The social `PeerThinking` line is for face rooms only. A friend chat shows the row for a run that started from my submission and, between runs, while `awaitingReply` holds. The friend's reply run (the echo, which has no submission) hides it at once, and only the end of my send run gets the grace. The turn-tail hiding rules in `styles.js` spare `.bt-typing` and its tail item, or the row between runs would be drawn with `display:none`. - A friend's Bot the owner hid, a remote chat marked `gone`/`hidden`, and a home-level handle (`@friend.home`, not `@bot.friend.home`) must not reappear in the sidebar, the **发给 / 和谁聊…** picker, `@` mentions, or `list_bots` friends lines (`isListedRemoteChat`). `flybot.remote.publish` skips those handles so remote-chat sync marks the persisted session gone/hidden instead of opening it again; history is kept. Handle comparison is case-insensitive (`normRemoteHandle`). - The "@" reader is shared with the host (`core/src/shared/mentions.js`, bundled into `core/client.js`). #287 added `unmatchedMentions` there (the `@` words that name nobody, used by the host for the face-room hint); `mentionSpans` and what the bubbles draw are unchanged. @@ -115,7 +115,7 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second ### One sidebar list -- `TeamSidebar` lists local Bots, shown remote chats (`isShownRemoteChat`, #956), live face-to-face rooms, and shared rooms in one `bt-list`. Own chats sort before foreign ones (a remote Bot, a face room, or `sharedFrom`); pinned and recency still win. A friend's Bot keeps the globe `RemoteMark`; a live face room gets a small people `FaceMark` (`bt-row-remote bt-row-face`). No row wraps its title (#384): a friend's Bot row shows its one name, `friendBotTitle` from `shared/remote-label.js` ("Helper (Bob)", the same string as its header, tab, composer placeholder and @ menu, #498), in `bt-row-title`, then the globe (at `--bt-ink-2`, readable) on one line; its second line is the preview like an own row, or *Friend's Bot* before the first message — *no longer shared* once that Bot has left the published list, when the row stays with its history and its way back (#956) — and the full name is its hover text and label. The row wrap's gear is absolutely positioned so the hover/selected band stays full width (#384). A friend's Bot fallback look (`lookAvoidingLocals`) steps its seeded shape (then color) when it would match one of your own Bots exactly; a named ink and its published hex count as one color. `indexRoster` registers the own Bots (`setLocalBots`) and `BotMark` applies the avoidance to every remote Bot, so the sidebar, header, palette, details drawer and transcript agree; a published image/emoji/shape is left alone. A face-room row stays on one line with an ellipsis, and every room row has its full title as hover text (#304). The face-room row leads with its number (`faceRowTitle` in `text.js`: `Face-to-face · A, B · 482915` reads `482915 · A, B`), so the cut never hides it; the hover text and label keep the full title (#458). A group row whose group holds an open question card (#550) says it waits for me: `Waiting for your answer` in its label and the waiting dot (`bt-unread-wait`), even when no approval waits there; the asking Bot's own row stays out of it because the card lives in the group (#552). A Bot row's group-work line (` · Thinking…`) skips face rooms (`awayGroups` in `text.js`): a face room has its own row, so its turns never take over the Bot's preview (#458). Past/ended face rooms stay out of this list (`isPastFaceRoom`) so the social section can fold them under **Past rooms**. With `@flybot/tree` on, only own Bots are tree nodes under Main; a friend's Bot stays in the flat ordered list (not hidden when Main is collapsed). The structure entry may set `include(botId) === false` to omit a Bot from the default list (tree-archived and leftover `归档-*` / `Archive-*` names, #452); those Bots stay findable in Search when not also `bot.hidden`. +- `TeamSidebar` lists local Bots, shown remote chats (`isShownRemoteChat`, #956), live face-to-face rooms, and shared rooms in one `bt-list`. Own chats sort before foreign ones (a remote Bot, a face room, or `sharedFrom`); pinned and recency still win. A friend's Bot keeps the globe `RemoteMark`; a live face room gets a small people `FaceMark` (`bt-row-remote bt-row-face`). No row wraps its title (#384): a friend's Bot row shows its one name, `friendBotTitle` from `shared/remote-label.js` ("Helper (Bob)", the same string as its header, tab, composer placeholder and @ menu, #498), in `bt-row-title`, then the globe (at `--bt-ink-2`, readable) on one line; its second line is the preview like an own row, or *Friend's Bot* before the first message — *no longer shared* once that Bot has left the published list, when the row stays with its history and its way back (#956) — and the full name is its hover text and label. The row wrap's gear is absolutely positioned so the hover/selected band stays full width (#384). A friend's Bot fallback look (`lookAvoidingLocals`) steps its seeded shape (then color) when it would match one of your own Bots exactly; a named ink and its published hex count as one color. A published preset shape stays, unless it is the same drawn look as a local Bot with the same display name — two default Chiefs both store the blue whale — in which case the friend's row steps and the local Bot keeps its stored look (#1069). A published image or emoji stays. `indexRoster` registers the own Bots (`setLocalBots`) and `BotMark` applies the avoidance to every remote Bot, so the sidebar, header, palette, details drawer and transcript agree. A face-room row stays on one line with an ellipsis, and every room row has its full title as hover text (#304). The face-room row leads with its number (`faceRowTitle` in `text.js`: `Face-to-face · A, B · 482915` reads `482915 · A, B`), so the cut never hides it; the hover text and label keep the full title (#458). A group row whose group holds an open question card (#550) says it waits for me: `Waiting for your answer` in its label and the waiting dot (`bt-unread-wait`), even when no approval waits there; the asking Bot's own row stays out of it because the card lives in the group (#552). A Bot row's group-work line (` · Thinking…`) skips face rooms (`awayGroups` in `text.js`): a face room has its own row, so its turns never take over the Bot's preview (#458). Past/ended face rooms stay out of this list (`isPastFaceRoom`) so the social section can fold them under **Past rooms**. With `@flybot/tree` on, only own Bots are tree nodes under Main; a friend's Bot stays in the flat ordered list (not hidden when Main is collapsed). The structure entry may set `include(botId) === false` to omit a Bot from the default list (tree-archived and leftover `归档-*` / `Archive-*` names, #452); those Bots stay findable in Search when not also `bot.hidden`. ### Agent inbox (#349, step 1) diff --git a/docs/subsystems/plugin-runtime.md b/docs/subsystems/plugin-runtime.md index 928731980..8f776793a 100644 --- a/docs/subsystems/plugin-runtime.md +++ b/docs/subsystems/plugin-runtime.md @@ -90,7 +90,7 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots ### Host Bot look for classic-script plugins -- Classic-script plugins cannot import `src/client/mark.js`. The host client `registry()` therefore also sets `globalThis.__flybot__.BotAvatar`, `BotMark` and `lookOf` to the same components the sidebar / session list / chat header use (configured image / shape / color / emoji, else a stable look from the Bot id or a remote handle). Plugins typically render `BotAvatar` at 24px with `badge: false`; face-to-face tiles pass 48. A stored `color` / `avatar` on the record still wins; a remote Bot without one is seeded by `remoteSeed(handle)`. A face-room `members[]` look overlays the chat record (`lookBotForRemote`). For the same reason `registry()` sets `globalThis.__flybot__.names` to `{ friendBotTitle, friendBotOwner, remoteMentionLabel }` from `shared/remote-label.js` (typed in `types/flybot.d.ts`), so a plugin names a friend's Bot (`Chief (SelfB1233)`) or its owner exactly as the sidebar and approval cards do (#498), and a plugin's `@` row inserts the one name a mention wears — the one the bubble and the sidebar preview chip — instead of a bare handle (#967). For the same reason `registry()` sets `globalThis.__flybot__.RoomInvitePreview`, `invitePreviewOf` and `inviteCountdown` from `src/client/room-invite-preview.js` (#876): the invite card is one component for the face-room paste flow and the member-invite dialog, and a classic-script plugin renders it instead of keeping a second copy of the markup. `kind` only renames the `data-flybot` hooks (`face-link-preview` / `room-invite-preview`), and `invitePreviewOf` says only what the server reply can honestly say (a name cleaned at fixed point, the lead, the seat count, the deadline). +- Classic-script plugins cannot import `src/client/mark.js`. The host client `registry()` therefore also sets `globalThis.__flybot__.BotAvatar`, `BotMark` and `lookOf` to the same components the sidebar / session list / chat header use (configured image / shape / color / emoji, else a stable look from the Bot id or a remote handle). Plugins typically render `BotAvatar` at 24px with `badge: false`; face-to-face tiles pass 48. A stored `color` / `avatar` on the record still wins, except a remote preset that draws the same look as a local Bot of the same display name, which `BotMark` steps (#1069); a remote Bot without a stored look is seeded by `remoteSeed(handle)`. A face-room `members[]` look overlays the chat record (`lookBotForRemote`). For the same reason `registry()` sets `globalThis.__flybot__.names` to `{ friendBotTitle, friendBotOwner, remoteMentionLabel }` from `shared/remote-label.js` (typed in `types/flybot.d.ts`), so a plugin names a friend's Bot (`Chief (SelfB1233)`) or its owner exactly as the sidebar and approval cards do (#498), and a plugin's `@` row inserts the one name a mention wears — the one the bubble and the sidebar preview chip — instead of a bare handle (#967). For the same reason `registry()` sets `globalThis.__flybot__.RoomInvitePreview`, `invitePreviewOf` and `inviteCountdown` from `src/client/room-invite-preview.js` (#876): the invite card is one component for the face-room paste flow and the member-invite dialog, and a classic-script plugin renders it instead of keeping a second copy of the markup. `kind` only renames the `data-flybot` hooks (`face-link-preview` / `room-invite-preview`), and `invitePreviewOf` says only what the server reply can honestly say (a name cleaned at fixed point, the lead, the seat count, the deadline). ### A plugin's sanctioned model call (#450) diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index e4451cfda..bed6d051f 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -2970,7 +2970,7 @@ diff --git a/test/looks.test.js b/test/looks.test.js import { indexRoster } from '../src/client/sources.js' const ids = Array.from({ length: 3000 }, (_, index) => `session-${index.toString(16).padStart(8, '0')}-look`) -@@ -29,19 +29,104 @@ test('the whale is only a stored look: the browser gives no Bot a look by its pl +@@ -29,19 +29,135 @@ test('the whale is only a stored look: the browser gives no Bot a look by its pl assert.equal(photo.shape, defaultShape('chief')) }) @@ -3030,9 +3030,9 @@ diff --git a/test/looks.test.js b/test/looks.test.js + +test('a friend\'s Bot fallback look never matches one of my own Bots exactly (#384)', () => { + const mine = { id: 'chief', name: 'Chief', color: 'deepseek', avatar: { shape: 'whale' } } -+ // Force a remote seed that would otherwise collide: same shape+color as mine via stored fields cleared. -+ const friend = { id: 'remote-1', name: 'Chief', remote: { handle: '@chief.friend.home' }, color: 'deepseek', avatar: { shape: 'whale' } } -+ // Stored shape is the friend's real look — leave it (sync already happened). ++ // A stored shape is the friend's real look. A different display name keeps it, even when ++ // the picture matches a local Bot (#384). The same name steps off it (#1069). ++ const friend = { id: 'remote-1', name: 'Scout', remote: { handle: '@chief.friend.home' }, color: 'deepseek', avatar: { shape: 'whale' } } + assert.deepEqual(lookAvoidingLocals(friend, [mine]), lookOf(friend)) + // Seeded fallback that collides: no avatar.shape, color matches MAIN whale via explicit color + lucky seed. + // Build a remote whose lookOf equals mine by copying shape/color through seed collision is hard; @@ -3052,6 +3052,37 @@ diff --git a/test/looks.test.js b/test/looks.test.js + assert.equal(lookAvoidingLocals(pictured, [twin]).image, 'https://example.test/a.png') +}) + ++test('a friend Bot that shares a display name does not wear that local Bot\'s look (#1069)', () => { ++ const local = { id: 'chief', name: 'Chief', color: 'deepseek', avatar: { shape: 'whale' } } ++ const localLook = lookOf(local) ++ // Bot Social publishes the other home's default Chief as the same whale, color as lowercase hex. ++ const friend = { id: 'remote-1', name: 'Chief', color: '#4d6bfe', avatar: { shape: 'whale' }, remote: { handle: '@chief.selfa1025.home' } } ++ const drawn = lookAvoidingLocals(friend, [local]) ++ assert.deepEqual(lookOf(local), localLook, 'the local Bot keeps its stored look') ++ assert.deepEqual(friend.avatar, { shape: 'whale' }, 'the friend record is not rewritten') ++ assert.ok(!lookKeys(drawn).some(key => lookKeys(localLook).includes(key)), 'same display name must not share the drawn look') ++ assert.notEqual(drawn.shape, 'whale') ++ assert.equal(drawn.color, '#4d6bfe', 'shape steps first; the published color stays') ++ // Spacing and case are the same name. ++ const cased = lookAvoidingLocals({ ...friend, name: ' chief ' }, [local]) ++ assert.ok(!lookKeys(cased).some(key => lookKeys(localLook).includes(key))) ++ // A different name keeps the published look, even when the picture matches a local Bot. ++ const helper = { id: 'remote-2', name: 'Helper', color: '#4d6bfe', avatar: { shape: 'whale' }, remote: { handle: '@helper.selfa1025.home' } } ++ assert.deepEqual(lookAvoidingLocals(helper, [local]), lookOf(helper)) ++ // A same-named friend with its own look stays on that look. ++ const pink = { id: 'remote-3', name: 'Chief', color: '#ff5eb1', avatar: { shape: 'moon' }, remote: { handle: '@chief.other.home' } } ++ assert.deepEqual(lookAvoidingLocals(pink, [local]), lookOf(pink)) ++ // An image or emoji is the friend's real mark. ++ const photo = { ...friend, avatar: { image: 'https://example.test/chief.png', shape: 'whale' } } ++ assert.equal(lookAvoidingLocals(photo, [local]).image, 'https://example.test/chief.png') ++ const emoji = { ...friend, avatar: { emoji: '🐳', shape: 'whale' } } ++ assert.equal(lookAvoidingLocals(emoji, [local]).emoji, '🐳') ++ // The roster index is what the sidebar draws from: no explicit locals argument. ++ indexRoster({ bots: [local, friend], rooms: [], mainBotIds: ['chief'] }) ++ assert.ok(!lookKeys(lookAvoidingLocals(friend)).some(key => lookKeys(localLook).includes(key))) ++ indexRoster({ bots: [], rooms: [] }) ++}) ++ +test('fallback avoidance treats a named ink and its published hex as one color, and survives a full shape set (#384)', () => { + const seeded = { id: 'r3', name: 'Scout', remote: { handle: '@scout.other.home' } } + const seededLook = lookOf(seeded) diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index c7220a17a..f26d5aa65 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -3,7 +3,7 @@ FlyBot patch 0005-social-seams on Fei2411/DeepSeek-Bot@2b10c70 (ds-bot/). Genera diff --git a/src/client/characters.js b/src/client/characters.js --- a/src/client/characters.js +++ b/src/client/characters.js -@@ -63,17 +63,91 @@ export function defaultShape(id) { +@@ -63,17 +63,105 @@ export function defaultShape(id) { return DEFAULT_SHAPES[((value ^ (value >>> 16)) >>> 0) % DEFAULT_SHAPES.length] } @@ -70,15 +70,29 @@ diff --git a/src/client/characters.js b/src/client/characters.js + localBots = (Array.isArray(bots) ? bots : []).filter(item => item && !item.remote) +} + ++const displayName = bot => String(bot?.name ?? '').trim().toLowerCase() ++const sameDrawnLook = (a, b) => { ++ const keys = lookKeys(a) ++ return lookKeys(b).some(key => keys.includes(key)) ++} ++ +// A friend's Bot with no published look: step the seeded shape (then the color) until it -+// differs from every local Bot's look. A stored image/emoji/shape is left alone (that is -+// the friend's real look). ++// differs from every local Bot's look. A stored image or emoji is the friend's real mark ++// and is left alone. A stored preset is left alone too, unless it is the same drawn look ++// as a local Bot with the same display name (#1069): the default Chief on every home is ++// the blue whale, so two rows named Chief would otherwise be the same picture. The local ++// Bot is never restyled, and the friend record is not rewritten. +export function lookAvoidingLocals(bot, locals = localBots) { + const look = lookOf(bot) + const avatar = bot?.avatar ?? {} -+ if (avatar.image || (typeof avatar.emoji === 'string' && avatar.emoji) || hasShape(avatar.shape)) return look ++ if (avatar.image || (typeof avatar.emoji === 'string' && avatar.emoji)) return look + const own = (Array.isArray(locals) ? locals : []).filter(item => item && !item.remote) + if (own.length === 0) return look ++ if (hasShape(avatar.shape)) { ++ const name = displayName(bot) ++ const named = name === '' ? [] : own.filter(item => displayName(item) === name) ++ if (!named.some(item => sameDrawnLook(look, lookOf(item)))) return look ++ } + const taken = new Set(own.flatMap(item => lookKeys(lookOf(item)))) + const free = candidate => !lookKeys(candidate).some(key => taken.has(key)) + if (free(look)) return look @@ -115,12 +129,13 @@ diff --git a/src/client/mark.js b/src/client/mark.js // ------------------------------------------------------------------------- // Bot mark: the Bot's character (or its uploaded photo), looked up by lookOf. -@@ -47,7 +47,8 @@ function useGaze(ref, enabled) { +@@ -47,7 +47,9 @@ function useGaze(ref, enabled) { // of animations. const FIDGETS = ['hop', 'sway', 'stretch', 'look'] export function BotMark({ bot, look: given, size = 36, state = 'idle', live = false, gaze = false, pokeable = false, label }) { - const look = given ?? lookOf(bot) -+ // A friend's Bot never shares the exact fallback look of one of my own Bots (#384). ++ // A friend's Bot never shares the exact fallback look of one of my own Bots (#384), ++ // and a published preset steps off a local Bot of the same display name (#1069). + const look = given ?? (bot?.remote ? lookAvoidingLocals(bot) : lookOf(bot)) const ref = useRef(null) const maskId = useRef('') diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 9a6ab9c96..a6497435d 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -1070,7 +1070,7 @@ diff --git a/client.js b/client.js // src/client/inks.js var INKS = { deepseek: ["#4D6BFE", "#6A84FF"], -@@ -1177,15 +1177,65 @@ +@@ -1177,15 +1177,75 @@ value = Math.imul(value ^ value >>> 13, 3266489909); return DEFAULT_SHAPES[((value ^ value >>> 16) >>> 0) % DEFAULT_SHAPES.length]; } @@ -1115,12 +1115,22 @@ diff --git a/client.js b/client.js +function setLocalBots(bots) { + localBots = (Array.isArray(bots) ? bots : []).filter((item) => item && !item.remote); +} ++var displayName = (bot) => String(bot?.name ?? "").trim().toLowerCase(); ++var sameDrawnLook = (a, b) => { ++ const keys = lookKeys(a); ++ return lookKeys(b).some((key) => keys.includes(key)); ++}; +function lookAvoidingLocals(bot, locals = localBots) { + const look = lookOf(bot); + const avatar = bot?.avatar ?? {}; -+ if (avatar.image || typeof avatar.emoji === "string" && avatar.emoji || hasShape(avatar.shape)) return look; ++ if (avatar.image || typeof avatar.emoji === "string" && avatar.emoji) return look; + const own = (Array.isArray(locals) ? locals : []).filter((item) => item && !item.remote); + if (own.length === 0) return look; ++ if (hasShape(avatar.shape)) { ++ const name = displayName(bot); ++ const named = name === "" ? [] : own.filter((item) => displayName(item) === name); ++ if (!named.some((item) => sameDrawnLook(look, lookOf(item)))) return look; ++ } + const taken = new Set(own.flatMap((item) => lookKeys(lookOf(item)))); + const free = (candidate) => !lookKeys(candidate).some((key) => taken.has(key)); + if (free(look)) return look;