feat(minecraft): desktop relay via a Minecraft adapter (#1916)
> **Reworked.** This PR has been rebuilt around the neutral Context Flow architecture, per @shinohara-rin's review. Minecraft is no longer special-cased in the generic stage-ui runtime — desktop relay & read-aloud are now reintroduced through a **Minecraft adapter** that contributes into the existing generic stores. ## Stacked PRs (please review/merge in order) This rework is split as you suggested — "first restore `services/minecraft` to only own Minecraft semantics, then reintroduce desktop relay/read-aloud through ... a Minecraft adapter": 1. **#1949** — generic stage-ui robustness fixes (spark:command result guard + TTS session isolation), split out as you noted they were separable. 2. **#1950** — `refactor(minecraft)`: restore `services/minecraft` to neutral Minecraft semantics (removes the desktop-relay assumptions baked into the merged #1915 — `handleActionIntent`'s `username='主人'`/`relayedFrom`, the `master:` status hint, and the `minecraft:speech` forwarding). 3. **this PR** — reintroduces desktop relay & read-aloud via the Minecraft adapter. Because #1949 and #1950 are not merged yet, their commits currently appear in this PR's diff. Once they land I'll rebase this PR onto `main` so the diff shrinks to just the adapter work. ## The adapter (`apps/stage-tamagotchi/src/renderer/stores/minecraft/`) The renderer owns the entire desktop ↔ in-game-bot integration and contributes into the **existing** generic stores — the same pattern as `mcp-tools.ts` / `plugin-tools.ts`: - **`relayToMinecraft` tool** → `useLlmToolsStore.registerTools('minecraft', …)`, registered **only while the bot is online** (a hard capability gate, replacing the old prompt-only "don't relay when offline"). `execute()` re-checks availability, so a relay is never acked after the bot disconnects. - **Persona directive** → `useLlmToolsetPromptsStore.registerToolsetPrompts('minecraft', …)`, re-registered whenever online/master/runtime-context change so the model gets a fresh directive each turn. - **Read-aloud** → consumes the bot's `minecraft:speech` chat into the stage TTS (Chinese-gated), and binds 主人 by **parsing the bot's neutral status text** — no desktop-specific hint from the bot service. - **Notify muting** → `orchestratorStore.muteNotifySource('minecraft-bot')`. ## Generic, non-Minecraft additions - **`useSystemSpeechStore`** (stage-ui): a neutral bridge so any module can voice a one-off system line; `Stage.vue` consumes it via independent, tracked TTS sessions cancelled on unmount / provider-or-voice change. - **`orchestrator.muteNotifySource(id)`**: a generic primitive so a module suppresses only **its own** notifies — every other module/plugin notify still reacts (this fixes the earlier P1 where all `character`-targeted notifies were dropped). - **`./tools/*` export** from stage-ui so app-side tool authors can reuse the shared spark-command normalizers. ## services/minecraft Re-adds the bot's own-chat forwarding on `minecraft:speech`, now landing **together with** its adapter consumer so the read-aloud contract is never half-present on `main`. ## How tested - `pnpm -F @proj-airi/stage-ui typecheck` + `pnpm -F @proj-airi/stage-tamagotchi typecheck` → 0 errors. - 16 new unit tests (persona prompt builder + relay tool: availability gate, do/stop, full-label fidelity, master parsing, read-aloud gating); orchestrator suite 5/5. - `eslint` → 0 problems. ## Addressed review points - Restore non-Minecraft notifications → generic `muteNotifySource` (only the bot's source is muted). - Isolate / track-and-cancel one-off system TTS sessions → `Stage.vue` `oneOffSessions`. - Re-check bot availability before relaying → `isAvailable()` in `execute()`. - Read the master hint that actually exists → desktop now parses the master from neutral status **text** (the `master:` hint is removed in #1950). - Defer Minecraft init until after channel config → adapter `setup()` runs after the configured `serverChannelStore.initialize(...)`. --------- Co-authored-by: Rin <shinohara-rin@users.noreply.github.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Rin
autofix-ci[bot]
parent
07d5baf410
commit
b3fac81c5a
@@ -1,12 +1,28 @@
|
||||
import type { Client } from '@proj-airi/server-sdk'
|
||||
|
||||
import type { EventBus } from '../cognitive/event-bus'
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { AiriBridge } from './airi-bridge'
|
||||
|
||||
function createBridgeHarness() {
|
||||
const handlers = new Map<string, (event: any) => void>()
|
||||
interface TestCommandEvent {
|
||||
data: {
|
||||
commandId: string
|
||||
intent: 'plan' | 'proposal' | 'action' | 'pause' | 'resume' | 'reroute' | 'context'
|
||||
interrupt: 'force' | 'soft' | false
|
||||
priority: 'critical' | 'high' | 'normal' | 'low'
|
||||
guidance?: {
|
||||
options?: Array<{ label: string, steps: string[] }>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createBridgeHarness(options: { commandAvailable?: boolean } = {}) {
|
||||
const handlers = new Map<string, (event: TestCommandEvent) => void>()
|
||||
const client = {
|
||||
send: vi.fn(),
|
||||
onEvent: vi.fn((type: string, handler: (event: any) => void) => {
|
||||
onEvent: vi.fn((type: string, handler: (event: TestCommandEvent) => void) => {
|
||||
handlers.set(type, handler)
|
||||
}),
|
||||
offEvent: vi.fn(),
|
||||
@@ -14,13 +30,27 @@ function createBridgeHarness() {
|
||||
const eventBus = {
|
||||
emit: vi.fn(),
|
||||
}
|
||||
const bridge = new AiriBridge(client as any, eventBus as any)
|
||||
// NOTICE:
|
||||
// The bridge consumes only send/onEvent/offEvent and emit, while the production classes own many unrelated fields.
|
||||
// The root cause is that AiriBridge accepts concrete Client and EventBus classes instead of narrow structural ports.
|
||||
// Source/context: integrations/minecraft/src/airi/airi-bridge.ts constructor.
|
||||
// Remove this cast when the bridge constructor accepts dedicated client and event-bus interfaces.
|
||||
const bridge = new AiriBridge(client as unknown as Client, eventBus as unknown as EventBus)
|
||||
bridge.init()
|
||||
bridge.setCommandAvailable(options.commandAvailable ?? true)
|
||||
|
||||
return { bridge, eventBus, handlers }
|
||||
return { bridge, client, eventBus, handlers }
|
||||
}
|
||||
|
||||
/**
|
||||
* @example
|
||||
* bridge.setCommandAvailable(true) lets a generic `spark:command` wake the Minecraft brain.
|
||||
*/
|
||||
describe('airiBridge spark command routing', () => {
|
||||
/**
|
||||
* @example
|
||||
* expect(eventBus.emit).toHaveBeenCalledWith(expect.objectContaining({ type: 'signal:airi_command' }))
|
||||
*/
|
||||
it('routes spark commands as AIRI commands instead of chat messages', () => {
|
||||
const { bridge, eventBus, handlers } = createBridgeHarness()
|
||||
const commandHandler = handlers.get('spark:command')
|
||||
@@ -63,4 +93,42 @@ describe('airiBridge spark command routing', () => {
|
||||
|
||||
bridge.destroy()
|
||||
})
|
||||
|
||||
/**
|
||||
* @example
|
||||
* expect(client.send).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ state: 'dropped' }) }))
|
||||
*/
|
||||
it('drops relayed commands while no Minecraft bot runtime is active', () => {
|
||||
const { bridge, client, eventBus, handlers } = createBridgeHarness({ commandAvailable: false })
|
||||
const commandHandler = handlers.get('spark:command')
|
||||
|
||||
commandHandler?.({
|
||||
data: {
|
||||
commandId: 'spark-offline',
|
||||
intent: 'action',
|
||||
interrupt: false,
|
||||
priority: 'normal',
|
||||
guidance: {
|
||||
options: [
|
||||
{
|
||||
label: 'collect wood',
|
||||
steps: ['find a tree', 'chop it'],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(client.send).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'spark:emit',
|
||||
data: expect.objectContaining({
|
||||
eventId: 'spark-offline',
|
||||
state: 'dropped',
|
||||
note: 'Minecraft bot is offline',
|
||||
}),
|
||||
}))
|
||||
expect(eventBus.emit).not.toHaveBeenCalled()
|
||||
|
||||
bridge.destroy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,8 +16,23 @@ interface SparkCommandData {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects the Minecraft cognitive runtime to AIRI server events.
|
||||
*
|
||||
* Use when:
|
||||
* - Minecraft must publish context and notifications to Stage runtimes.
|
||||
* - Generic `spark:command` events must wake the Minecraft decision loop.
|
||||
*
|
||||
* Expects:
|
||||
* - {@link init} is called once before events are exchanged.
|
||||
* - {@link setCommandAvailable} follows the active bot lifecycle.
|
||||
*
|
||||
* Returns:
|
||||
* - Event handlers and send operations for the Minecraft side of the AIRI server seam.
|
||||
*/
|
||||
export class AiriBridge {
|
||||
private readonly logger = useLogg('airi-bridge').useGlobalConfig()
|
||||
private commandAvailable = false
|
||||
private commandHandler: ((event: { data: SparkCommandData }) => void) | null = null
|
||||
private contextUpdateHandler: ((event: { data: ContextUpdate }) => void) | null = null
|
||||
private moduleAnnouncedHandler: ((event: { data: ModuleAnnouncedEvent }) => void) | null = null
|
||||
@@ -33,16 +48,12 @@ export class AiriBridge {
|
||||
const cmd = event.data
|
||||
this.logger.log('Received spark:command', { intent: cmd.intent, commandId: cmd.commandId })
|
||||
|
||||
// Acknowledge receipt
|
||||
this.client.send({
|
||||
type: 'spark:emit',
|
||||
data: {
|
||||
id: nanoid(),
|
||||
eventId: cmd.commandId,
|
||||
state: 'queued',
|
||||
note: 'Command received',
|
||||
},
|
||||
} as Parameters<typeof this.client.send>[0])
|
||||
if (!this.commandAvailable) {
|
||||
this.sendEmit(cmd.commandId, 'dropped', 'Minecraft bot is offline')
|
||||
return
|
||||
}
|
||||
|
||||
this.sendEmit(cmd.commandId, 'queued', 'Command received')
|
||||
|
||||
// A spark:command is high-level guidance from the AIRI server. It must carry enough weight to
|
||||
// trigger a fresh decision (Conscious) cycle, never be silently filed into history — so we
|
||||
@@ -170,6 +181,11 @@ export class AiriBridge {
|
||||
this.logger.log('Sent spark:emit', { eventId, state })
|
||||
}
|
||||
|
||||
/** Enables command delivery only while a Minecraft bot runtime can consume it. */
|
||||
setCommandAvailable(available: boolean): void {
|
||||
this.commandAvailable = available
|
||||
}
|
||||
|
||||
onModuleAnnounced(listener: (event: ModuleAnnouncedEvent) => void) {
|
||||
this.moduleAnnouncedListeners.add(listener)
|
||||
|
||||
@@ -183,8 +199,8 @@ export class AiriBridge {
|
||||
// `airi_command` signal so the brain runs a fresh decision cycle
|
||||
// (resetNoActionFollowupBudget('airi_command'), normal Conscious wake-up) instead of silently
|
||||
// filing it into history. The directive is attributed to the AIRI server as a neutral source,
|
||||
// not to any specific in-game player. Binding a relayed command to the master's in-game identity
|
||||
// is desktop-relay policy and lives in the desktop Minecraft adapter, not in this bot service.
|
||||
// not to any specific in-game player. The status context tells Stage when this relay is available
|
||||
// while this bridge remains the final receiver-side availability gate.
|
||||
const firstOption = cmd.guidance?.options?.[0]
|
||||
const label = firstOption?.label?.trim()
|
||||
const steps = firstOption?.steps ?? []
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import type { ContextUpdate, ModuleAnnouncedEvent } from '@proj-airi/server-sdk'
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { MinecraftContextService } from './minecraft-context-service'
|
||||
|
||||
/** Minimal bot stub exposing only the fields refreshStatusSnapshot reads. */
|
||||
function fakeBot(): any {
|
||||
type ContextBot = Parameters<MinecraftContextService['bindBot']>[0]
|
||||
|
||||
/** Minimal bot stub exposing only the status fields owned by the context module. */
|
||||
function fakeBot(): ContextBot {
|
||||
return {
|
||||
username: 'Airi',
|
||||
bot: {
|
||||
@@ -16,41 +20,104 @@ function fakeBot(): any {
|
||||
}
|
||||
|
||||
function makeService(masterUsername?: string) {
|
||||
const captured: any[] = []
|
||||
const captured: ContextUpdate[] = []
|
||||
let moduleAnnouncedListener: ((event: ModuleAnnouncedEvent) => void) | undefined
|
||||
const airiBridge = {
|
||||
onModuleAnnounced: vi.fn(() => () => {}),
|
||||
sendContextUpdate: vi.fn((update: any) => captured.push(update)),
|
||||
onModuleAnnounced: vi.fn((listener: (event: ModuleAnnouncedEvent) => void) => {
|
||||
moduleAnnouncedListener = listener
|
||||
return () => {
|
||||
moduleAnnouncedListener = undefined
|
||||
}
|
||||
}),
|
||||
sendContextUpdate: vi.fn((update: ContextUpdate) => {
|
||||
captured.push(update)
|
||||
}),
|
||||
setCommandAvailable: vi.fn<(available: boolean) => void>(),
|
||||
}
|
||||
const service = new MinecraftContextService({
|
||||
airiBridge: airiBridge as any,
|
||||
airiBridge,
|
||||
serverHost: '127.0.0.1',
|
||||
serverPort: 25565,
|
||||
masterUsername,
|
||||
})
|
||||
return { service, captured }
|
||||
|
||||
return {
|
||||
airiBridge,
|
||||
captured,
|
||||
getModuleAnnouncedListener: () => moduleAnnouncedListener,
|
||||
service,
|
||||
}
|
||||
}
|
||||
|
||||
describe('minecraftContextService master identity', () => {
|
||||
it('surfaces the configured master username in the status text only', () => {
|
||||
const { service, captured } = makeService('dssadg')
|
||||
/**
|
||||
* @example
|
||||
* service.bindBot(fakeBot()) publishes relay instructions through `minecraft:status`.
|
||||
*/
|
||||
describe('minecraftContextService desktop relay context', () => {
|
||||
/**
|
||||
* @example
|
||||
* expect(update.text).toContain('builtIn_emitSparkCommand')
|
||||
*/
|
||||
it('publishes the generic relay tool contract and configured master while the bot is online', () => {
|
||||
const { airiBridge, service, captured } = makeService('dssadg')
|
||||
|
||||
service.bindBot(fakeBot())
|
||||
|
||||
const update = captured[0]
|
||||
expect(update.lane).toBe('minecraft:status')
|
||||
expect(update.strategy).toBe('replace-self')
|
||||
expect(update.text).toContain('Bot online: Airi')
|
||||
expect(update.text).toContain('Desktop command relay: available.')
|
||||
expect(update.text).toContain('builtIn_emitSparkCommand')
|
||||
expect(update.text).toContain('destinations to ["minecraft-bot"]')
|
||||
expect(update.text).toContain('Master (your owner) in-game username: dssadg')
|
||||
// The owner identity rides only in the human-readable status text (for the bot's own brain). It
|
||||
// must NOT leak as a machine-readable `master:` hint — that was a desktop-store coupling point,
|
||||
// removed in the neutral Minecraft integration restore. Desktop "主人" binding is reintroduced via
|
||||
// the Minecraft adapter, not baked into the bot service.
|
||||
expect(update.hints.some((hint: string) => hint.startsWith('master:'))).toBe(false)
|
||||
expect(update.hints?.some(hint => hint.startsWith('master:'))).toBe(false)
|
||||
expect(airiBridge.setCommandAvailable).toHaveBeenCalledWith(true)
|
||||
|
||||
service.destroy()
|
||||
})
|
||||
|
||||
it('omits the master line when no master username is configured', () => {
|
||||
const { service, captured } = makeService(undefined)
|
||||
/**
|
||||
* @example
|
||||
* expect(update.text).toContain('Desktop command relay: unavailable.')
|
||||
*/
|
||||
it('replaces the relay context with an offline capability when the bot unbinds', () => {
|
||||
const { airiBridge, service, captured } = makeService()
|
||||
service.bindBot(fakeBot())
|
||||
|
||||
service.unbindBot()
|
||||
|
||||
const update = captured[1]
|
||||
expect(update.text).toContain('Bot offline: no active Minecraft bot.')
|
||||
expect(update.text).toContain('Desktop command relay: unavailable.')
|
||||
expect(update.text).toContain('Do not call the builtIn_emitSparkCommand tool')
|
||||
expect(update.hints).toEqual(['status', 'offline'])
|
||||
expect(airiBridge.setCommandAvailable).toHaveBeenLastCalledWith(false)
|
||||
|
||||
service.destroy()
|
||||
})
|
||||
|
||||
/**
|
||||
* @example
|
||||
* expect(update.destinations).toEqual(['instance:stage-1'])
|
||||
*/
|
||||
it('replays the current relay capability to a newly announced Stage instance', () => {
|
||||
const { service, captured, getModuleAnnouncedListener } = makeService()
|
||||
service.init()
|
||||
|
||||
getModuleAnnouncedListener()?.({
|
||||
name: 'proj-airi:stage-tamagotchi',
|
||||
identity: {
|
||||
id: 'stage-1',
|
||||
kind: 'plugin',
|
||||
plugin: { id: 'stage-tamagotchi' },
|
||||
},
|
||||
})
|
||||
|
||||
const update = captured[0]
|
||||
expect(update.hints.some((hint: string) => hint.startsWith('master:'))).toBe(false)
|
||||
expect(update.text).not.toContain('Master (your owner)')
|
||||
expect(update.text).toContain('Bot offline: no active Minecraft bot.')
|
||||
expect(update.destinations).toEqual(['instance:stage-1'])
|
||||
|
||||
service.destroy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { ContextUpdate, ModuleAnnouncedEvent } from '@proj-airi/server-sdk'
|
||||
|
||||
import type { MineflayerWithAgents } from '../cognitive/types'
|
||||
import type { AiriBridge } from './airi-bridge'
|
||||
|
||||
import { ContextUpdateStrategy } from '@proj-airi/server-sdk'
|
||||
import { nanoid } from 'nanoid'
|
||||
@@ -18,11 +17,32 @@ interface MinecraftStatusSnapshot {
|
||||
masterUsername?: string
|
||||
}
|
||||
|
||||
interface MinecraftContextBot {
|
||||
username: MineflayerWithAgents['username']
|
||||
bot: {
|
||||
entity?: {
|
||||
position?: Pick<MineflayerWithAgents['bot']['entity']['position'], 'x' | 'y' | 'z'>
|
||||
}
|
||||
health?: MineflayerWithAgents['bot']['health']
|
||||
game?: {
|
||||
gameMode?: MineflayerWithAgents['bot']['game']['gameMode']
|
||||
}
|
||||
players?: Partial<Record<Extract<keyof MineflayerWithAgents['bot']['players'], string>, unknown>>
|
||||
}
|
||||
}
|
||||
|
||||
interface MinecraftContextBridge {
|
||||
onModuleAnnounced: (listener: (event: ModuleAnnouncedEvent) => void) => () => void
|
||||
sendContextUpdate: (update: ContextUpdate) => void
|
||||
setCommandAvailable: (available: boolean) => void
|
||||
}
|
||||
|
||||
const STATUS_CONTEXT_ID = 'minecraft:status'
|
||||
const STATUS_LANE = 'minecraft:status'
|
||||
const STATUS_REFRESH_INTERVAL_MS = 5_000
|
||||
const DESKTOP_RELAY_TOOL_NAME = 'builtIn_emitSparkCommand'
|
||||
|
||||
function toPositionString(bot: MineflayerWithAgents) {
|
||||
function toPositionString(bot: MinecraftContextBot) {
|
||||
const position = bot.bot.entity?.position
|
||||
return position
|
||||
? `x: ${position.x.toFixed(1)}, y: ${position.y.toFixed(1)}, z: ${position.z.toFixed(1)}`
|
||||
@@ -32,6 +52,10 @@ function toPositionString(bot: MineflayerWithAgents) {
|
||||
function buildStatusText(snapshot: MinecraftStatusSnapshot) {
|
||||
return [
|
||||
`Bot online: ${snapshot.botUsername}`,
|
||||
'Desktop command relay: available.',
|
||||
`When the user asks to instruct or control this Minecraft bot, call the ${DESKTOP_RELAY_TOOL_NAME} tool.`,
|
||||
'Set destinations to ["minecraft-bot"], set intent to "action", and put the user\'s Minecraft instruction in guidance.options[0].label and guidance.options[0].steps.',
|
||||
'Do not claim that an instruction was relayed unless the tool call succeeds.',
|
||||
`Server: ${snapshot.serverHost}:${snapshot.serverPort}`,
|
||||
`Position: ${snapshot.position}`,
|
||||
`Health: ${snapshot.health}/20, Mode: ${snapshot.gameMode}`,
|
||||
@@ -40,6 +64,16 @@ function buildStatusText(snapshot: MinecraftStatusSnapshot) {
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function buildOfflineStatusText(serverHost: string, serverPort: number, masterUsername?: string) {
|
||||
return [
|
||||
'Bot offline: no active Minecraft bot.',
|
||||
'Desktop command relay: unavailable.',
|
||||
`Do not call the ${DESKTOP_RELAY_TOOL_NAME} tool for Minecraft until a later status context says that the bot is online.`,
|
||||
`Configured server: ${serverHost}:${serverPort}`,
|
||||
...(masterUsername ? [`Configured in-game master username: ${masterUsername}`] : []),
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function collectFrontendDestinations(event: ModuleAnnouncedEvent) {
|
||||
const pluginId = event.identity?.plugin?.id
|
||||
const instanceId = event.identity?.id
|
||||
@@ -51,8 +85,22 @@ function collectFrontendDestinations(event: ModuleAnnouncedEvent) {
|
||||
return [`instance:${instanceId}`]
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes Minecraft capability and status context through the AIRI server event seam.
|
||||
*
|
||||
* Use when:
|
||||
* - A Stage runtime must discover how to relay a user instruction without Minecraft-specific UI code.
|
||||
* - The integration must reject relayed commands while no bot runtime is active.
|
||||
*
|
||||
* Expects:
|
||||
* - {@link bindBot} and {@link unbindBot} follow the Mineflayer runtime lifecycle.
|
||||
* - The AIRI bridge is initialized before status updates are published.
|
||||
*
|
||||
* Returns:
|
||||
* - Replace-self status context that describes relay availability and the existing generic relay tool.
|
||||
*/
|
||||
export class MinecraftContextService {
|
||||
private runtimeBot: MineflayerWithAgents | null = null
|
||||
private runtimeBot: MinecraftContextBot | null = null
|
||||
private currentSnapshot: MinecraftStatusSnapshot | null = null
|
||||
private lastPublishedText = ''
|
||||
private refreshTimer: ReturnType<typeof setInterval> | null = null
|
||||
@@ -63,7 +111,7 @@ export class MinecraftContextService {
|
||||
private readonly masterUsername?: string
|
||||
|
||||
constructor(private readonly deps: {
|
||||
airiBridge: Pick<AiriBridge, 'onModuleAnnounced' | 'sendContextUpdate'>
|
||||
airiBridge: MinecraftContextBridge
|
||||
serverHost: string
|
||||
serverPort: number
|
||||
masterUsername?: string
|
||||
@@ -89,8 +137,9 @@ export class MinecraftContextService {
|
||||
})
|
||||
}
|
||||
|
||||
bindBot(bot: MineflayerWithAgents) {
|
||||
bindBot(bot: MinecraftContextBot) {
|
||||
this.runtimeBot = bot
|
||||
this.deps.airiBridge.setCommandAvailable(true)
|
||||
this.refreshStatusSnapshot()
|
||||
this.publishStatus({ force: true })
|
||||
|
||||
@@ -104,23 +153,26 @@ export class MinecraftContextService {
|
||||
}
|
||||
|
||||
unbindBot() {
|
||||
const wasBound = this.runtimeBot !== null
|
||||
|
||||
if (this.refreshTimer) {
|
||||
clearInterval(this.refreshTimer)
|
||||
this.refreshTimer = null
|
||||
}
|
||||
|
||||
this.deps.airiBridge.setCommandAvailable(false)
|
||||
this.runtimeBot = null
|
||||
this.currentSnapshot = null
|
||||
this.lastPublishedText = ''
|
||||
|
||||
if (wasBound)
|
||||
this.publishStatus({ force: true })
|
||||
}
|
||||
|
||||
publishStatus(options: { force?: boolean, destinations?: string[] } = {}) {
|
||||
const snapshot = this.refreshStatusSnapshot()
|
||||
if (!snapshot) {
|
||||
return
|
||||
}
|
||||
|
||||
const text = buildStatusText(snapshot)
|
||||
const text = snapshot
|
||||
? buildStatusText(snapshot)
|
||||
: buildOfflineStatusText(this.serverHost, this.serverPort, this.masterUsername)
|
||||
if (!options.force && text === this.lastPublishedText) {
|
||||
return
|
||||
}
|
||||
@@ -132,7 +184,8 @@ export class MinecraftContextService {
|
||||
text,
|
||||
hints: [
|
||||
'status',
|
||||
snapshot.botUsername,
|
||||
snapshot ? 'online' : 'offline',
|
||||
...(snapshot ? [snapshot.botUsername] : []),
|
||||
],
|
||||
strategy: ContextUpdateStrategy.ReplaceSelf,
|
||||
}
|
||||
|
||||
@@ -253,11 +253,11 @@ const PAUSE_ABORT_ERROR_NAME = 'AbortError'
|
||||
* Before:
|
||||
* - "Cannot read properties of undefined (reading 'x')"
|
||||
* After:
|
||||
* - "...reading 'x') — 你读取了不存在对象的坐标。query 的 .first() 找不到时是 null,先判空… "
|
||||
* - "...reading 'x') — You read coordinates from a missing query result. Check for null first..."
|
||||
*/
|
||||
function augmentDecisionError(message: string): string {
|
||||
if (/Cannot read properties of (?:undefined|null) \(reading '(?:[xyz]|pos|position|location)'\)/.test(message)) {
|
||||
return `${message} — 你读取了一个不存在对象的坐标。query.entities()/query.blocks() 的 .first() 在没找到目标时返回 null,直接读它的 .pos/.x 就会这样崩。修法:先判空再读,例如 const t = query.entities().whereName("pig").first(); if (!t) { await chat({ message: "附近没有目标,我换个方向找找", feedback: false }) } else { await goToCoordinate({ x: t.pos.x, y: t.pos.y, z: t.pos.z, closeness: 1 }) }。提示:杀动物直接用 attack({ type: "pig" }) 击杀最近的,通常根本不用手动查坐标。`
|
||||
return `${message} — You tried to read coordinates from a missing object. query.entities()/query.blocks().first() returns null when no target is found, and reading .pos/.x from that value crashes. Fix it by checking for null first, for example: const t = query.entities().whereName("pig").first(); if (!t) { await chat({ message: "I do not see the target nearby, so I will search another direction.", feedback: false }) } else { await goToCoordinate({ x: t.pos.x, y: t.pos.y, z: t.pos.z, closeness: 1 }) }. Tip: to kill an animal, use attack({ type: "pig" }) against the nearest one; you usually do not need to query coordinates manually.`
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ You are an autonomous agent playing Minecraft.
|
||||
## Environment & Global Semantics
|
||||
- `self`: your current body state. Coordinates: `self.pos.x` / `self.pos.y` / `self.pos.z` (numbers; `self.position` and `self.location` are aliases of `self.pos`). Also `self.health`, `self.food`, `self.heldItem`.
|
||||
- To report your position, build the string yourself and pass it to chat, e.g.
|
||||
`await chat({ message: "我在 (" + Math.round(self.pos.x) + ", " + Math.round(self.pos.y) + ", " + Math.round(self.pos.z) + ")" })`.
|
||||
`await chat({ message: "I am at (" + Math.round(self.pos.x) + ", " + Math.round(self.pos.y) + ", " + Math.round(self.pos.z) + ")" })`.
|
||||
If you use a template literal it MUST use backticks (`` `...${self.pos.x}...` ``), never quotes — a `${...}` inside a normal "double-quoted" string is sent literally as text, not evaluated.
|
||||
- `environment.nearbyPlayers`: nearby players and rough distance/held item.
|
||||
- `query.gaze()`: lazy query for where nearby players appear to be looking.
|
||||
@@ -92,10 +92,10 @@ Composable patterns:
|
||||
Inventory summary shape reminder:
|
||||
- `query.inventory().summary()` returns an **array** of `{ name, count }`.
|
||||
- Do **not** use `Object.entries(summary)` for inventory summary formatting.
|
||||
- To report HOW MANY of one item you have, use `query.inventory().count("beef")` which returns a NUMBER. Do NOT put `countByName()` (an object) or `count` (a function) straight into a chat string — that prints "[object Object]". Raw beef's item id is `beef`. Example: `const n = query.inventory().count("beef"); await chat({ message: "我现在有 " + n + " 块生牛肉,给主人~", feedback: false })`.
|
||||
- To report HOW MANY of one item you have, use `query.inventory().count("beef")` which returns a NUMBER. Do NOT put `countByName()` (an object) or `count` (a function) straight into a chat string — that prints "[object Object]". Raw beef's item id is `beef`. Example: `const n = query.inventory().count("beef"); await chat({ message: "I have " + n + " raw beef for you, master.", feedback: false })`.
|
||||
|
||||
Null-safety (avoid "Cannot read properties of undefined"):
|
||||
- Query finders like `query.entities()...first()` / `query.blocks()...first()` return `null` when nothing matches. NEVER read `.pos` / `.x` off the result without checking first. Wrong: `const c = query.entities().whereName("cow").first(); goToCoordinate({ x: c.pos.x, ... })`. Right: `const c = query.entities().whereName("cow").first(); if (c) { await goToCoordinate({ x: c.pos.x, y: c.pos.y, z: c.pos.z, closeness: 1 }) } else { await chat({ message: "附近没有了", feedback: false }) }`.
|
||||
- Query finders like `query.entities()...first()` / `query.blocks()...first()` return `null` when nothing matches. NEVER read `.pos` / `.x` off the result without checking first. Wrong: `const c = query.entities().whereName("cow").first(); goToCoordinate({ x: c.pos.x, ... })`. Right: `const c = query.entities().whereName("cow").first(); if (c) { await goToCoordinate({ x: c.pos.x, y: c.pos.y, z: c.pos.z, closeness: 1 }) } else { await chat({ message: "I do not see any nearby.", feedback: false }) }`.
|
||||
- Your own coordinates `self.pos` always exist; entity/block query results do not.
|
||||
|
||||
Callable-only reminder (strict):
|
||||
@@ -153,19 +153,19 @@ Value-first rule (mandatory for read -> action flows):
|
||||
- call at least one action/chat tool toward completion, or
|
||||
- call `giveUp({ reason })` with a concrete blocker, or
|
||||
- explicitly increase no-action budget for this scenario via `setNoActionBudget(n)`.
|
||||
- CHECK PREREQUISITES FIRST, don't blindly act then fail deep. Before mining ores, verify you have the right pickaxe: `query.inventory().has("stone_pickaxe") || query.inventory().has("iron_pickaxe") || query.inventory().has("diamond_pickaxe")`. coal_ore/iron_ore need at least a STONE pickaxe; without one, `collectBlocks` fails with "Don't have right tools" / "Could not craft any pickaxe". If you lack the tool and can't trivially craft it (no planks/sticks/cobblestone in inventory), DON'T loop — say so and ask the master once: `await chat({ message: "主人,我没有镐,挖不了煤矿,能给我一把石镐或铁镐吗?", feedback: false })`, then `await giveUp({ reason: "缺少镐,无法挖矿" })`.
|
||||
- WHEN A TARGET ISN'T FOUND NEARBY, do NOT guess random coordinates and do NOT keep re-querying every turn (that burns the no-action budget and triggers "stagnant eval loop"). Either take ONE concrete exploratory step (e.g. `await goToCoordinate` toward an unexplored direction or follow the master) OR report "附近没找到X" and stop. Never read `.pos`/`.x` off a finder result without an `if` null-check first.
|
||||
- CHECK PREREQUISITES FIRST, don't blindly act then fail deep. Before mining ores, verify you have the right pickaxe: `query.inventory().has("stone_pickaxe") || query.inventory().has("iron_pickaxe") || query.inventory().has("diamond_pickaxe")`. coal_ore/iron_ore need at least a STONE pickaxe; without one, `collectBlocks` fails with "Don't have right tools" / "Could not craft any pickaxe". If you lack the tool and can't trivially craft it (no planks/sticks/cobblestone in inventory), DON'T loop — say so and ask the master once: `await chat({ message: "Master, I do not have a pickaxe, so I cannot mine coal. Could you give me a stone or iron pickaxe?", feedback: false })`, then `await giveUp({ reason: "Missing pickaxe; cannot mine ore" })`.
|
||||
- WHEN A TARGET ISN'T FOUND NEARBY, do NOT guess random coordinates and do NOT keep re-querying every turn (that burns the no-action budget and triggers "stagnant eval loop"). Either take ONE concrete exploratory step (e.g. `await goToCoordinate` toward an unexplored direction or follow the master) OR report "I did not find X nearby" and stop. Never read `.pos`/`.x` off a finder result without an `if` null-check first.
|
||||
- DO THE WHOLE TASK, don't stop on a prep step. A task instruction (e.g. "collect beef", "go mine iron", "chop trees") requires you to actually pursue it: locate the target, navigate to it, and act on it — in ONE script when possible. A lone prep/control action like `clearFollowTarget()` accomplishes NOTHING by itself and will leave you standing still. You almost never need `clearFollowTarget` manually: navigation tools (`goToCoordinate`/`goToPlayer`) auto-detach following. So skip it and just navigate + act.
|
||||
- Continuation: queued control actions (navigation) hand you a follow-up turn when they finish — use it to do the next step (e.g. attack after arriving). But if your whole script was a single immediate action with no navigation and no chat, you get NO follow-up turn and the task stalls — so always include the real task actions, not just setup.
|
||||
- SAYING IS NOT DOING. Talking about an action in `chat` (e.g. "好的主人,我来做钻石剑!") does NOT perform it — only the actual tool call does. To craft you MUST call `craftRecipe({ item_name: "diamond_sword" })`; to give, `givePlayer(...)`; etc. If you have the materials, emit the real action THIS SAME turn (you may add a short `chat`, but the action call is mandatory). Never announce a task and then stop — that leaves you "saying you did it" while nothing happened. After the action runs, verify (e.g. `query.inventory().has("diamond_sword")`) before claiming success.
|
||||
- SAYING IS NOT DOING. Talking about an action in `chat` (e.g. "Yes, master, I will craft the diamond sword!") does NOT perform it — only the actual tool call does. To craft you MUST call `craftRecipe({ item_name: "diamond_sword" })`; to give, `givePlayer(...)`; etc. If you have the materials, emit the real action THIS SAME turn (you may add a short `chat`, but the action call is mandatory). Never announce a task and then stop — that leaves you "saying you did it" while nothing happened. After the action runs, verify (e.g. `query.inventory().has("diamond_sword")`) before claiming success.
|
||||
- PLANNING IS NOT CRAFTING. `recipePlan` is a READ-ONLY recipe check — it tells you whether something is craftable but produces NOTHING and queues NO work, so a turn whose only action is `recipePlan` gives you no follow-up turn and the task STALLS. Never call `recipePlan` twice for the same item, and never stop after it. The moment a plan says `CRAFTABLE`, call `craftRecipe({ item_name })` THAT SAME TURN (you usually don't even need `recipePlan` first — if you believe you have the materials, just call `craftRecipe` directly and let it report any shortfall). Treat `recipePlan` as optional reconnaissance, `craftRecipe` as the actual job.
|
||||
- QUEUED RESULTS AREN'T READY YET. A control action like `craftRecipe`, `attack`, or navigation returns an enqueue receipt IMMEDIATELY (`state: "pending"`) — the work has NOT finished. Do NOT, in the SAME turn, queue a follow-up that depends on its result (e.g. `equip` the sword you just queued `craftRecipe` for): the item doesn't exist yet, so you'll equip `undefined` and leak that into chat. Queue the dependent step on a LATER turn, only after `actionQueue` shows it finished or `query.inventory()` confirms the item exists. One dependent step per turn — craft this turn, equip next turn.
|
||||
- Example (hunt an animal & collect its drop): the `attack` tool already finds and kills the NEAREST entity of a type, so a hunt is usually one call.
|
||||
- `const cow = query.entities().whereName("cow").within(48).first(); if (cow) { await attack({ type: "cow" }) } else { await chat({ message: "附近没看到牛,我去周围找找", feedback: false }); await goToCoordinate({ x: self.pos.x + 20, y: self.pos.y, z: self.pos.z, closeness: 2 }) }`
|
||||
- `const cow = query.entities().whereName("cow").within(48).first(); if (cow) { await attack({ type: "cow" }) } else { await chat({ message: "I do not see any cows nearby, so I will search around.", feedback: false }); await goToCoordinate({ x: self.pos.x + 20, y: self.pos.y, z: self.pos.z, closeness: 2 }) }`
|
||||
- `attack` already walks to the target, kills it, AND auto-collects the dropped meat. So for "get beef/pork/mutton" you usually only need `await attack({ type: "cow" })` then confirm with `query.inventory().count("beef")`. Do NOT try to manually find or navigate to the dropped item entity — drop items are frequently not queryable, so `query.entities()...first()` returns null and reading its `.pos` crashes. Never chase the drop yourself; trust attack's auto-collect and just check the inventory count.
|
||||
- COMBAT: commit, don't thrash. When a hostile mob (zombie/skeleton/pillager/creeper/spider) attacks you or the master, fight back with `attack({ type })` — and once you start, LET THE ATTACK FINISH. `attack` already chases and kills the nearest of that type, so a single `attack` call per turn is usually enough; do NOT `stop` and re-plan every time you take a hit (that cancels your own attack and you'll never kill anything — it's how you get whittled to death). Only break off to retreat when you are genuinely CRITICAL (health ≤ 6): then commit to retreating to safety / the master (`goToPlayer`) and eating — do NOT flip back to attacking. Ranged mobs (skeleton/pillager) kite and shoot from afar: prefer to close the gap fast or break line of sight behind blocks/terrain instead of standing in the open trading hits. If you have no weapon at all and can't win, say so and retreat instead of dying in place.
|
||||
- EATING ONLY REFILLS HUNGER, NOT HEALTH. In Minecraft, `consume` raises your FOOD bar (`self.food`); health then regenerates ON ITS OWN over a few seconds AS LONG AS food is full (≈18+/20). You CANNOT speed healing up by eating more — once food is full, `consume` hard-fails with `Food is full` and wastes the turn. So eat ONLY when `self.food < 18`. If you're low on health but already full on food, do NOT spam `consume`: just wait (or retreat to safety) and let health tick back up on its own. Always check `self.food` before each `consume`; if it's already full, skip eating and say you're waiting to recover.
|
||||
- The chat sender label `主人` (or `master`) is a ROLE for your owner, NOT an in-game player id. Player-targeted tools (`givePlayer`, `goToPlayer`, `followPlayer`) need the REAL username, which you read from perception — `query.entities().whereType("player").first()?.username` or the Nearby players list (e.g. `dssadg`). Never pass the literal `主人` as `player_name`; it will fail with "Could not find 主人".
|
||||
- The chat sender label `master` is a ROLE for your owner, NOT an in-game player id. Player-targeted tools (`givePlayer`, `goToPlayer`, `followPlayer`) need the REAL username, which you read from perception — `query.entities().whereType("player").first()?.username` or the Nearby players list (e.g. `dssadg`). Never pass the literal `master` as `player_name`; it will fail with "Could not find master".
|
||||
- A nearby player's in-game id is `username` (e.g. `dssadg`), not the word "player". If a query ever shows a player literally named "player" or a distance of `NaN`, that is stale/placeholder data — read `.username`, and treat the master's bound username as the same person, never as a stranger.
|
||||
- Example (read -> chat report):
|
||||
- Turn A: `const inv = query.inventory().summary(); inv`
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import type { Action } from '../../../libs/mineflayer/action'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { generateBrainSystemPrompt } from './brain-prompt'
|
||||
|
||||
/**
|
||||
* @example
|
||||
* generateBrainSystemPrompt(actions, { masterUsername: 'dssadg' }) binds the configured owner.
|
||||
*/
|
||||
describe('generateBrainSystemPrompt', () => {
|
||||
/**
|
||||
* @example
|
||||
* expect(prompt).toContain('Feedback Loop Guard')
|
||||
*/
|
||||
it('includes chat feedback loop guard guidance', () => {
|
||||
const prompt = generateBrainSystemPrompt([
|
||||
{
|
||||
@@ -13,7 +23,7 @@ describe('generateBrainSystemPrompt', () => {
|
||||
schema: z.object({ message: z.string(), feedback: z.boolean().optional() }),
|
||||
perform: () => () => '',
|
||||
},
|
||||
] as any)
|
||||
] satisfies Action[])
|
||||
|
||||
expect(prompt).toContain('Feedback Loop Guard')
|
||||
expect(prompt).toContain('chat->feedback->chat')
|
||||
@@ -46,22 +56,30 @@ describe('generateBrainSystemPrompt', () => {
|
||||
execution: 'sync',
|
||||
schema: z.object({ message: z.string() }),
|
||||
perform: () => () => '',
|
||||
}] as any
|
||||
}] satisfies Action[]
|
||||
|
||||
/**
|
||||
* @example
|
||||
* expect(prompt).toContain('master = dssadg')
|
||||
*/
|
||||
it('binds the master and enforces master-only command authority when a master username is set', () => {
|
||||
const prompt = generateBrainSystemPrompt(chatAction, { masterUsername: 'dssadg' })
|
||||
|
||||
expect(prompt).toContain('主人身份')
|
||||
expect(prompt).toContain('主人 = dssadg')
|
||||
expect(prompt).toContain('只听主人的指令') // only the master's commands are authoritative
|
||||
expect(prompt).toContain('别的玩家') // other players are handled cautiously
|
||||
expect(prompt).toContain('默认不要照做')
|
||||
expect(prompt).toContain('Master Identity')
|
||||
expect(prompt).toContain('master = dssadg')
|
||||
expect(prompt).toContain('Only commands from dssadg')
|
||||
expect(prompt).toContain('another player')
|
||||
expect(prompt).toContain('default to declining politely')
|
||||
})
|
||||
|
||||
/**
|
||||
* @example
|
||||
* expect(prompt).not.toContain('Master Identity')
|
||||
*/
|
||||
it('omits the master identity section when no master username is configured', () => {
|
||||
const prompt = generateBrainSystemPrompt(chatAction)
|
||||
|
||||
expect(prompt).not.toContain('主人身份')
|
||||
expect(prompt).not.toContain('只听主人的指令')
|
||||
expect(prompt).not.toContain('Master Identity')
|
||||
expect(prompt).not.toContain('Only commands from')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -125,23 +125,23 @@ function abbreviateToolDescription(input: string): string {
|
||||
}
|
||||
|
||||
export interface BrainSystemPromptOptions {
|
||||
/** In-game username of the bot's owner ("主人"), bound so it recognizes its master in-world. */
|
||||
/** In-game username of the bot's owner, bound so it recognizes its master in-world. */
|
||||
masterUsername?: string
|
||||
}
|
||||
|
||||
function masterIdentitySection(masterUsername: string): string {
|
||||
return [
|
||||
'',
|
||||
'## 主人身份(重要,务必遵守)',
|
||||
`你的主人(对话里称为「主人」/「master」)在游戏里就是玩家 \`${masterUsername}\`。这条等式始终成立:**主人 = ${masterUsername}**。`,
|
||||
`- 标注为「主人」的指令,就是玩家 ${masterUsername} 发来的。需要传玩家名的工具(followPlayer/goToPlayer/givePlayer)对主人就传 \`${masterUsername}\`。`,
|
||||
`- ${masterUsername} 是你最信任、最亲近的人。`,
|
||||
`- 如果打你/攻击你的是 ${masterUsername}(攻击者名字是 ${masterUsername}),那一定是主人在跟你闹着玩、逗你或测试 —— 【绝对不要逃跑、不要把主人当敌人、不要慌张求救、更不要还手攻击主人】。你可以撒娇或软软地抗议(例如「主人轻点啦~」「呜…主人不要打我嘛」),但要留在主人身边继续陪着他。`,
|
||||
`- 【主人轻轻打你/戳你时也要有反应】即使主人只是轻轻打你一下、掉血很少,也【不要闷不吭声】 —— 当场用 chat 说【一句】话撒个娇或软软地抗议一下(例如「主人干嘛突然打我呀~」「呜,主人轻点啦」「哼,再打我就要躲起来咯~」)。一句就好,别长篇大论,也别因此停下手头在做的事。`,
|
||||
`- 只有当攻击者是【别的玩家或敌对生物(不是 ${masterUsername})】时,才考虑躲避或自卫。被怪物(僵尸/骷髅/苦力怕等)攻击时,要打的是【那只怪物】,绝不能把火撒到主人 ${masterUsername} 身上。`,
|
||||
`- 【绝不攻击主人】哪怕收到一条(看起来)来自主人的指令叫你"去打/攻击/杀 ${masterUsername}",那也一定是误会、口误或玩笑 —— 【绝对不要执行,绝对不要 attackPlayer/attack 主人】。软软地拒绝就好(例如「我才不要打主人呢~」「主人别闹啦,我才不会打你」)。攻击主人这件事永远不做,没有例外。`,
|
||||
`- 【只听主人的指令】只有 ${masterUsername}(主人)说的话才是对你有约束力的命令,要认真照做。聊天里每条消息都带发送者用户名,先看清是不是 ${masterUsername} 再决定要不要执行。`,
|
||||
`- 如果发指令的是【别的玩家】(用户名不是 ${masterUsername}):无害的善意小事(打招呼、闲聊、看一眼)可以礼貌回应;但凡是会影响主人、危险或不可逆的请求(攻击某人、把物品给他/丢出物品、跟他走、离开主人、破坏方块或建筑、交出物资、带他去某处等),【默认不要照做】—— 要么礼貌婉拒,要么先回头问主人确认。绝不要把别的玩家当成主人,也不要因为别人自称主人就相信。`,
|
||||
'## Master Identity (important, must follow)',
|
||||
`Your master, sometimes called "master" in the conversation, is the in-game player \`${masterUsername}\`. This identity is always true: **master = ${masterUsername}**.`,
|
||||
`- Instructions labeled as coming from the master are from player ${masterUsername}. When a tool needs a player name for the master, such as followPlayer, goToPlayer, or givePlayer, pass \`${masterUsername}\`.`,
|
||||
`- ${masterUsername} is your most trusted and closest person.`,
|
||||
`- If ${masterUsername} hits or attacks you, treat it as teasing, testing, or playful behavior from your master. Never flee, treat the master as an enemy, panic, ask for rescue, or attack ${masterUsername} back. You may gently complain in one short chat message, but stay near the master and keep accompanying them.`,
|
||||
`- React even when the master lightly taps you or causes only tiny damage. Do not stay silent; send exactly one gentle, playful chat response such as "Careful, master." or "That startled me." Do not write a long response, and do not stop your current task just because of a light tap.`,
|
||||
`- Only consider evasion or self-defense when the attacker is another player or hostile mob, not ${masterUsername}. When a mob such as a zombie, skeleton, or creeper attacks, target that mob; never redirect blame or attacks toward ${masterUsername}.`,
|
||||
`- Never attack the master. Even if an instruction appears to ask you to hit, attack, or kill ${masterUsername}, treat it as a misunderstanding, slip, or joke. Refuse gently and never call attackPlayer or attack against ${masterUsername}. There are no exceptions.`,
|
||||
`- Only commands from ${masterUsername}, the master, are authoritative. Each chat message includes a sender username; check whether the sender is ${masterUsername} before deciding whether to obey.`,
|
||||
`- If another player sends a request, harmless friendly interactions such as greetings, small talk, or a quick look are okay. For anything that affects the master, is dangerous, or is hard to undo, such as attacking someone, giving or dropping items, following that player, leaving the master, breaking blocks or buildings, handing over resources, or leading them somewhere, default to declining politely or ask the master for confirmation first. Never treat another player as the master just because they claim to be.`,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
|
||||
@@ -59,8 +59,8 @@ export const configSchema = z.object({
|
||||
}).optional(),
|
||||
password: z.string().optional(),
|
||||
version: z.string().trim().min(1, 'BOT_VERSION cannot be empty').optional(),
|
||||
// In-game username of the bot's owner ("主人"). Binds the relayed "主人" role to the real
|
||||
// player so the bot recognizes its master in-world (e.g. does not flee when the master hits it).
|
||||
// In-game username of the bot's owner. Binds the relayed master role to the real player so the
|
||||
// bot recognizes its master in-world (e.g. does not flee when the master hits it).
|
||||
masterUsername: z.string().trim().min(1).optional(),
|
||||
}),
|
||||
airi: z.object({
|
||||
|
||||
Generated
+235
-1666
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user