Compare commits
No commits in common. "9aa0abbf5997198e0a26f185aa6fac8342fb69f0" and "8d7138fc0c5d3b1ecdbc3abdabff42f9db17df1b" have entirely different histories.
9aa0abbf59
...
8d7138fc0c
56 changed files with 26 additions and 8179 deletions
|
|
@ -1,33 +0,0 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npm install:*)",
|
||||
"Bash(npm rebuild:*)",
|
||||
"Bash(cmake --version)",
|
||||
"Bash(node -e \"const bp = require\\(''./node_modules/bedrock-protocol''\\); console.log\\(''bedrock-protocol loaded OK''\\)\")",
|
||||
"Bash(node -e \"const bp = require\\(''bedrock-protocol''\\); console.log\\(''bedrock-protocol loaded OK, version:'', bp.version || ''unknown''\\)\")",
|
||||
"Bash(brew install:*)",
|
||||
"Bash(node -e \"const bp = require\\(''bedrock-protocol''\\); console.log\\(''bedrock-protocol loaded OK''\\)\")",
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(node -e \":*)",
|
||||
"Bash(python3 -c \"import PySide6; print\\(''''PySide6 version:'''', PySide6.__version__\\)\")",
|
||||
"Bash(pip3 install:*)",
|
||||
"Bash(grep -r realm /Users/roberts/development/doug-minecraft/bridge/node_modules/bedrock-protocol/src/*.js)",
|
||||
"Bash(python3 -c \":*)",
|
||||
"Bash(python3 -c \"from dougbot.gui.main_window import MainWindow; print\\(''OK''\\)\")",
|
||||
"Bash(python3 -c \"from dougbot.gui.create_doug import CreateDougScreen; print\\(''OK''\\)\")",
|
||||
"Bash(curl -s -o /dev/null -w \"HTTP %{http_code} — %{time_total}s\" --max-time 10 https://pocket.realms.minecraft.net)",
|
||||
"Bash(curl -s -o /dev/null -w \"HTTP %{http_code} — %{time_total}s\\\\n\" --max-time 15 https://pocket.realms.minecraft.net)",
|
||||
"Bash(npm view:*)",
|
||||
"WebSearch",
|
||||
"WebFetch(domain:wiki.bedrock.dev)",
|
||||
"WebFetch(domain:github.com)",
|
||||
"Bash(node -e \"const bp = require\\(''bedrock-protocol''\\); console.log\\(''bedrock-protocol loaded''\\)\")",
|
||||
"Bash(node -e \"const o = require\\(''''bedrock-protocol/src/options''''\\); console.log\\(''''Latest supported:'''', o.CURRENT_VERSION\\); console.log\\(''''All versions:'''', Object.keys\\(o.Versions\\).slice\\(-5\\)\\)\")",
|
||||
"Bash(node -e \"const o = require\\(''bedrock-protocol/src/options''\\); console.log\\(''CURRENT_VERSION:'', o.CURRENT_VERSION\\); const keys = Object.keys\\(o.Versions\\); console.log\\(''First 5:'', keys.slice\\(0,5\\)\\); console.log\\(''Last 5:'', keys.slice\\(-5\\)\\)\")",
|
||||
"Bash(node dist/index.js --host 192.168.1.90 --port 19140 --username Doug-Offline --offline --ws-port 9999)",
|
||||
"Bash(echo \"Exit: $?\")",
|
||||
"Bash(npm ls:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
15
.env.example
15
.env.example
|
|
@ -1,15 +0,0 @@
|
|||
# DougBot Configuration
|
||||
# Copy this to .env and fill in your values
|
||||
|
||||
# Database (optional - defaults to SQLite)
|
||||
DB_TYPE=sqlite
|
||||
# DB_TYPE=mariadb
|
||||
# DB_HOST=192.168.1.3
|
||||
# DB_PORT=3306
|
||||
# DB_USER=db_user
|
||||
# DB_PASS=your_password
|
||||
# DB_NAME=dougbot
|
||||
|
||||
# Ollama Server
|
||||
OLLAMA_HOST=http://192.168.1.3
|
||||
OLLAMA_PORT=11434
|
||||
29
.gitignore
vendored
29
.gitignore
vendored
|
|
@ -1,15 +1,14 @@
|
|||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
node_modules/
|
||||
bridge/dist/
|
||||
*.egg-info/
|
||||
.eggs/
|
||||
dist/
|
||||
build/
|
||||
.venv/
|
||||
venv/
|
||||
*.db
|
||||
*.sqlite
|
||||
*.png
|
||||
bridge/dist/
|
||||
# ---> VisualStudioCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
!.vscode/*.code-snippets
|
||||
|
||||
# Local History for Visual Studio Code
|
||||
.history/
|
||||
|
||||
# Built Visual Studio Code Extensions
|
||||
*.vsix
|
||||
|
||||
|
|
|
|||
9
LICENSE
Normal file
9
LICENSE
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 roberts
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
3
README.md
Normal file
3
README.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# dougbot
|
||||
|
||||
DougBot is a revamp of the CletusBot for Minecraft. Cletus failed. He was a Java bot, and he was not successful. With Doug, I am hoping to convert to Bedrock and push the boundaries of this concept to make a minecraft bot that will actually play and interact.
|
||||
1654
bridge/package-lock.json
generated
1654
bridge/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,24 +0,0 @@
|
|||
{
|
||||
"name": "dougbot-bridge",
|
||||
"version": "0.1.0",
|
||||
"description": "Minecraft Bedrock protocol bridge for DougBot",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"postinstall": "node patches/patch-raknet.js",
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"bedrock-protocol": "^3.55.0",
|
||||
"minecraft-data": "^3.108.0",
|
||||
"uuid": "^11.1.0",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/ws": "^8.5.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
// Patch jsp-raknet to use RakNet protocol version 11
|
||||
// Newer BDS servers (1.26+) require protocol 11 instead of 10
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const clientFile = path.join(__dirname, '..', 'node_modules', 'jsp-raknet', 'js', 'Client.js');
|
||||
if (fs.existsSync(clientFile)) {
|
||||
let content = fs.readFileSync(clientFile, 'utf8');
|
||||
if (content.includes('RAKNET_PROTOCOL = 10')) {
|
||||
content = content.replace('RAKNET_PROTOCOL = 10', 'RAKNET_PROTOCOL = 11');
|
||||
fs.writeFileSync(clientFile, content);
|
||||
console.log('Patched jsp-raknet to RakNet protocol 11');
|
||||
} else {
|
||||
console.log('jsp-raknet already patched');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
/**
|
||||
* Chat action handler.
|
||||
* Handles sending chat messages through the bridge protocol.
|
||||
*/
|
||||
|
||||
import { BedrockClient } from '../client';
|
||||
import { createLogger } from '../utils/logger';
|
||||
|
||||
const log = createLogger('actions:chat');
|
||||
|
||||
export class ChatAction {
|
||||
private client: BedrockClient;
|
||||
|
||||
constructor(client: BedrockClient) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a chat message to the Minecraft server.
|
||||
* Splits long messages into chunks to avoid Bedrock's character limit.
|
||||
*/
|
||||
sendChat(message: string): { success: boolean; error?: string } {
|
||||
if (!this.client.isReady()) {
|
||||
return { success: false, error: 'Client not connected or not spawned' };
|
||||
}
|
||||
|
||||
// Clean the message — remove characters that may cause bad_packet kicks
|
||||
let clean = message
|
||||
.replace(/\*/g, '') // Remove asterisks (action markers)
|
||||
.replace(/[^\x20-\x7E]/g, '') // Remove non-ASCII
|
||||
.trim();
|
||||
|
||||
if (!clean) {
|
||||
return { success: false, error: 'Message was empty after cleaning' };
|
||||
}
|
||||
|
||||
// Bedrock chat limit — be conservative
|
||||
const MAX_CHAT_LENGTH = 200;
|
||||
|
||||
if (clean.length <= MAX_CHAT_LENGTH) {
|
||||
this.client.sendChat(clean);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Split long messages with delay between chunks
|
||||
const chunks = this.splitMessage(clean, MAX_CHAT_LENGTH);
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
setTimeout(() => {
|
||||
this.client.sendChat(chunks[i]);
|
||||
}, i * 500); // 500ms delay between chunks
|
||||
}
|
||||
|
||||
log.info(`Sending ${chunks.length} chat chunks`);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a long message into chunks, preferring word boundaries.
|
||||
*/
|
||||
private splitMessage(message: string, maxLength: number): string[] {
|
||||
const chunks: string[] = [];
|
||||
let remaining = message;
|
||||
|
||||
while (remaining.length > 0) {
|
||||
if (remaining.length <= maxLength) {
|
||||
chunks.push(remaining);
|
||||
break;
|
||||
}
|
||||
|
||||
// Find the last space within the limit
|
||||
let splitIndex = remaining.lastIndexOf(' ', maxLength);
|
||||
if (splitIndex === -1 || splitIndex < maxLength * 0.5) {
|
||||
splitIndex = maxLength; // Force split if no good break point
|
||||
}
|
||||
|
||||
chunks.push(remaining.substring(0, splitIndex));
|
||||
remaining = remaining.substring(splitIndex).trimStart();
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,189 +0,0 @@
|
|||
/**
|
||||
* Movement actions — walk to position, look at, jump.
|
||||
* Uses player_auth_input for server-authoritative movement (works on Realms).
|
||||
*/
|
||||
|
||||
import { BedrockClient } from '../client';
|
||||
import { createLogger } from '../utils/logger';
|
||||
|
||||
const log = createLogger('actions:movement');
|
||||
|
||||
interface Vec3 {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
}
|
||||
|
||||
// Default input flags — all false
|
||||
const DEFAULT_FLAGS: Record<string, boolean> = {
|
||||
ascend: false, descend: false, north_jump: false, jump_down: false,
|
||||
sprint_down: false, change_height: false, jumping: false,
|
||||
auto_jumping_in_water: false, sneaking: false, sneak_down: false,
|
||||
up: false, down: false, left: false, right: false,
|
||||
up_left: false, up_right: false, want_up: false, want_down: false,
|
||||
want_down_slow: false, want_up_slow: false, sprinting: false,
|
||||
ascend_block: false, descend_block: false, sneak_toggle_down: false,
|
||||
persist_sneak: false, start_sprinting: false, stop_sprinting: false,
|
||||
start_sneaking: false, stop_sneaking: false, start_swimming: false,
|
||||
stop_swimming: false, start_jumping: false, start_gliding: false,
|
||||
stop_gliding: false, item_interact: false, block_action: false,
|
||||
item_stack_request: false, handled_teleport: false, emoting: false,
|
||||
client_predicted_vehicle: false,
|
||||
};
|
||||
|
||||
export class MovementAction {
|
||||
private client: BedrockClient;
|
||||
private moveInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private targetPos: Vec3 | null = null;
|
||||
private isMoving: boolean = false;
|
||||
private onArrival: (() => void) | null = null;
|
||||
private tickCounter: bigint = 0n;
|
||||
|
||||
constructor(client: BedrockClient) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
/** Set callback for when movement completes */
|
||||
setOnArrival(callback: () => void) {
|
||||
this.onArrival = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a player_auth_input packet (server-authoritative movement).
|
||||
*/
|
||||
private sendAuthInput(position: Vec3, yaw: number, moveX: number, moveZ: number, flags: Record<string, boolean> = {}): boolean {
|
||||
const rawClient = this.client.getRawClient();
|
||||
if (!rawClient) return false;
|
||||
|
||||
this.tickCounter++;
|
||||
|
||||
const mergedFlags = { ...DEFAULT_FLAGS, ...flags };
|
||||
|
||||
try {
|
||||
rawClient.write('player_auth_input', {
|
||||
pitch: 0.0,
|
||||
yaw: yaw,
|
||||
position: { x: position.x, y: position.y, z: position.z },
|
||||
move_vector: { x: moveX, z: moveZ },
|
||||
head_yaw: yaw,
|
||||
input_data: mergedFlags,
|
||||
input_mode: 'mouse',
|
||||
play_mode: 'normal',
|
||||
interaction_model: 'touch',
|
||||
interact_rotation: { x: 0.0, z: 0.0 },
|
||||
tick: this.tickCounter,
|
||||
delta: { x: 0.0, y: 0.0, z: 0.0 },
|
||||
analogue_move_vector: { x: moveX, z: moveZ },
|
||||
camera_orientation: { x: 0.0, y: 0.0, z: 0.0 },
|
||||
raw_move_vector: { x: moveX, z: moveZ },
|
||||
});
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
log.error('Auth input failed', { error: e.message });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk toward a target position using player_auth_input.
|
||||
*/
|
||||
walkTo(target: Vec3, speed: number = 4.317): { success: boolean; error?: string } {
|
||||
if (!this.client.isReady()) {
|
||||
return { success: false, error: 'Not connected' };
|
||||
}
|
||||
|
||||
this.stop();
|
||||
this.targetPos = target;
|
||||
this.isMoving = true;
|
||||
|
||||
const TICK_MS = 50; // 20 ticks per second
|
||||
const SPEED_PER_TICK = speed / 20;
|
||||
const ARRIVAL_THRESHOLD = 1.5;
|
||||
|
||||
this.moveInterval = setInterval(() => {
|
||||
if (!this.targetPos || !this.isMoving) {
|
||||
this.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
const pos = this.client.state.position;
|
||||
const dx = this.targetPos.x - pos.x;
|
||||
const dz = this.targetPos.z - pos.z;
|
||||
const distXZ = Math.sqrt(dx * dx + dz * dz);
|
||||
|
||||
if (distXZ < ARRIVAL_THRESHOLD) {
|
||||
log.info('Arrived at target');
|
||||
const cb = this.onArrival;
|
||||
this.stop();
|
||||
if (cb) cb();
|
||||
return;
|
||||
}
|
||||
|
||||
// Normalize direction
|
||||
const nx = dx / distXZ;
|
||||
const nz = dz / distXZ;
|
||||
|
||||
// New position
|
||||
const newX = pos.x + nx * SPEED_PER_TICK;
|
||||
const newZ = pos.z + nz * SPEED_PER_TICK;
|
||||
|
||||
// Calculate yaw (look direction) — Minecraft yaw: 0 = +Z, 90 = -X
|
||||
const yaw = Math.atan2(-nx, nz) * (180 / Math.PI);
|
||||
|
||||
// Send auth input with 'up' flag (forward movement)
|
||||
const sent = this.sendAuthInput(
|
||||
{ x: newX, y: pos.y, z: newZ },
|
||||
yaw,
|
||||
nx, // move_vector x component
|
||||
nz, // move_vector z component
|
||||
{ up: true }
|
||||
);
|
||||
|
||||
if (sent) {
|
||||
// Update our local state
|
||||
this.client.state.position.x = newX;
|
||||
this.client.state.position.z = newZ;
|
||||
} else {
|
||||
this.stop();
|
||||
}
|
||||
}, TICK_MS);
|
||||
|
||||
log.info('Walking to', { target });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Look at a specific position.
|
||||
*/
|
||||
lookAt(target: Vec3): { success: boolean } {
|
||||
if (!this.client.isReady()) return { success: false };
|
||||
|
||||
const pos = this.client.state.position;
|
||||
const dx = target.x - pos.x;
|
||||
const dz = target.z - pos.z;
|
||||
|
||||
const yaw = Math.atan2(-dx, dz) * (180 / Math.PI);
|
||||
|
||||
// Send a single auth input with no movement
|
||||
return { success: this.sendAuthInput(pos, yaw, 0, 0) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all movement.
|
||||
*/
|
||||
stop(): void {
|
||||
if (this.moveInterval) {
|
||||
clearInterval(this.moveInterval);
|
||||
this.moveInterval = null;
|
||||
}
|
||||
this.isMoving = false;
|
||||
this.targetPos = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if currently moving.
|
||||
*/
|
||||
getIsMoving(): boolean {
|
||||
return this.isMoving;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
/**
|
||||
* Authentication helpers for Xbox Live and Realm connections.
|
||||
* Wraps prismarine-auth device code flow.
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { createLogger } from './utils/logger';
|
||||
|
||||
const log = createLogger('auth');
|
||||
|
||||
/** Persistent auth token cache directory */
|
||||
function getAuthCacheDir(): string {
|
||||
return path.join(os.homedir(), '.dougbot', 'auth-cache');
|
||||
}
|
||||
|
||||
export interface AuthConfig {
|
||||
/** Microsoft account email */
|
||||
email: string;
|
||||
/** Directory to cache auth tokens (defaults to ~/.dougbot/auth-cache) */
|
||||
profilesFolder?: string;
|
||||
/** Callback when device code is needed */
|
||||
onDeviceCode?: (code: string, url: string) => void;
|
||||
}
|
||||
|
||||
export interface RealmInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
owner: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build createClient options for Xbox Live authentication.
|
||||
* This handles the device code flow where the user enters a code
|
||||
* at microsoft.com/link.
|
||||
*/
|
||||
export function buildAuthOptions(config: AuthConfig): Record<string, any> {
|
||||
const opts: Record<string, any> = {
|
||||
username: config.email,
|
||||
offline: false,
|
||||
profilesFolder: config.profilesFolder || getAuthCacheDir(),
|
||||
onMsaCode: (data: { user_code: string; verification_uri: string }) => {
|
||||
log.info(`Auth required! Go to ${data.verification_uri} and enter code: ${data.user_code}`);
|
||||
if (config.onDeviceCode) {
|
||||
config.onDeviceCode(data.user_code, data.verification_uri);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return opts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build createClient options for connecting to a Realm.
|
||||
* The realm can be selected by ID or by a picker function.
|
||||
*/
|
||||
export function buildRealmOptions(
|
||||
config: AuthConfig,
|
||||
realmId?: string,
|
||||
): Record<string, any> {
|
||||
const authOpts = buildAuthOptions(config);
|
||||
|
||||
if (realmId) {
|
||||
// Connect to a specific Realm by ID
|
||||
authOpts.realms = { realmId };
|
||||
} else {
|
||||
// Auto-pick: connect to the first available Realm
|
||||
authOpts.realms = {
|
||||
pickRealm: (realms: any[]) => {
|
||||
log.info(`Found ${realms.length} Realms:`);
|
||||
for (const r of realms) {
|
||||
log.info(` - [${r.id}] ${r.name} (owner: ${r.ownerUUID}, state: ${r.state})`);
|
||||
}
|
||||
// Return the first active realm
|
||||
const active = realms.find((r: any) => r.state === 'OPEN') || realms[0];
|
||||
log.info(`Connecting to Realm: ${active.name} (ID: ${active.id})`);
|
||||
return active;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return authOpts;
|
||||
}
|
||||
|
||||
/**
|
||||
* List available Realms for an authenticated account.
|
||||
* This triggers auth if needed, then returns the Realm list.
|
||||
*/
|
||||
export async function listRealms(config: AuthConfig): Promise<RealmInfo[]> {
|
||||
try {
|
||||
// Dynamic import to avoid issues if prismarine-realms isn't available
|
||||
const { Authflow, Titles } = require('prismarine-auth');
|
||||
const { RealmAPI } = require('prismarine-realms');
|
||||
|
||||
const codeCallback = config.onDeviceCode
|
||||
? (data: any) => config.onDeviceCode!(data.user_code, data.verification_uri)
|
||||
: undefined;
|
||||
|
||||
const authflow = new Authflow(
|
||||
config.email,
|
||||
config.profilesFolder || undefined,
|
||||
{
|
||||
authTitle: Titles.MinecraftNintendoSwitch,
|
||||
deviceType: 'Nintendo',
|
||||
flow: 'live',
|
||||
},
|
||||
codeCallback,
|
||||
);
|
||||
|
||||
const api = RealmAPI.from(authflow, 'bedrock');
|
||||
const realms = await api.getRealms();
|
||||
|
||||
return realms.map((r: any) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
owner: r.ownerUUID || 'unknown',
|
||||
state: r.state || 'unknown',
|
||||
}));
|
||||
} catch (err: any) {
|
||||
log.error('Failed to list Realms', { error: err.message });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,365 +0,0 @@
|
|||
/**
|
||||
* Bedrock protocol client wrapper.
|
||||
* Manages the connection to a Minecraft Bedrock server and
|
||||
* provides event hooks for the bridge to consume.
|
||||
*/
|
||||
|
||||
import { createClient, Client } from 'bedrock-protocol';
|
||||
import { EventEmitter } from 'events';
|
||||
import { createLogger } from './utils/logger';
|
||||
import { Vec3 } from './utils/vec3';
|
||||
|
||||
const log = createLogger('client');
|
||||
|
||||
export interface ClientOptions {
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
offline: boolean;
|
||||
version?: string;
|
||||
/** Extra options merged into createClient (auth, realms, etc.) */
|
||||
extraOptions?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface ClientState {
|
||||
connected: boolean;
|
||||
spawned: boolean;
|
||||
position: Vec3;
|
||||
health: number;
|
||||
food: number;
|
||||
gameTime: number;
|
||||
dayTime: number;
|
||||
}
|
||||
|
||||
export class BedrockClient extends EventEmitter {
|
||||
private client: Client | null = null;
|
||||
private options: ClientOptions;
|
||||
public state: ClientState;
|
||||
|
||||
constructor(options: ClientOptions) {
|
||||
super();
|
||||
this.options = options;
|
||||
this.state = {
|
||||
connected: false,
|
||||
spawned: false,
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
health: 20,
|
||||
food: 20,
|
||||
gameTime: 0,
|
||||
dayTime: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
log.info(`Connecting to ${this.options.host}:${this.options.port} as ${this.options.username}`);
|
||||
|
||||
try {
|
||||
const clientOpts: any = {
|
||||
host: this.options.host,
|
||||
port: this.options.port,
|
||||
username: this.options.username,
|
||||
offline: this.options.offline,
|
||||
version: this.options.version || '26.10',
|
||||
raknetBackend: 'jsp-raknet',
|
||||
connectTimeout: 20000,
|
||||
};
|
||||
|
||||
// Merge extra options (auth, realms, etc.)
|
||||
if (this.options.extraOptions) {
|
||||
Object.assign(clientOpts, this.options.extraOptions);
|
||||
}
|
||||
|
||||
this.client = createClient(clientOpts);
|
||||
|
||||
this.setupEventHandlers();
|
||||
|
||||
log.info('Client created, waiting for spawn...');
|
||||
} catch (err: any) {
|
||||
log.error('Failed to create client', { error: err.message });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private setupEventHandlers(): void {
|
||||
if (!this.client) return;
|
||||
|
||||
// Connection events
|
||||
this.client.on('join', () => {
|
||||
log.info('Joined server');
|
||||
this.state.connected = true;
|
||||
this.emit('connected');
|
||||
});
|
||||
|
||||
this.client.on('spawn', () => {
|
||||
log.info('Spawned in world');
|
||||
this.state.spawned = true;
|
||||
|
||||
// Critical: send initialization packets so the server treats us as a real player
|
||||
// Without this, the bot is stuck in an immortal ghost state (issue #523)
|
||||
const rawClient = this.client as any;
|
||||
try {
|
||||
const runtimeId = rawClient?.entityId ?? rawClient?.startGameData?.runtime_entity_id ?? 0n;
|
||||
rawClient?.write('set_local_player_as_initialized', {
|
||||
runtime_entity_id: BigInt(runtimeId),
|
||||
});
|
||||
log.info('Sent player initialization');
|
||||
} catch (err: any) {
|
||||
log.warn('Failed to send player init', { error: err.message });
|
||||
}
|
||||
|
||||
this.emit('spawn_complete', { position: this.state.position });
|
||||
});
|
||||
|
||||
this.client.on('close', () => {
|
||||
log.info('Disconnected from server');
|
||||
this.state.connected = false;
|
||||
this.state.spawned = false;
|
||||
this.emit('disconnected', { reason: 'connection_closed' });
|
||||
});
|
||||
|
||||
this.client.on('error', (err: any) => {
|
||||
const errorMsg = err?.message || String(err);
|
||||
const errorStack = err?.stack || '';
|
||||
const errorCode = err?.code || '';
|
||||
log.error('Client error', { error: errorMsg, code: errorCode, stack: errorStack });
|
||||
this.emit('error', { message: errorMsg, code: errorCode });
|
||||
});
|
||||
|
||||
this.client.on('kick', (reason: any) => {
|
||||
log.warn('Kicked from server', { reason });
|
||||
this.state.connected = false;
|
||||
this.emit('disconnected', { reason: `kicked: ${JSON.stringify(reason)}` });
|
||||
});
|
||||
|
||||
// Chat messages
|
||||
this.client.on('text', (packet: any) => {
|
||||
const sender = packet.source_name || packet.xuid || 'Unknown';
|
||||
const message = packet.message || '';
|
||||
const type = packet.type || 'chat';
|
||||
|
||||
log.info('Chat received', { sender, message, type });
|
||||
|
||||
// Skip our own messages and system messages with no sender
|
||||
if (sender === this.options.username) return;
|
||||
if (!message || message.trim() === '') return;
|
||||
|
||||
this.emit('chat_message', { sender, message, type });
|
||||
});
|
||||
|
||||
// Player position updates (our own position)
|
||||
this.client.on('move_player', (packet: any) => {
|
||||
if (packet.runtime_id === this.getRuntimeId()) {
|
||||
this.state.position = {
|
||||
x: packet.position.x,
|
||||
y: packet.position.y,
|
||||
z: packet.position.z,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Respawn / position correction
|
||||
this.client.on('respawn', (packet: any) => {
|
||||
this.state.position = {
|
||||
x: packet.position?.x ?? this.state.position.x,
|
||||
y: packet.position?.y ?? this.state.position.y,
|
||||
z: packet.position?.z ?? this.state.position.z,
|
||||
};
|
||||
log.info('Respawned', { position: this.state.position });
|
||||
this.emit('respawn', { position: this.state.position });
|
||||
});
|
||||
|
||||
// Health and food updates
|
||||
this.client.on('update_attributes', (packet: any) => {
|
||||
if (!packet.attributes) return;
|
||||
for (const attr of packet.attributes) {
|
||||
if (attr.name === 'minecraft:health') {
|
||||
const oldHealth = this.state.health;
|
||||
this.state.health = attr.current ?? attr.value ?? this.state.health;
|
||||
if (this.state.health !== oldHealth) {
|
||||
this.emit('health_changed', {
|
||||
health: this.state.health,
|
||||
food: this.state.food,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (attr.name === 'minecraft:player.hunger') {
|
||||
this.state.food = attr.current ?? attr.value ?? this.state.food;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Time update
|
||||
this.client.on('set_time', (packet: any) => {
|
||||
this.state.gameTime = packet.time ?? this.state.gameTime;
|
||||
this.state.dayTime = packet.time % 24000;
|
||||
this.emit('time_update', {
|
||||
gameTime: this.state.gameTime,
|
||||
dayTime: this.state.dayTime,
|
||||
});
|
||||
});
|
||||
|
||||
// Player join/leave
|
||||
this.client.on('player_list', (packet: any) => {
|
||||
if (!packet.records?.records) return;
|
||||
for (const record of packet.records.records) {
|
||||
if (record.username === this.options.username) continue;
|
||||
if (packet.records.type === 'add') {
|
||||
this.emit('player_joined', { username: record.username });
|
||||
} else if (packet.records.type === 'remove') {
|
||||
this.emit('player_left', { username: record.username });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Death
|
||||
this.client.on('death_info', (packet: any) => {
|
||||
log.info('Died', { message: packet.message });
|
||||
this.emit('death', {
|
||||
message: packet.message,
|
||||
position: { ...this.state.position },
|
||||
});
|
||||
});
|
||||
|
||||
// Entity events
|
||||
this.client.on('add_entity', (packet: any) => {
|
||||
this.emit('entity_spawned', {
|
||||
entityId: packet.runtime_id,
|
||||
type: packet.entity_type,
|
||||
position: packet.position,
|
||||
});
|
||||
});
|
||||
|
||||
this.client.on('add_player', (packet: any) => {
|
||||
if (packet.username === this.options.username) return;
|
||||
this.emit('entity_spawned', {
|
||||
entityId: packet.runtime_id,
|
||||
type: 'player',
|
||||
name: packet.username,
|
||||
position: packet.position,
|
||||
});
|
||||
});
|
||||
|
||||
this.client.on('remove_entity', (packet: any) => {
|
||||
this.emit('entity_removed', {
|
||||
entityId: packet.entity_id_self,
|
||||
});
|
||||
});
|
||||
|
||||
// Start position from start_game
|
||||
this.client.on('start_game', (packet: any) => {
|
||||
if (packet.player_position) {
|
||||
// BDS encodes Y with a 32768 offset in start_game
|
||||
let y = Number(packet.player_position.y);
|
||||
if (y > 30000) y -= 32768;
|
||||
this.state.position = {
|
||||
x: Number(packet.player_position.x),
|
||||
y: y,
|
||||
z: Number(packet.player_position.z),
|
||||
};
|
||||
log.info('Start position', { position: this.state.position });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a chat message to the server.
|
||||
*/
|
||||
sendChat(message: string): void {
|
||||
if (!this.client || !this.state.connected) {
|
||||
log.warn('Cannot send chat: not connected');
|
||||
return;
|
||||
}
|
||||
|
||||
// Full text packet with all required fields for newer BDS
|
||||
this.client.queue('text', {
|
||||
needs_translation: false,
|
||||
category: 'authored',
|
||||
type: 'chat',
|
||||
source_name: this.options.username,
|
||||
message: message,
|
||||
xuid: '',
|
||||
platform_chat_id: '',
|
||||
has_filtered_message: false,
|
||||
});
|
||||
|
||||
log.info('Sent chat', { message: message.substring(0, 60) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a server command (requires OP).
|
||||
*/
|
||||
sendCommand(command: string): void {
|
||||
if (!this.client || !this.state.connected) {
|
||||
log.warn('Cannot send command: not connected');
|
||||
return;
|
||||
}
|
||||
|
||||
// Strip leading / if present
|
||||
const cmd = command.startsWith('/') ? command.substring(1) : command;
|
||||
|
||||
try {
|
||||
this.client.queue('command_request', {
|
||||
command: cmd,
|
||||
origin: {
|
||||
type: 'player',
|
||||
uuid: '00000000-0000-0000-0000-000000000000',
|
||||
request_id: `cmd_${Date.now()}`,
|
||||
player_entity_id: 0n,
|
||||
},
|
||||
internal: false,
|
||||
version: '1',
|
||||
});
|
||||
} catch (err: any) {
|
||||
log.error('Command failed', { command: cmd, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Teleport to a position using /tp command.
|
||||
*/
|
||||
teleportTo(x: number, y: number, z: number): void {
|
||||
this.sendCommand(`tp @s ${x.toFixed(2)} ${y.toFixed(2)} ${z.toFixed(2)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Teleport relative to current position.
|
||||
*/
|
||||
teleportRelative(dx: number, dy: number, dz: number): void {
|
||||
this.sendCommand(`tp @s ~${dx.toFixed(2)} ~${dy.toFixed(2)} ~${dz.toFixed(2)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the client's runtime entity ID.
|
||||
*/
|
||||
private getRuntimeId(): bigint | number | undefined {
|
||||
// bedrock-protocol stores this internally
|
||||
return (this.client as any)?.entityId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the raw bedrock-protocol client for advanced operations.
|
||||
*/
|
||||
getRawClient(): Client | null {
|
||||
return this.client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from the server.
|
||||
*/
|
||||
disconnect(): void {
|
||||
if (this.client) {
|
||||
log.info('Disconnecting...');
|
||||
this.client.disconnect();
|
||||
this.client = null;
|
||||
this.state.connected = false;
|
||||
this.state.spawned = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the client is connected and spawned.
|
||||
*/
|
||||
isReady(): boolean {
|
||||
return this.state.connected && this.state.spawned;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,340 +0,0 @@
|
|||
/**
|
||||
* DougBot Bridge - Entry Point
|
||||
*
|
||||
* Connects to Minecraft Bedrock servers (offline, online, or Realm)
|
||||
* and exposes a WebSocket API for the Python controller.
|
||||
*
|
||||
* Usage:
|
||||
* node dist/index.js --host <host> --port <port> --username <name> [--ws-port <ws_port>] [--offline]
|
||||
* node dist/index.js --email <email> --host <host> --port <port> [--ws-port <ws_port>]
|
||||
* node dist/index.js --email <email> --realm [--realm-id <id>] [--ws-port <ws_port>]
|
||||
*/
|
||||
|
||||
import { BedrockClient, ClientOptions } from './client';
|
||||
import { BridgeWSServer } from './ws_server';
|
||||
import { ChatAction } from './actions/chat';
|
||||
import { MovementAction } from './actions/movement';
|
||||
import { EntityTracker } from './world/entity_tracker';
|
||||
import { buildAuthOptions, buildRealmOptions, listRealms } from './auth';
|
||||
import { createLogger, setLogLevel, LogLevel } from './utils/logger';
|
||||
|
||||
const log = createLogger('main');
|
||||
|
||||
// ============================================================
|
||||
// Parse command line arguments
|
||||
// ============================================================
|
||||
|
||||
function parseArgs(): Record<string, string> {
|
||||
const args = process.argv.slice(2);
|
||||
const parsed: Record<string, string> = {};
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i].startsWith('--')) {
|
||||
const key = args[i].substring(2);
|
||||
if (i + 1 < args.length && !args[i + 1].startsWith('--')) {
|
||||
parsed[key] = args[i + 1];
|
||||
i++;
|
||||
} else {
|
||||
parsed[key] = 'true';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Main
|
||||
// ============================================================
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs();
|
||||
|
||||
if (args['debug'] === 'true') {
|
||||
setLogLevel(LogLevel.DEBUG);
|
||||
}
|
||||
|
||||
const wsPort = parseInt(args['ws-port'] || '8765', 10);
|
||||
const connType = args['conn-type'] || (args['offline'] === 'true' ? 'offline' : (args['realm'] === 'true' ? 'realm' : 'online'));
|
||||
|
||||
log.info('DougBot Bridge starting', {
|
||||
connType,
|
||||
host: args['host'] || '(realm)',
|
||||
port: args['port'] || '(realm)',
|
||||
username: args['username'] || args['email'] || 'Doug',
|
||||
wsPort,
|
||||
});
|
||||
|
||||
// Create the WebSocket server
|
||||
const wsServer = new BridgeWSServer(wsPort);
|
||||
|
||||
// Build client options based on connection type
|
||||
let clientOptions: ClientOptions;
|
||||
let bedrockClient: BedrockClient;
|
||||
|
||||
if (connType === 'offline') {
|
||||
// ── Offline: Direct connection, no auth ──
|
||||
clientOptions = {
|
||||
host: args['host'] || '127.0.0.1',
|
||||
port: parseInt(args['port'] || '19132', 10),
|
||||
username: args['username'] || 'Doug',
|
||||
offline: true,
|
||||
};
|
||||
} else if (connType === 'realm') {
|
||||
// ── Realm: Xbox Live auth + Realm connection ──
|
||||
const email = args['email'];
|
||||
if (!email) {
|
||||
log.error('Realm connection requires --email');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const authOpts = buildRealmOptions(
|
||||
{
|
||||
email,
|
||||
onDeviceCode: (code, url) => {
|
||||
// Emit device code as an event so Python GUI can display it
|
||||
wsServer.emitEvent('auth_device_code', { code, url });
|
||||
},
|
||||
},
|
||||
args['realm-id'] || undefined,
|
||||
);
|
||||
|
||||
clientOptions = {
|
||||
host: '', // Will be resolved by Realm auth
|
||||
port: 0,
|
||||
username: email,
|
||||
offline: false,
|
||||
extraOptions: authOpts,
|
||||
};
|
||||
} else {
|
||||
// ── Online: Xbox Live auth + direct server ──
|
||||
const email = args['email'];
|
||||
if (!email) {
|
||||
log.error('Online connection requires --email');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const authOpts = buildAuthOptions({
|
||||
email,
|
||||
onDeviceCode: (code, url) => {
|
||||
wsServer.emitEvent('auth_device_code', { code, url });
|
||||
},
|
||||
});
|
||||
|
||||
clientOptions = {
|
||||
host: args['host'] || '127.0.0.1',
|
||||
port: parseInt(args['port'] || '19132', 10),
|
||||
username: email,
|
||||
offline: false,
|
||||
extraOptions: authOpts,
|
||||
};
|
||||
}
|
||||
|
||||
bedrockClient = new BedrockClient(clientOptions);
|
||||
const chatAction = new ChatAction(bedrockClient);
|
||||
const movementAction = new MovementAction(bedrockClient);
|
||||
movementAction.setOnArrival(() => {
|
||||
wsServer.emitEvent('movement_complete', {});
|
||||
});
|
||||
const entityTracker = new EntityTracker();
|
||||
|
||||
// Wire up entity tracking from bedrock events
|
||||
bedrockClient.on('entity_spawned', (data: any) => {
|
||||
entityTracker.addEntity(data.entityId, data.type, data.position, data.name);
|
||||
});
|
||||
bedrockClient.on('entity_removed', (data: any) => {
|
||||
entityTracker.removeEntity(data.entityId);
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Wire up bridge action handler
|
||||
// ============================================================
|
||||
|
||||
wsServer.setActionHandler(async (action, params) => {
|
||||
switch (action) {
|
||||
case 'status':
|
||||
return {
|
||||
status: 'success',
|
||||
data: {
|
||||
connected: bedrockClient.state.connected,
|
||||
spawned: bedrockClient.state.spawned,
|
||||
position: bedrockClient.state.position,
|
||||
health: bedrockClient.state.health,
|
||||
food: bedrockClient.state.food,
|
||||
gameTime: bedrockClient.state.gameTime,
|
||||
dayTime: bedrockClient.state.dayTime,
|
||||
},
|
||||
};
|
||||
|
||||
case 'connect':
|
||||
try {
|
||||
await bedrockClient.connect();
|
||||
return { status: 'success', data: { message: 'Connecting to server...' } };
|
||||
} catch (err: any) {
|
||||
return { status: 'error', error: err.message };
|
||||
}
|
||||
|
||||
case 'disconnect':
|
||||
bedrockClient.disconnect();
|
||||
return { status: 'success', data: { message: 'Disconnected' } };
|
||||
|
||||
case 'send_chat':
|
||||
const chatResult = chatAction.sendChat(params.message || '');
|
||||
return {
|
||||
status: chatResult.success ? 'success' : 'error',
|
||||
data: chatResult.success ? {} : undefined,
|
||||
error: chatResult.error,
|
||||
};
|
||||
|
||||
case 'get_position':
|
||||
return {
|
||||
status: 'success',
|
||||
data: { position: bedrockClient.state.position },
|
||||
};
|
||||
|
||||
case 'get_health':
|
||||
return {
|
||||
status: 'success',
|
||||
data: { health: bedrockClient.state.health, food: bedrockClient.state.food },
|
||||
};
|
||||
|
||||
case 'get_time':
|
||||
return {
|
||||
status: 'success',
|
||||
data: { gameTime: bedrockClient.state.gameTime, dayTime: bedrockClient.state.dayTime },
|
||||
};
|
||||
|
||||
case 'move_to':
|
||||
// Use /tp for reliable movement (works on all server types)
|
||||
try {
|
||||
bedrockClient.teleportTo(params.x || 0, params.y || 0, params.z || 0);
|
||||
return { status: 'success' };
|
||||
} catch (err: any) {
|
||||
return { status: 'error', error: err.message };
|
||||
}
|
||||
|
||||
case 'move_relative':
|
||||
try {
|
||||
bedrockClient.teleportRelative(params.dx || 0, params.dy || 0, params.dz || 0);
|
||||
return { status: 'success' };
|
||||
} catch (err: any) {
|
||||
return { status: 'error', error: err.message };
|
||||
}
|
||||
|
||||
case 'send_command':
|
||||
try {
|
||||
bedrockClient.sendCommand(params.command || '');
|
||||
return { status: 'success' };
|
||||
} catch (err: any) {
|
||||
return { status: 'error', error: err.message };
|
||||
}
|
||||
|
||||
case 'stop':
|
||||
movementAction.stop();
|
||||
return { status: 'success' };
|
||||
|
||||
case 'look_at':
|
||||
movementAction.lookAt({ x: params.x || 0, y: params.y || 0, z: params.z || 0 });
|
||||
return { status: 'success' };
|
||||
|
||||
case 'get_nearby_entities':
|
||||
const nearby = entityTracker.getNearbyEntities(
|
||||
bedrockClient.state.position,
|
||||
params.radius || 32,
|
||||
);
|
||||
return { status: 'success', data: { entities: nearby } };
|
||||
|
||||
case 'get_nearby_hostiles':
|
||||
const hostiles = entityTracker.getNearbyHostiles(
|
||||
bedrockClient.state.position,
|
||||
params.radius || 16,
|
||||
);
|
||||
return { status: 'success', data: { hostiles } };
|
||||
|
||||
case 'get_players':
|
||||
return {
|
||||
status: 'success',
|
||||
data: { players: entityTracker.getPlayerNames() },
|
||||
};
|
||||
|
||||
case 'list_realms':
|
||||
try {
|
||||
const email = params.email;
|
||||
if (!email) {
|
||||
return { status: 'error', error: 'Email is required to list Realms' };
|
||||
}
|
||||
|
||||
const realms = await listRealms({
|
||||
email,
|
||||
onDeviceCode: (code, url) => {
|
||||
wsServer.emitEvent('auth_device_code', { code, url });
|
||||
},
|
||||
});
|
||||
|
||||
return { status: 'success', data: { realms } };
|
||||
} catch (err: any) {
|
||||
return { status: 'error', error: err.message };
|
||||
}
|
||||
|
||||
default:
|
||||
log.warn(`Unhandled action: ${action}`);
|
||||
return {
|
||||
status: 'error',
|
||||
error: `Unknown action: ${action}`,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Forward Bedrock events to WebSocket
|
||||
// ============================================================
|
||||
|
||||
const forwardEvents = [
|
||||
'chat_message', 'health_changed', 'entity_spawned', 'entity_removed',
|
||||
'player_joined', 'player_left', 'damage_taken', 'death',
|
||||
'time_update', 'spawn_complete', 'connected', 'disconnected',
|
||||
'error', 'respawn',
|
||||
] as const;
|
||||
|
||||
for (const event of forwardEvents) {
|
||||
bedrockClient.on(event, (data: any) => {
|
||||
wsServer.emitEvent(event as any, data || {});
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Start
|
||||
// ============================================================
|
||||
|
||||
try {
|
||||
await wsServer.start();
|
||||
await bedrockClient.connect();
|
||||
log.info('Bridge is running. Waiting for Python controller...');
|
||||
} catch (err: any) {
|
||||
log.error('Failed to start bridge', { error: err.message });
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Graceful shutdown
|
||||
const shutdown = () => {
|
||||
log.info('Shutting down...');
|
||||
bedrockClient.disconnect();
|
||||
wsServer.stop();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('unhandledRejection', (reason: any) => {
|
||||
log.error('Unhandled promise rejection', { error: reason?.message || String(reason), stack: reason?.stack || '' });
|
||||
});
|
||||
process.on('uncaughtException', (err) => {
|
||||
log.error('Uncaught exception', { error: err.message, stack: err.stack });
|
||||
shutdown();
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
log.error('Fatal error', { error: err.message });
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -1,157 +0,0 @@
|
|||
/**
|
||||
* Shared protocol types for Python <-> Node.js WebSocket communication.
|
||||
* Both sides must agree on these message formats.
|
||||
*/
|
||||
|
||||
// ============================================================
|
||||
// Message Envelope Types
|
||||
// ============================================================
|
||||
|
||||
export interface RequestMessage {
|
||||
id: string;
|
||||
type: 'request';
|
||||
action: string;
|
||||
params: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface ResponseMessage {
|
||||
id: string;
|
||||
type: 'response';
|
||||
status: 'success' | 'error';
|
||||
data: Record<string, any>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface EventMessage {
|
||||
type: 'event';
|
||||
event: string;
|
||||
data: Record<string, any>;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export type BridgeMessage = RequestMessage | ResponseMessage | EventMessage;
|
||||
|
||||
// ============================================================
|
||||
// Action Types (Python -> Node requests)
|
||||
// ============================================================
|
||||
|
||||
export type ActionType =
|
||||
// Connection
|
||||
| 'connect'
|
||||
| 'disconnect'
|
||||
| 'status'
|
||||
// Movement
|
||||
| 'move_to'
|
||||
| 'jump'
|
||||
| 'sprint'
|
||||
| 'sneak'
|
||||
| 'stop'
|
||||
| 'look_at'
|
||||
// Blocks
|
||||
| 'place_block'
|
||||
| 'break_block'
|
||||
| 'get_block_at'
|
||||
// Inventory
|
||||
| 'get_inventory'
|
||||
| 'equip'
|
||||
| 'drop'
|
||||
| 'swap_slots'
|
||||
| 'use_item'
|
||||
// Combat
|
||||
| 'attack_entity'
|
||||
| 'use_shield'
|
||||
| 'eat'
|
||||
// Interaction
|
||||
| 'open_container'
|
||||
| 'close_container'
|
||||
| 'trade_with'
|
||||
| 'craft'
|
||||
// Chat
|
||||
| 'send_chat'
|
||||
// World Queries
|
||||
| 'get_nearby_entities'
|
||||
| 'get_nearby_blocks'
|
||||
| 'get_time'
|
||||
| 'get_health'
|
||||
| 'get_position';
|
||||
|
||||
// ============================================================
|
||||
// Event Types (Node -> Python unsolicited)
|
||||
// ============================================================
|
||||
|
||||
export type EventType =
|
||||
| 'chat_message'
|
||||
| 'health_changed'
|
||||
| 'entity_spawned'
|
||||
| 'entity_removed'
|
||||
| 'player_joined'
|
||||
| 'player_left'
|
||||
| 'block_changed'
|
||||
| 'damage_taken'
|
||||
| 'death'
|
||||
| 'inventory_changed'
|
||||
| 'time_update'
|
||||
| 'spawn_complete'
|
||||
| 'connected'
|
||||
| 'disconnected'
|
||||
| 'error'
|
||||
| 'auth_device_code'
|
||||
| 'movement_complete';
|
||||
|
||||
// ============================================================
|
||||
// Data Structures
|
||||
// ============================================================
|
||||
|
||||
export interface Vec3 {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
}
|
||||
|
||||
export interface EntityData {
|
||||
id: number | bigint;
|
||||
type: string;
|
||||
name?: string;
|
||||
position: Vec3;
|
||||
health?: number;
|
||||
}
|
||||
|
||||
export interface ItemData {
|
||||
id: number;
|
||||
name: string;
|
||||
count: number;
|
||||
slot: number;
|
||||
metadata?: number;
|
||||
}
|
||||
|
||||
export interface BlockData {
|
||||
position: Vec3;
|
||||
name: string;
|
||||
id: number;
|
||||
}
|
||||
|
||||
export interface PlayerData {
|
||||
username: string;
|
||||
position?: Vec3;
|
||||
entityId?: number | bigint;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Helper: Create messages
|
||||
// ============================================================
|
||||
|
||||
export function createResponse(
|
||||
requestId: string,
|
||||
status: 'success' | 'error',
|
||||
data: Record<string, any> = {},
|
||||
error?: string
|
||||
): ResponseMessage {
|
||||
return { id: requestId, type: 'response', status, data, error };
|
||||
}
|
||||
|
||||
export function createEvent(
|
||||
event: EventType,
|
||||
data: Record<string, any> = {}
|
||||
): EventMessage {
|
||||
return { type: 'event', event, data, timestamp: Date.now() };
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
/**
|
||||
* Simple structured logger for the bridge process.
|
||||
* Outputs JSON lines to stdout for Python to capture.
|
||||
*/
|
||||
|
||||
export enum LogLevel {
|
||||
DEBUG = 'DEBUG',
|
||||
INFO = 'INFO',
|
||||
WARN = 'WARN',
|
||||
ERROR = 'ERROR',
|
||||
}
|
||||
|
||||
let currentLevel = LogLevel.INFO;
|
||||
|
||||
export function setLogLevel(level: LogLevel): void {
|
||||
currentLevel = level;
|
||||
}
|
||||
|
||||
const levelPriority: Record<LogLevel, number> = {
|
||||
[LogLevel.DEBUG]: 0,
|
||||
[LogLevel.INFO]: 1,
|
||||
[LogLevel.WARN]: 2,
|
||||
[LogLevel.ERROR]: 3,
|
||||
};
|
||||
|
||||
function log(level: LogLevel, module: string, message: string, data?: any): void {
|
||||
if (levelPriority[level] < levelPriority[currentLevel]) return;
|
||||
|
||||
const entry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
level,
|
||||
module,
|
||||
message,
|
||||
...(data !== undefined ? { data } : {}),
|
||||
};
|
||||
|
||||
// Output as JSON line for Python to parse
|
||||
console.log(JSON.stringify(entry));
|
||||
}
|
||||
|
||||
export function createLogger(module: string) {
|
||||
return {
|
||||
debug: (msg: string, data?: any) => log(LogLevel.DEBUG, module, msg, data),
|
||||
info: (msg: string, data?: any) => log(LogLevel.INFO, module, msg, data),
|
||||
warn: (msg: string, data?: any) => log(LogLevel.WARN, module, msg, data),
|
||||
error: (msg: string, data?: any) => log(LogLevel.ERROR, module, msg, data),
|
||||
};
|
||||
}
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
/**
|
||||
* Simple 3D vector utilities for position calculations.
|
||||
*/
|
||||
|
||||
export interface Vec3 {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
}
|
||||
|
||||
export function vec3(x: number, y: number, z: number): Vec3 {
|
||||
return { x, y, z };
|
||||
}
|
||||
|
||||
export function distance(a: Vec3, b: Vec3): number {
|
||||
const dx = a.x - b.x;
|
||||
const dy = a.y - b.y;
|
||||
const dz = a.z - b.z;
|
||||
return Math.sqrt(dx * dx + dy * dy + dz * dz);
|
||||
}
|
||||
|
||||
export function distanceXZ(a: Vec3, b: Vec3): number {
|
||||
const dx = a.x - b.x;
|
||||
const dz = a.z - b.z;
|
||||
return Math.sqrt(dx * dx + dz * dz);
|
||||
}
|
||||
|
||||
export function add(a: Vec3, b: Vec3): Vec3 {
|
||||
return { x: a.x + b.x, y: a.y + b.y, z: a.z + b.z };
|
||||
}
|
||||
|
||||
export function subtract(a: Vec3, b: Vec3): Vec3 {
|
||||
return { x: a.x - b.x, y: a.y - b.y, z: a.z - b.z };
|
||||
}
|
||||
|
||||
export function scale(v: Vec3, s: number): Vec3 {
|
||||
return { x: v.x * s, y: v.y * s, z: v.z * s };
|
||||
}
|
||||
|
||||
export function normalize(v: Vec3): Vec3 {
|
||||
const len = Math.sqrt(v.x * v.x + v.y * v.y + v.z * v.z);
|
||||
if (len === 0) return { x: 0, y: 0, z: 0 };
|
||||
return { x: v.x / len, y: v.y / len, z: v.z / len };
|
||||
}
|
||||
|
||||
export function floor(v: Vec3): Vec3 {
|
||||
return { x: Math.floor(v.x), y: Math.floor(v.y), z: Math.floor(v.z) };
|
||||
}
|
||||
|
||||
export function equals(a: Vec3, b: Vec3): boolean {
|
||||
return a.x === b.x && a.y === b.y && a.z === b.z;
|
||||
}
|
||||
|
||||
export function toString(v: Vec3): string {
|
||||
return `(${v.x.toFixed(1)}, ${v.y.toFixed(1)}, ${v.z.toFixed(1)})`;
|
||||
}
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
/**
|
||||
* Tracks nearby entities — players, mobs, items.
|
||||
*/
|
||||
|
||||
import { createLogger } from '../utils/logger';
|
||||
|
||||
const log = createLogger('world:entities');
|
||||
|
||||
export interface TrackedEntity {
|
||||
id: string;
|
||||
type: string;
|
||||
name?: string;
|
||||
position: { x: number; y: number; z: number };
|
||||
health?: number;
|
||||
isHostile: boolean;
|
||||
}
|
||||
|
||||
const HOSTILE_MOBS = new Set([
|
||||
'minecraft:zombie', 'minecraft:skeleton', 'minecraft:spider', 'minecraft:creeper',
|
||||
'minecraft:enderman', 'minecraft:witch', 'minecraft:blaze', 'minecraft:ghast',
|
||||
'minecraft:slime', 'minecraft:phantom', 'minecraft:drowned', 'minecraft:husk',
|
||||
'minecraft:stray', 'minecraft:wither_skeleton', 'minecraft:pillager',
|
||||
'minecraft:vindicator', 'minecraft:evoker', 'minecraft:ravager',
|
||||
'zombie', 'skeleton', 'spider', 'creeper', 'enderman', 'witch',
|
||||
'blaze', 'ghast', 'slime', 'phantom', 'drowned', 'husk', 'stray',
|
||||
]);
|
||||
|
||||
export class EntityTracker {
|
||||
private entities: Map<string, TrackedEntity> = new Map();
|
||||
private players: Map<string, TrackedEntity> = new Map();
|
||||
|
||||
addEntity(id: string | bigint | number, type: string, position: any, name?: string): void {
|
||||
const sid = String(id);
|
||||
const isHostile = HOSTILE_MOBS.has(type) || HOSTILE_MOBS.has(type.replace('minecraft:', ''));
|
||||
const entity: TrackedEntity = {
|
||||
id: sid,
|
||||
type,
|
||||
name,
|
||||
position: { x: position?.x || 0, y: position?.y || 0, z: position?.z || 0 },
|
||||
isHostile,
|
||||
};
|
||||
|
||||
if (type === 'player') {
|
||||
this.players.set(name || sid, entity);
|
||||
}
|
||||
this.entities.set(sid, entity);
|
||||
}
|
||||
|
||||
updatePosition(id: string | bigint | number, position: any): void {
|
||||
const entity = this.entities.get(String(id));
|
||||
if (entity && position) {
|
||||
entity.position = { x: position.x || 0, y: position.y || 0, z: position.z || 0 };
|
||||
}
|
||||
}
|
||||
|
||||
removeEntity(id: string | bigint | number): void {
|
||||
const sid = String(id);
|
||||
const entity = this.entities.get(sid);
|
||||
if (entity?.type === 'player' && entity.name) {
|
||||
this.players.delete(entity.name);
|
||||
}
|
||||
this.entities.delete(sid);
|
||||
}
|
||||
|
||||
getNearbyEntities(pos: { x: number; y: number; z: number }, radius: number = 32): TrackedEntity[] {
|
||||
const result: TrackedEntity[] = [];
|
||||
for (const entity of this.entities.values()) {
|
||||
const dx = entity.position.x - pos.x;
|
||||
const dy = entity.position.y - pos.y;
|
||||
const dz = entity.position.z - pos.z;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
||||
if (dist <= radius) {
|
||||
result.push(entity);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
getNearbyHostiles(pos: { x: number; y: number; z: number }, radius: number = 16): TrackedEntity[] {
|
||||
return this.getNearbyEntities(pos, radius).filter(e => e.isHostile);
|
||||
}
|
||||
|
||||
getPlayers(): TrackedEntity[] {
|
||||
return Array.from(this.players.values());
|
||||
}
|
||||
|
||||
getPlayerNames(): string[] {
|
||||
return Array.from(this.players.keys());
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.entities.clear();
|
||||
this.players.clear();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,162 +0,0 @@
|
|||
/**
|
||||
* WebSocket server that Python connects to for controlling the bot.
|
||||
* Handles request/response and pushes events to Python.
|
||||
*/
|
||||
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
import { createLogger } from './utils/logger';
|
||||
import {
|
||||
RequestMessage,
|
||||
ResponseMessage,
|
||||
EventMessage,
|
||||
BridgeMessage,
|
||||
createResponse,
|
||||
createEvent,
|
||||
EventType,
|
||||
} from './protocol';
|
||||
|
||||
const log = createLogger('ws_server');
|
||||
|
||||
export type ActionHandler = (
|
||||
action: string,
|
||||
params: Record<string, any>
|
||||
) => Promise<{ status: 'success' | 'error'; data?: Record<string, any>; error?: string }>;
|
||||
|
||||
export class BridgeWSServer {
|
||||
private wss: WebSocketServer | null = null;
|
||||
private client: WebSocket | null = null;
|
||||
private actionHandler: ActionHandler | null = null;
|
||||
private port: number;
|
||||
|
||||
constructor(port: number) {
|
||||
this.port = port;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the handler that processes incoming action requests.
|
||||
*/
|
||||
setActionHandler(handler: ActionHandler): void {
|
||||
this.actionHandler = handler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the WebSocket server.
|
||||
*/
|
||||
start(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.wss = new WebSocketServer({ port: this.port }, () => {
|
||||
log.info(`WebSocket server listening on port ${this.port}`);
|
||||
resolve();
|
||||
});
|
||||
|
||||
this.wss.on('error', (err) => {
|
||||
log.error('WebSocket server error', { error: err.message });
|
||||
reject(err);
|
||||
});
|
||||
|
||||
this.wss.on('connection', (ws: WebSocket) => {
|
||||
if (this.client) {
|
||||
log.warn('New connection replacing existing client');
|
||||
this.client.close();
|
||||
}
|
||||
|
||||
this.client = ws;
|
||||
log.info('Python controller connected');
|
||||
|
||||
ws.on('message', async (data: Buffer) => {
|
||||
try {
|
||||
const message: BridgeMessage = JSON.parse(data.toString());
|
||||
await this.handleMessage(ws, message);
|
||||
} catch (err: any) {
|
||||
log.error('Failed to handle message', { error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
log.info('Python controller disconnected');
|
||||
if (this.client === ws) {
|
||||
this.client = null;
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', (err) => {
|
||||
log.error('WebSocket client error', { error: err.message });
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming message from Python.
|
||||
*/
|
||||
private async handleMessage(ws: WebSocket, message: BridgeMessage): Promise<void> {
|
||||
if (message.type !== 'request') {
|
||||
log.warn('Received non-request message', { type: message.type });
|
||||
return;
|
||||
}
|
||||
|
||||
const request = message as RequestMessage;
|
||||
log.debug(`Action request: ${request.action}`, { params: request.params });
|
||||
|
||||
if (!this.actionHandler) {
|
||||
this.sendToClient(createResponse(request.id, 'error', {}, 'No action handler registered'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.actionHandler(request.action, request.params || {});
|
||||
this.sendToClient(createResponse(request.id, result.status, result.data || {}, result.error));
|
||||
} catch (err: any) {
|
||||
log.error(`Action ${request.action} failed`, { error: err.message });
|
||||
this.sendToClient(createResponse(request.id, 'error', {}, err.message));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Push an event to the connected Python controller.
|
||||
*/
|
||||
emitEvent(event: EventType, data: Record<string, any> = {}): void {
|
||||
this.sendToClient(createEvent(event, data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to the connected Python client.
|
||||
*/
|
||||
private sendToClient(message: ResponseMessage | EventMessage): void {
|
||||
if (!this.client || this.client.readyState !== WebSocket.OPEN) {
|
||||
return; // No client connected, silently drop
|
||||
}
|
||||
|
||||
try {
|
||||
// BigInt values (entity IDs) can't be serialized by default JSON.stringify
|
||||
const json = JSON.stringify(message, (_, value) =>
|
||||
typeof value === 'bigint' ? value.toString() : value
|
||||
);
|
||||
this.client.send(json);
|
||||
} catch (err: any) {
|
||||
log.error('Failed to send message to client', { error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a Python controller is connected.
|
||||
*/
|
||||
isClientConnected(): boolean {
|
||||
return this.client !== null && this.client.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the WebSocket server.
|
||||
*/
|
||||
stop(): void {
|
||||
if (this.client) {
|
||||
this.client.close();
|
||||
this.client = null;
|
||||
}
|
||||
if (this.wss) {
|
||||
this.wss.close();
|
||||
this.wss = null;
|
||||
}
|
||||
log.info('WebSocket server stopped');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
98
context.md
98
context.md
|
|
@ -1,98 +0,0 @@
|
|||
I am wanting to start a new project where we create a minecraft player and have that player linked to my local ollama AI server for chat/text queries in game and to receive commands to do certain tasks. Now, I understand that in the beginning, the AI player will not understand how to play that well, so we will need a way to store memory and have it learn how to progress and do tasks as it matures.
|
||||
|
||||
I want this to be a fairly streamlined process to deploy the character, and I want to make sure that it's not some dumb and clunky AI bot... but rather a bot that can actually adapt, learn, and play.
|
||||
|
||||
One of the biggest requirements is going to be that the bot will be able to log into a bedrock server or bedrock realm and play on there with all of the same knowledge and features.
|
||||
|
||||
Here are the requirements for Doug:
|
||||
|
||||
Doug must have the ability to retain and reference memory. This can be a table, a json, or a mariadb database. I have a database already active and can provide the server info. Or, we can bake that into the character creation process.
|
||||
|
||||
The application must have a way to assign a persona to Doug. We can use multiple sliders for the basic personality traits, a box to type in age, and a text block for custom additions. A good example would be something like this: 16 for the age, sarcasm, humor, are half way up, and in the custom field, we added no profanity. This would allow us to have an AI persona of a 16 year old teenager with decent humor and sarcasm and won’t use profanity when talking.
|
||||
|
||||
The AI bot must become automated and do things autonoumously. I do not want the bot to just be idle and stand there, and I do not what the bot to get stuck in some "logic" loop where he is just walking in circles. Instead, actually interact with the world around him... talk to villagers and trade, organize chests, defend himself from mobs, set out on a mission or task, and track status/complete that task. This needs to happen autonomously and not only when he is asked to do something.
|
||||
|
||||
Must have an understanding of the minecraft items that exist and build/craft them.. weapons, tools, special blocks, etc...
|
||||
|
||||
In the persona, we can have options like OCD where he creates chests for items and sorts things out of frusteration... or anxiety where he is afraid of the night and avoids conflict. And life sim mode.. where if someone dies and he has a bond with that person, he digs a grave and makes a headstone for them (in a graveyard). If he has chatty-cathy, he could talk more than normal... these are just some of the examples... let's research on the internet what would be good options to include.
|
||||
|
||||
Doug must not only respond in chat, but also engage in chat. If he is bored or he is doing something, ask questions, ask for help, or simply have conversation. Of course, this all aligns with his persona.
|
||||
|
||||
In the persona configuration, I’d like a toggle to set whether the model / Doug will know if he is AI or not. If he is not supposed to know if he is AI, then he will assume that this is the real world and will respond accordingly.
|
||||
|
||||
I imagine the application allowing us to create a player, and once that is done, we won’t be able to change the persona anymore as that should be baked into the database. This way, Doug will learn and grow with that persona.
|
||||
|
||||
Doug must be able to play minecraft and learn/grow with the game and the server he is in.
|
||||
|
||||
Doug must be able to interface with an Ollama server / model to query and return responses.
|
||||
|
||||
Doug must be able to monitor chat and respond accordingly. If he is asked to make something, he must use his knowledge of the game and his memory to build something. Furthermore, he will learn and improve based on his interactions with other players and the real world.
|
||||
|
||||
Once a new doug agent is made, it must be locked and bound to the database so that it's world can't change and the model/memory/interactions are locked to that world and player.
|
||||
|
||||
|
||||
------
|
||||
|
||||
1. Java is a nice-to-have but me and my family primarily play on Bedrock. I host most of the bedrock servers we play on with my AMP server, so that is the requirement. Bedrock Realms (online) and Java would be nice-to-have and can be added later.
|
||||
|
||||
2. To simplify things, every doug (when created) will have a server hard-coded into him so he will not be able to switch between servers. This will make the memory function easier. Maybe later, we can add a multi-server function, but I think a single server per Doug instance is the best approach.
|
||||
|
||||
3. I am okay with storing authentication in the database. Honestly though, whichever is easier to do would be best. I also know that for each online player, I will have to have a separate account. That is why the account information is critical at the creation of each Doug.
|
||||
|
||||
4. Yes!! That is the beauty of this application and the persona builds. We are testing to see if multiple AI agents / Doug's can work together and create a world without being instructed to. We want to see what/how they will interact with each other.
|
||||
|
||||
5. Let's use a hybrid approach to get the best of both worlds. We want this to be as compatible with Bedrock as possible and we also want to have a really good looking interface.
|
||||
|
||||
6. I think having a desktop app using python or something similar would be best. Something that can be running while the game is also running. Ultimately though, it needs to be cross-platform and have the ability to run on Mac/Linux/Windows.
|
||||
|
||||
7. All systems.. Mac/Linux/Windows.
|
||||
|
||||
8. Any models that are available to the server. I've seen other applications have a dropdown with selection of available models. I'd like to keep that approach.
|
||||
|
||||
9. Yes, if Doug is asked a question, I expect that response to come when it comes. That should not stop him from performing other tasks. He should be built to respond once he has an answer, but be built in a way that he doesn't rely solely on AI to do normal fucntions.
|
||||
|
||||
10. I would be nice for him to see, but he does not need to take screenshots.
|
||||
|
||||
11. Doug should have a hard-coded response.
|
||||
|
||||
12. Yes, I think having a local storage file like json would be smart. That can be a toggle.
|
||||
|
||||
13. I want all of those memories and more if possible. Especially chat interactions so that we can ask him questions about things and he can recall from memory before using AI tokens. Also, remembering where players died and key points of interest.
|
||||
|
||||
14. All of the above. Doug should also be able to query AI for help and use the internet for help as well.
|
||||
|
||||
15. Yes. He needs persistent memory.
|
||||
|
||||
16. Let's get a comprehensive list and use that to load out different styles of play for Doug based on these traits.
|
||||
|
||||
17. ABSOLUTELY!! Doug needs to evolve.
|
||||
|
||||
18. A little bit of both. The goal is to keep him in the dark. But, if the cat is out of the bag, he needs to react according to his personality traits. The world he is in must be the world he knows. If he finds out he is AI, then he has to freak out and change everything about himself as if someone found out they weren't real as well. That can be a special area of the database to start building that persona trait. He will of course react differently each time, but making this random will make it all the much better. For instance, he could start destroying stuff out of anger... blowing things up with TNT.. or he could go complete helpful and realize it's cool. We will never know.
|
||||
|
||||
19. Fully emersive process, but following somewhat of a structure. He needs to calculate the cost-benefit analysis... if there are diamonds a few blocks away, get them first, then go home. If he is the only one on the server and he wants to stay through the night, go for it... if multiple people are on and he is being hounded to go to bed, then he needs to go to bed. We will let him make the decisions, but let's make sure that he is following some sort of trait alignment.
|
||||
|
||||
20. A combination.
|
||||
|
||||
21. All of it. He will have access to the internet, so he should be able to build anything. IF he uses scaffolding or other blocks to help get around, up/down... HE MUST CLEAN UP AFTER HIMSELF.
|
||||
|
||||
22. Lean advanced combat over time and lessons learned from interactions.
|
||||
|
||||
23. Unless specified, he should try to produce the minimum viable product.
|
||||
|
||||
24. Doug should talk how he wants to talk, but I think that should really depend on the age settings and persona as well. If doug's age is lower enough, he should not have a large vocabulary and can learn based on what players are saying.
|
||||
|
||||
25. Let's combind that; every few minutes if players are around to talk, but if he sees something interesting and thinks others are interested, he should let us know.
|
||||
|
||||
26. YES! YES! YES! This is what will make Doug unique. He MUST build relationships with his players based on his interactions. To take that a step farther, he needs to name EVERYTHING with nametags. All of the animals, all of the villagers, everything that can get a nametag should. Then Doug should build relationsships with those villagers and animals as well. If something dies, he needs to have a natural response to that. If someone kills his animals, he needs to be sad... and angry at the person who killed it. If Doug feels like retaliation is neccessary, then Doug must attack the players he doesn't like.
|
||||
|
||||
27. Doug should respond smartly. If we say "Doug, can you..." then that is clearly a message for him.. if he is in the middle of a conversation with someone, we should have to keep saying "Doug" at the beginning. He should also chime in on conversations that are happening if he has input. There should be no mechanical side of Doug... he needs to be a human.. or act like one, so we should have be able to take advantage of a special way to communicate with commands.
|
||||
|
||||
28. Let's start with option A. This might take a while, but we can improve the areas as needed. I think we should also break down each major section so that we can make it easier to find problem areas and improve on them.
|
||||
|
||||
29. This will be a work-in-progress, but we would like to get something going soon.
|
||||
|
||||
30. This will eventually be open source, but for now, it's personal. I will be storing the code on my gitea/forgejo server as soon as possible, so let's shoot for open source.
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,115 +0,0 @@
|
|||
"""
|
||||
Ollama API client.
|
||||
Handles communication with the local Ollama server for AI reasoning.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
from typing import Optional, AsyncGenerator
|
||||
from dougbot.utils.logging import get_logger
|
||||
|
||||
log = get_logger("ai.ollama")
|
||||
|
||||
|
||||
class OllamaClient:
|
||||
"""HTTP client for the Ollama REST API."""
|
||||
|
||||
def __init__(self, base_url: str = "http://127.0.0.1:11434"):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
# Long timeout — first request loads the model into GPU/RAM which can take minutes
|
||||
self._client = httpx.Client(timeout=300.0)
|
||||
|
||||
def list_models(self) -> list[str]:
|
||||
"""
|
||||
Get available models from the Ollama server.
|
||||
Returns list of model names.
|
||||
"""
|
||||
try:
|
||||
response = self._client.get(f"{self.base_url}/api/tags")
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
models = [m["name"] for m in data.get("models", [])]
|
||||
log.info(f"Found {len(models)} models: {models}")
|
||||
return models
|
||||
except httpx.ConnectError:
|
||||
log.error(f"Cannot connect to Ollama at {self.base_url}")
|
||||
return []
|
||||
except Exception as e:
|
||||
log.error(f"Failed to list models: {e}")
|
||||
return []
|
||||
|
||||
def chat(
|
||||
self,
|
||||
model: str,
|
||||
system_prompt: str,
|
||||
user_message: str,
|
||||
chat_history: Optional[list[dict]] = None,
|
||||
temperature: float = 0.8,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Send a chat request to Ollama and return the response.
|
||||
|
||||
Args:
|
||||
model: Model name (e.g., "llama3", "mistral")
|
||||
system_prompt: The system prompt (persona + context)
|
||||
user_message: The user's message
|
||||
chat_history: Previous messages for context
|
||||
temperature: Creativity (0.0-1.0, higher = more creative)
|
||||
|
||||
Returns:
|
||||
The AI response text, or None on failure.
|
||||
"""
|
||||
messages = [{"role": "system", "content": system_prompt}]
|
||||
|
||||
# Add conversation history
|
||||
if chat_history:
|
||||
for msg in chat_history:
|
||||
messages.append({
|
||||
"role": msg.get("role", "user"),
|
||||
"content": msg.get("content", ""),
|
||||
})
|
||||
|
||||
# Add current message
|
||||
messages.append({"role": "user", "content": user_message})
|
||||
|
||||
try:
|
||||
response = self._client.post(
|
||||
f"{self.base_url}/api/chat",
|
||||
json={
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"stream": False,
|
||||
"think": False, # Disable thinking mode (Qwen3.5, etc.)
|
||||
"options": {
|
||||
"temperature": temperature,
|
||||
"num_predict": 150, # Cap response length for chat
|
||||
},
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
reply = data.get("message", {}).get("content", "")
|
||||
log.debug(f"Ollama response ({len(reply)} chars): {reply[:100]}...")
|
||||
return reply
|
||||
|
||||
except httpx.ConnectError:
|
||||
log.error(f"Cannot connect to Ollama at {self.base_url}")
|
||||
return None
|
||||
except httpx.TimeoutException:
|
||||
log.error("Ollama request timed out")
|
||||
return None
|
||||
except Exception as e:
|
||||
log.error(f"Ollama chat failed: {e}")
|
||||
return None
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Check if the Ollama server is reachable."""
|
||||
try:
|
||||
response = self._client.get(f"{self.base_url}/api/tags")
|
||||
return response.status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the HTTP client."""
|
||||
self._client.close()
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
"""
|
||||
Prompt builder — constructs a concise system prompt from persona config.
|
||||
Designed to produce short, grounded responses for Minecraft chat.
|
||||
"""
|
||||
|
||||
from dougbot.db.models import PersonaConfig
|
||||
from dougbot.utils.logging import get_logger
|
||||
|
||||
log = get_logger("ai.prompt_builder")
|
||||
|
||||
# Short quirk labels
|
||||
QUIRK_LABELS = {
|
||||
"ocd": "neat and organized",
|
||||
"anxiety": "nervous and worried",
|
||||
"chatty_cathy": "talkative",
|
||||
"life_sim_mode": "emotional about events",
|
||||
"pyromaniac": "loves fire",
|
||||
"hoarder": "never throws anything away",
|
||||
"perfectionist": "high standards",
|
||||
"scaredy_cat": "afraid of mobs",
|
||||
"architect": "cares about aesthetics",
|
||||
"superstitious": "believes in luck and omens",
|
||||
"drama_queen": "overdramatic",
|
||||
"conspiracy_theorist": "suspicious of everything",
|
||||
"pet_parent": "loves animals",
|
||||
"trash_talker": "playful roaster",
|
||||
"philosopher": "deep thinker",
|
||||
"night_owl": "prefers nighttime",
|
||||
"kleptomaniac": "borrows without asking",
|
||||
"foodie": "picky about food",
|
||||
"nomad": "always moving",
|
||||
"speedrunner": "hates wasting time",
|
||||
"tinker": "loves redstone",
|
||||
"prankster": "enjoys pranks",
|
||||
"doomsday_prepper": "always preparing for disaster",
|
||||
}
|
||||
|
||||
# Slider to one-word descriptors at extremes
|
||||
TRAIT_WORDS = {
|
||||
"bravery": ("cowardly", None, "fearless"),
|
||||
"sociability": ("shy", None, "very social"),
|
||||
"patience": ("impatient", None, "very patient"),
|
||||
"ambition": ("lazy", None, "ambitious"),
|
||||
"empathy": ("cold", None, "caring"),
|
||||
"curiosity": ("unadventurous", None, "curious"),
|
||||
"generosity": ("greedy", None, "generous"),
|
||||
"sarcasm": ("sincere", None, "sarcastic"),
|
||||
"orderliness": ("messy", None, "neat"),
|
||||
"loyalty": ("independent", None, "loyal"),
|
||||
"stubbornness": ("agreeable", None, "stubborn"),
|
||||
"self_awareness": ("oblivious", None, "self-aware"),
|
||||
"risk_tolerance": ("cautious", None, "reckless"),
|
||||
"creativity": ("conventional", None, "creative"),
|
||||
"work_ethic": ("lazy", None, "hardworking"),
|
||||
}
|
||||
|
||||
|
||||
def build_system_prompt(
|
||||
name: str,
|
||||
age: int,
|
||||
persona: PersonaConfig,
|
||||
relationships: list[dict] | None = None,
|
||||
context: dict | None = None,
|
||||
custom_notes: str = "",
|
||||
) -> str:
|
||||
"""Build a concise system prompt."""
|
||||
|
||||
# Identity
|
||||
if persona.believes_real:
|
||||
identity = f"You are {name}, a {age}-year-old person. This world is your real life."
|
||||
else:
|
||||
identity = f"You are {name}, a {age}-year-old AI playing Minecraft."
|
||||
|
||||
# Personality — only mention notable traits (not middle-of-road ones)
|
||||
traits = []
|
||||
sliders = persona.to_dict()["sliders"]
|
||||
for trait_name, value in sliders.items():
|
||||
words = TRAIT_WORDS.get(trait_name)
|
||||
if not words:
|
||||
continue
|
||||
low, _, high = words
|
||||
if value < 30 and low:
|
||||
traits.append(low)
|
||||
elif value > 70 and high:
|
||||
traits.append(high)
|
||||
|
||||
# Active quirks
|
||||
quirks = persona.get_active_quirks()
|
||||
quirk_words = [QUIRK_LABELS.get(q, q) for q in quirks]
|
||||
|
||||
# Combine personality into one line
|
||||
all_traits = traits + quirk_words
|
||||
personality = f"Personality: {', '.join(all_traits)}." if all_traits else ""
|
||||
|
||||
# Profanity
|
||||
lang = "No profanity - use clean substitutes." if persona.profanity_filter else ""
|
||||
|
||||
# Custom notes
|
||||
custom = custom_notes.strip() if custom_notes else ""
|
||||
|
||||
# Build the prompt — keep it SHORT
|
||||
parts = [
|
||||
identity,
|
||||
personality,
|
||||
lang,
|
||||
custom,
|
||||
"",
|
||||
"Rules: Reply in ONE short sentence. Under 15 words. Talk like a normal person.",
|
||||
"NEVER make up activities. If asked what you are doing, say not much or just hanging out.",
|
||||
"Plain text only. Do not start with your name.",
|
||||
]
|
||||
|
||||
return "\n".join(p for p in parts if p)
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
"""
|
||||
DougBot Application bootstrap.
|
||||
Initializes PySide6 QApplication with dark theme and launches the main window.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from PySide6.QtWidgets import QApplication
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
from dougbot.gui.main_window import MainWindow
|
||||
from dougbot.gui.widgets.theme import DARK_THEME
|
||||
from dougbot.utils.logging import setup_logging, get_logger
|
||||
|
||||
|
||||
def run():
|
||||
"""Launch the DougBot application."""
|
||||
# Set up logging
|
||||
setup_logging()
|
||||
log = get_logger("app")
|
||||
log.info("Starting DougBot...")
|
||||
|
||||
# Create application
|
||||
app = QApplication(sys.argv)
|
||||
app.setApplicationName("DougBot")
|
||||
app.setApplicationVersion("0.1.0")
|
||||
|
||||
# Apply dark theme
|
||||
app.setStyleSheet(DARK_THEME)
|
||||
|
||||
# Create and show main window
|
||||
window = MainWindow()
|
||||
window.show()
|
||||
|
||||
log.info("DougBot is running")
|
||||
|
||||
# Run event loop
|
||||
sys.exit(app.exec())
|
||||
|
|
@ -1,213 +0,0 @@
|
|||
"""
|
||||
Node.js bridge subprocess manager.
|
||||
Spawns, monitors, and manages the lifecycle of the Node.js bridge process.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtCore import QObject, Signal, QProcess, QProcessEnvironment
|
||||
|
||||
from dougbot.utils.logging import get_logger
|
||||
|
||||
log = get_logger("bridge.node_manager")
|
||||
|
||||
|
||||
class NodeManager(QObject):
|
||||
"""Manages the Node.js bridge subprocess."""
|
||||
|
||||
# Signals
|
||||
started = Signal()
|
||||
stopped = Signal()
|
||||
error_occurred = Signal(str)
|
||||
log_output = Signal(str) # Bridge stdout/stderr lines
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._process: Optional[QProcess] = None
|
||||
self._ws_port: int = 8765
|
||||
self._bridge_dir: Optional[Path] = None
|
||||
|
||||
def _find_bridge_dir(self) -> Path:
|
||||
"""Locate the bridge directory."""
|
||||
# Check relative to the dougbot package
|
||||
candidates = [
|
||||
Path(__file__).parent.parent.parent / "bridge", # development
|
||||
Path.cwd() / "bridge", # cwd
|
||||
]
|
||||
|
||||
for candidate in candidates:
|
||||
if (candidate / "package.json").exists():
|
||||
return candidate
|
||||
|
||||
raise FileNotFoundError(
|
||||
"Cannot find bridge directory. Ensure the 'bridge' folder "
|
||||
"is in the project root with package.json."
|
||||
)
|
||||
|
||||
def _ensure_built(self) -> Path:
|
||||
"""Ensure the bridge TypeScript is compiled."""
|
||||
bridge_dir = self._find_bridge_dir()
|
||||
dist_dir = bridge_dir / "dist"
|
||||
entry_file = dist_dir / "index.js"
|
||||
|
||||
if not entry_file.exists():
|
||||
log.info("Bridge not built, compiling TypeScript...")
|
||||
# Check if node_modules exists
|
||||
if not (bridge_dir / "node_modules").exists():
|
||||
log.info("Installing bridge dependencies...")
|
||||
proc = QProcess()
|
||||
proc.setWorkingDirectory(str(bridge_dir))
|
||||
proc.start("npm", ["install"])
|
||||
proc.waitForFinished(120000) # 2 minutes
|
||||
|
||||
if proc.exitCode() != 0:
|
||||
stderr = proc.readAllStandardError().data().decode()
|
||||
raise RuntimeError(f"npm install failed: {stderr}")
|
||||
|
||||
# Compile TypeScript
|
||||
proc = QProcess()
|
||||
proc.setWorkingDirectory(str(bridge_dir))
|
||||
proc.start("npx", ["tsc"])
|
||||
proc.waitForFinished(60000) # 1 minute
|
||||
|
||||
if proc.exitCode() != 0:
|
||||
stderr = proc.readAllStandardError().data().decode()
|
||||
raise RuntimeError(f"TypeScript compilation failed: {stderr}")
|
||||
|
||||
log.info("Bridge compiled successfully")
|
||||
|
||||
self._bridge_dir = bridge_dir
|
||||
return entry_file
|
||||
|
||||
def start(
|
||||
self,
|
||||
conn_type: str = "offline",
|
||||
mc_host: str = "127.0.0.1",
|
||||
mc_port: int = 19132,
|
||||
username: str = "Doug",
|
||||
email: str = "",
|
||||
realm_id: str = "",
|
||||
ws_port: int = 8765,
|
||||
) -> None:
|
||||
"""
|
||||
Start the Node.js bridge subprocess.
|
||||
|
||||
Args:
|
||||
conn_type: "offline", "online", or "realm"
|
||||
mc_host: Minecraft server host (offline/online)
|
||||
mc_port: Minecraft server port (offline/online)
|
||||
username: Bot's Minecraft username (offline mode)
|
||||
email: Microsoft account email (online/realm)
|
||||
realm_id: Realm ID to connect to (realm mode)
|
||||
ws_port: WebSocket port for Python to connect to
|
||||
"""
|
||||
if self._process and self._process.state() == QProcess.ProcessState.Running:
|
||||
log.warn("Bridge is already running")
|
||||
return
|
||||
|
||||
self._ws_port = ws_port
|
||||
|
||||
try:
|
||||
entry_file = self._ensure_built()
|
||||
except Exception as e:
|
||||
self.error_occurred.emit(str(e))
|
||||
return
|
||||
|
||||
# Find node executable - prefer fnm-managed Node 20 (raknet-native crashes on Node 25)
|
||||
node_path = None
|
||||
fnm_node = Path.home() / ".local" / "share" / "fnm" / "node-versions" / "v20.20.2" / "installation" / "bin" / "node"
|
||||
if fnm_node.exists():
|
||||
node_path = str(fnm_node)
|
||||
log.info(f"Using fnm Node 20: {node_path}")
|
||||
else:
|
||||
node_path = shutil.which("node")
|
||||
|
||||
if not node_path:
|
||||
self.error_occurred.emit("Node.js not found. Please install Node.js.")
|
||||
return
|
||||
|
||||
# Build arguments based on connection type
|
||||
args = [
|
||||
str(entry_file),
|
||||
"--ws-port", str(ws_port),
|
||||
"--conn-type", conn_type,
|
||||
]
|
||||
|
||||
if conn_type == "offline":
|
||||
args.extend(["--host", mc_host, "--port", str(mc_port)])
|
||||
args.extend(["--username", username])
|
||||
args.append("--offline")
|
||||
elif conn_type == "online":
|
||||
args.extend(["--host", mc_host, "--port", str(mc_port)])
|
||||
args.extend(["--email", email])
|
||||
elif conn_type == "realm":
|
||||
args.extend(["--email", email])
|
||||
args.append("--realm")
|
||||
if realm_id:
|
||||
args.extend(["--realm-id", realm_id])
|
||||
|
||||
# Create process
|
||||
self._process = QProcess(self)
|
||||
self._process.setWorkingDirectory(str(self._bridge_dir))
|
||||
|
||||
# Connect signals
|
||||
self._process.readyReadStandardOutput.connect(self._on_stdout)
|
||||
self._process.readyReadStandardError.connect(self._on_stderr)
|
||||
self._process.finished.connect(self._on_finished)
|
||||
self._process.started.connect(self._on_started)
|
||||
self._process.errorOccurred.connect(self._on_error)
|
||||
|
||||
log.info(f"Starting bridge: node {' '.join(args)}")
|
||||
self._process.start(node_path, args)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the Node.js bridge subprocess."""
|
||||
if self._process and self._process.state() == QProcess.ProcessState.Running:
|
||||
log.info("Stopping bridge process...")
|
||||
self._process.terminate()
|
||||
|
||||
if not self._process.waitForFinished(5000):
|
||||
log.warn("Bridge didn't stop gracefully, killing...")
|
||||
self._process.kill()
|
||||
|
||||
def is_running(self) -> bool:
|
||||
"""Check if the bridge process is running."""
|
||||
return (
|
||||
self._process is not None
|
||||
and self._process.state() == QProcess.ProcessState.Running
|
||||
)
|
||||
|
||||
@property
|
||||
def ws_port(self) -> int:
|
||||
return self._ws_port
|
||||
|
||||
def _on_started(self) -> None:
|
||||
log.info("Bridge process started")
|
||||
self.started.emit()
|
||||
|
||||
def _on_finished(self, exit_code: int, exit_status: QProcess.ExitStatus) -> None:
|
||||
log.info(f"Bridge process exited (code={exit_code}, status={exit_status})")
|
||||
self.stopped.emit()
|
||||
|
||||
def _on_error(self, error: QProcess.ProcessError) -> None:
|
||||
error_msg = f"Bridge process error: {error}"
|
||||
log.error(error_msg)
|
||||
self.error_occurred.emit(error_msg)
|
||||
|
||||
def _on_stdout(self) -> None:
|
||||
if self._process:
|
||||
data = self._process.readAllStandardOutput().data().decode("utf-8", errors="replace")
|
||||
for line in data.strip().split("\n"):
|
||||
if line:
|
||||
self.log_output.emit(line)
|
||||
|
||||
def _on_stderr(self) -> None:
|
||||
if self._process:
|
||||
data = self._process.readAllStandardError().data().decode("utf-8", errors="replace")
|
||||
for line in data.strip().split("\n"):
|
||||
if line:
|
||||
log.warn(f"[bridge stderr] {line}")
|
||||
self.log_output.emit(f"[STDERR] {line}")
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
"""
|
||||
Bridge communication protocol types.
|
||||
Python-side mirror of bridge/src/protocol.ts
|
||||
"""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class RequestMessage:
|
||||
"""Request from Python to Node.js bridge."""
|
||||
action: str
|
||||
params: dict[str, Any] = field(default_factory=dict)
|
||||
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
|
||||
def to_json(self) -> str:
|
||||
return json.dumps({
|
||||
"id": self.id,
|
||||
"type": "request",
|
||||
"action": self.action,
|
||||
"params": self.params,
|
||||
})
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResponseMessage:
|
||||
"""Response from Node.js bridge to Python."""
|
||||
id: str
|
||||
status: str # 'success' or 'error'
|
||||
data: dict[str, Any] = field(default_factory=dict)
|
||||
error: Optional[str] = None
|
||||
|
||||
@staticmethod
|
||||
def from_dict(data: dict) -> "ResponseMessage":
|
||||
return ResponseMessage(
|
||||
id=data.get("id", ""),
|
||||
status=data.get("status", "error"),
|
||||
data=data.get("data", {}),
|
||||
error=data.get("error"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class EventMessage:
|
||||
"""Event from Node.js bridge to Python (unsolicited)."""
|
||||
event: str
|
||||
data: dict[str, Any] = field(default_factory=dict)
|
||||
timestamp: int = 0
|
||||
|
||||
@staticmethod
|
||||
def from_dict(data: dict) -> "EventMessage":
|
||||
return EventMessage(
|
||||
event=data.get("event", ""),
|
||||
data=data.get("data", {}),
|
||||
timestamp=data.get("timestamp", 0),
|
||||
)
|
||||
|
||||
|
||||
def parse_bridge_message(raw: str) -> ResponseMessage | EventMessage | None:
|
||||
"""Parse a raw JSON message from the bridge."""
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
msg_type = data.get("type")
|
||||
|
||||
if msg_type == "response":
|
||||
return ResponseMessage.from_dict(data)
|
||||
elif msg_type == "event":
|
||||
return EventMessage.from_dict(data)
|
||||
else:
|
||||
return None
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
|
@ -1,139 +0,0 @@
|
|||
"""
|
||||
WebSocket client for communicating with the Node.js bridge.
|
||||
Uses QWebSocket for native Qt event loop integration.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Optional, Callable
|
||||
from PySide6.QtCore import QObject, Signal, QTimer, QUrl
|
||||
from PySide6.QtWebSockets import QWebSocket
|
||||
|
||||
from dougbot.bridge.protocol import (
|
||||
RequestMessage,
|
||||
ResponseMessage,
|
||||
EventMessage,
|
||||
parse_bridge_message,
|
||||
)
|
||||
from dougbot.utils.logging import get_logger
|
||||
|
||||
log = get_logger("bridge.ws_client")
|
||||
|
||||
|
||||
class BridgeWSClient(QObject):
|
||||
"""WebSocket client that connects to the Node.js bridge."""
|
||||
|
||||
# Signals
|
||||
connected = Signal()
|
||||
disconnected = Signal()
|
||||
event_received = Signal(str, dict) # event_name, data
|
||||
error_occurred = Signal(str)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._ws = QWebSocket("", parent=self)
|
||||
self._url: Optional[str] = None
|
||||
self._pending_requests: dict[str, Callable] = {}
|
||||
self._reconnect_timer = QTimer(self)
|
||||
self._reconnect_timer.setInterval(3000)
|
||||
self._reconnect_timer.timeout.connect(self._try_reconnect)
|
||||
self._should_reconnect = False
|
||||
|
||||
# Connect QWebSocket signals
|
||||
self._ws.connected.connect(self._on_connected)
|
||||
self._ws.disconnected.connect(self._on_disconnected)
|
||||
self._ws.textMessageReceived.connect(self._on_message)
|
||||
self._ws.errorOccurred.connect(self._on_error)
|
||||
|
||||
def connect_to_bridge(self, port: int, host: str = "127.0.0.1") -> None:
|
||||
"""Connect to the Node.js bridge WebSocket server."""
|
||||
self._url = f"ws://{host}:{port}"
|
||||
self._should_reconnect = True
|
||||
log.info(f"Connecting to bridge at {self._url}")
|
||||
self._ws.open(QUrl(self._url))
|
||||
|
||||
def disconnect_from_bridge(self) -> None:
|
||||
"""Disconnect from the bridge."""
|
||||
self._should_reconnect = False
|
||||
self._reconnect_timer.stop()
|
||||
self._ws.close()
|
||||
|
||||
def send_request(
|
||||
self,
|
||||
action: str,
|
||||
params: dict | None = None,
|
||||
callback: Callable[[ResponseMessage], None] | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Send a request to the bridge.
|
||||
|
||||
Args:
|
||||
action: The action to perform
|
||||
params: Action parameters
|
||||
callback: Optional callback for the response
|
||||
|
||||
Returns:
|
||||
Request ID
|
||||
"""
|
||||
request = RequestMessage(action=action, params=params or {})
|
||||
|
||||
if callback:
|
||||
self._pending_requests[request.id] = callback
|
||||
|
||||
self._ws.sendTextMessage(request.to_json())
|
||||
log.debug(f"Sent request: {action} (id={request.id[:8]})")
|
||||
return request.id
|
||||
|
||||
def send_chat(self, message: str) -> None:
|
||||
"""Convenience method to send a chat message."""
|
||||
self.send_request("send_chat", {"message": message})
|
||||
|
||||
def get_status(self, callback: Callable[[ResponseMessage], None]) -> None:
|
||||
"""Get current bot status from the bridge."""
|
||||
self.send_request("status", {}, callback)
|
||||
|
||||
def is_connected(self) -> bool:
|
||||
"""Check if connected to the bridge."""
|
||||
from PySide6.QtNetwork import QAbstractSocket
|
||||
return self._ws.state() == QAbstractSocket.SocketState.ConnectedState
|
||||
|
||||
def _on_connected(self) -> None:
|
||||
log.info("Connected to bridge WebSocket")
|
||||
self._reconnect_timer.stop()
|
||||
self.connected.emit()
|
||||
|
||||
def _on_disconnected(self) -> None:
|
||||
log.info("Disconnected from bridge WebSocket")
|
||||
self.disconnected.emit()
|
||||
|
||||
if self._should_reconnect:
|
||||
log.info("Will attempt reconnection in 3 seconds...")
|
||||
self._reconnect_timer.start()
|
||||
|
||||
def _on_message(self, raw: str) -> None:
|
||||
"""Handle incoming message from the bridge."""
|
||||
message = parse_bridge_message(raw)
|
||||
if message is None:
|
||||
log.warn(f"Failed to parse bridge message: {raw[:100]}")
|
||||
return
|
||||
|
||||
if isinstance(message, ResponseMessage):
|
||||
# Match to pending request
|
||||
callback = self._pending_requests.pop(message.id, None)
|
||||
if callback:
|
||||
callback(message)
|
||||
else:
|
||||
log.debug(f"Response for unknown request: {message.id[:8]}")
|
||||
|
||||
elif isinstance(message, EventMessage):
|
||||
log.debug(f"Event: {message.event}")
|
||||
self.event_received.emit(message.event, message.data)
|
||||
|
||||
def _on_error(self, error) -> None:
|
||||
error_msg = self._ws.errorString()
|
||||
log.error(f"WebSocket error: {error_msg}")
|
||||
self.error_occurred.emit(error_msg)
|
||||
|
||||
def _try_reconnect(self) -> None:
|
||||
if self._url and self._should_reconnect:
|
||||
log.debug("Attempting reconnection...")
|
||||
self._ws.open(QUrl(self._url))
|
||||
|
|
@ -1,286 +0,0 @@
|
|||
"""
|
||||
Doug's Brain — the autonomous decision loop.
|
||||
Runs every 2 seconds and decides what Doug should do next.
|
||||
"""
|
||||
|
||||
import math
|
||||
import random
|
||||
import time
|
||||
from PySide6.QtCore import QObject, QTimer, Signal
|
||||
|
||||
from dougbot.bridge.ws_client import BridgeWSClient
|
||||
from dougbot.bridge.protocol import ResponseMessage
|
||||
from dougbot.utils.logging import get_logger
|
||||
|
||||
log = get_logger("core.brain")
|
||||
|
||||
|
||||
class DougBrain(QObject):
|
||||
"""Autonomous decision engine. Ticks every 2 seconds."""
|
||||
|
||||
# Signal for chat messages the brain wants to send
|
||||
wants_to_chat = Signal(str) # message
|
||||
|
||||
def __init__(self, ws_client: BridgeWSClient, doug_name: str, parent=None):
|
||||
super().__init__(parent)
|
||||
self._ws = ws_client
|
||||
self._doug_name = doug_name
|
||||
self._tick_timer = QTimer(self)
|
||||
self._tick_timer.timeout.connect(self._tick)
|
||||
self._running = False
|
||||
|
||||
# State
|
||||
self._position = {"x": 0, "y": 0, "z": 0}
|
||||
self._health = 20
|
||||
self._food = 20
|
||||
self._day_time = 0
|
||||
self._nearby_players: list[dict] = []
|
||||
self._nearby_hostiles: list[dict] = []
|
||||
self._is_moving = False
|
||||
self._current_action = "idle"
|
||||
self._action_start_time = 0.0
|
||||
self._ticks_since_chat = 0
|
||||
self._idle_since = time.time()
|
||||
self._spawn_pos = {"x": 0, "y": 0, "z": 0}
|
||||
self._has_spawn = False
|
||||
|
||||
# State request tracking
|
||||
self._pending_status = False
|
||||
|
||||
def start(self):
|
||||
"""Start the brain loop."""
|
||||
self._running = True
|
||||
self._idle_since = time.time()
|
||||
self._tick_timer.start(2000) # Every 2 seconds
|
||||
log.info("Brain started — Doug is thinking")
|
||||
|
||||
def stop(self):
|
||||
"""Stop the brain loop."""
|
||||
self._running = False
|
||||
self._tick_timer.stop()
|
||||
log.info("Brain stopped")
|
||||
|
||||
def update_from_event(self, event: str, data: dict):
|
||||
"""Update brain state from bridge events."""
|
||||
if event == "health_changed":
|
||||
self._health = data.get("health", self._health)
|
||||
self._food = data.get("food", self._food)
|
||||
elif event == "time_update":
|
||||
self._day_time = data.get("dayTime", self._day_time)
|
||||
elif event == "spawn_complete":
|
||||
pos = data.get("position", {})
|
||||
self._position = {"x": pos.get("x", 0), "y": pos.get("y", 0), "z": pos.get("z", 0)}
|
||||
if not self._has_spawn:
|
||||
self._spawn_pos = dict(self._position)
|
||||
self._has_spawn = True
|
||||
elif event == "damage_taken":
|
||||
log.info(f"Doug took damage! Health: {self._health}")
|
||||
elif event == "movement_complete":
|
||||
self._is_moving = False
|
||||
self._current_action = "idle"
|
||||
self._idle_since = time.time()
|
||||
|
||||
def _tick(self):
|
||||
"""One brain tick — observe, decide, act."""
|
||||
from PySide6.QtNetwork import QAbstractSocket
|
||||
if not self._running or self._ws._ws.state() != QAbstractSocket.SocketState.ConnectedState:
|
||||
return
|
||||
|
||||
self._ticks_since_chat += 1
|
||||
|
||||
# If movement is taking too long (> 15 sec), cancel it
|
||||
if self._is_moving and (time.time() - self._action_start_time > 15):
|
||||
self._is_moving = False
|
||||
self._current_action = "idle"
|
||||
self._idle_since = time.time()
|
||||
log.debug("Movement timed out, going idle")
|
||||
|
||||
# Request current status from bridge
|
||||
if not self._pending_status:
|
||||
self._pending_status = True
|
||||
self._ws.send_request("status", {}, self._on_status)
|
||||
self._ws.send_request("get_nearby_entities", {"radius": 16}, self._on_entities)
|
||||
|
||||
def _on_status(self, response: ResponseMessage):
|
||||
"""Process status response from bridge."""
|
||||
self._pending_status = False
|
||||
if response.status != "success":
|
||||
return
|
||||
|
||||
data = response.data
|
||||
self._position = data.get("position", self._position)
|
||||
self._health = data.get("health", self._health)
|
||||
self._food = data.get("food", self._food)
|
||||
self._day_time = data.get("dayTime", self._day_time)
|
||||
|
||||
# Now make a decision
|
||||
self._decide()
|
||||
|
||||
def _on_entities(self, response: ResponseMessage):
|
||||
"""Process nearby entities response."""
|
||||
if response.status != "success":
|
||||
return
|
||||
|
||||
entities = response.data.get("entities", [])
|
||||
self._nearby_players = [
|
||||
e for e in entities
|
||||
if e.get("type") == "player" and e.get("name") != self._doug_name
|
||||
]
|
||||
self._nearby_hostiles = [
|
||||
e for e in entities if e.get("isHostile", False)
|
||||
]
|
||||
|
||||
def _decide(self):
|
||||
"""Core decision logic — what should Doug do right now?"""
|
||||
|
||||
# Continue walking if we have steps left
|
||||
if self._is_moving and self._current_action == "wandering":
|
||||
if hasattr(self, '_walk_steps_left') and self._walk_steps_left > 0:
|
||||
self._walk_steps_left -= 1
|
||||
target = getattr(self, '_walk_target', None)
|
||||
if target:
|
||||
dx = target["x"] - self._position["x"]
|
||||
dz = target["z"] - self._position["z"]
|
||||
dist = math.sqrt(dx * dx + dz * dz)
|
||||
if dist > 0.5:
|
||||
nx = dx / dist
|
||||
nz = dz / dist
|
||||
step = min(1.5, dist)
|
||||
self._ws.send_request("move_relative", {
|
||||
"dx": nx * step,
|
||||
"dy": 0,
|
||||
"dz": nz * step,
|
||||
})
|
||||
self._position["x"] += nx * step
|
||||
self._position["z"] += nz * step
|
||||
return
|
||||
# Arrived
|
||||
self._is_moving = False
|
||||
self._current_action = "idle"
|
||||
self._idle_since = time.time()
|
||||
return
|
||||
|
||||
# Don't interrupt other actions
|
||||
if self._is_moving:
|
||||
return
|
||||
|
||||
idle_duration = time.time() - self._idle_since
|
||||
|
||||
# Priority 1: Flee from CLOSE hostiles (within 8 blocks) when hurt
|
||||
close_hostiles = [h for h in self._nearby_hostiles if self._distance_to(h) < 8]
|
||||
if close_hostiles and self._health < 14:
|
||||
self._flee_from_hostile(close_hostiles[0])
|
||||
return
|
||||
|
||||
# Priority 2: Wander every 4-8 seconds of idle
|
||||
if idle_duration > random.uniform(4, 8):
|
||||
self._wander()
|
||||
return
|
||||
|
||||
# Priority 3: Look around when idle
|
||||
if idle_duration > 2 and random.random() < 0.4:
|
||||
self._look_around()
|
||||
return
|
||||
|
||||
def _distance_to(self, entity: dict) -> float:
|
||||
"""Distance from Doug to an entity."""
|
||||
epos = entity.get("position", {})
|
||||
dx = self._position["x"] - epos.get("x", 0)
|
||||
dz = self._position["z"] - epos.get("z", 0)
|
||||
return math.sqrt(dx * dx + dz * dz)
|
||||
|
||||
def _wander(self):
|
||||
"""Walk to a random nearby position using small teleport steps."""
|
||||
# Pick a random direction and distance (3-8 blocks)
|
||||
angle = random.uniform(0, 2 * math.pi)
|
||||
dist = random.uniform(3, 8)
|
||||
target_x = self._position["x"] + math.cos(angle) * dist
|
||||
target_z = self._position["z"] + math.sin(angle) * dist
|
||||
|
||||
# Don't wander too far from spawn (50 block radius)
|
||||
if self._has_spawn:
|
||||
dx = target_x - self._spawn_pos["x"]
|
||||
dz = target_z - self._spawn_pos["z"]
|
||||
if math.sqrt(dx * dx + dz * dz) > 50:
|
||||
angle = math.atan2(
|
||||
self._spawn_pos["z"] - self._position["z"],
|
||||
self._spawn_pos["x"] - self._position["x"],
|
||||
)
|
||||
target_x = self._position["x"] + math.cos(angle) * 6
|
||||
target_z = self._position["z"] + math.sin(angle) * 6
|
||||
log.debug("Wandering back toward spawn")
|
||||
|
||||
# Use small relative teleports (~1 block per step) to simulate walking
|
||||
dx = target_x - self._position["x"]
|
||||
dz = target_z - self._position["z"]
|
||||
total_dist = math.sqrt(dx * dx + dz * dz)
|
||||
if total_dist < 0.5:
|
||||
return
|
||||
|
||||
# Normalize direction
|
||||
nx = dx / total_dist
|
||||
nz = dz / total_dist
|
||||
|
||||
# Take a single step of ~1.5 blocks toward target
|
||||
step = min(1.5, total_dist)
|
||||
self._ws.send_request("move_relative", {
|
||||
"dx": nx * step,
|
||||
"dy": 0,
|
||||
"dz": nz * step,
|
||||
})
|
||||
|
||||
# Update local position estimate
|
||||
self._position["x"] += nx * step
|
||||
self._position["z"] += nz * step
|
||||
|
||||
# Keep walking for a few ticks (set timer to continue)
|
||||
self._walk_target = {"x": target_x, "z": target_z}
|
||||
self._walk_steps_left = int(total_dist / 1.5)
|
||||
self._is_moving = True
|
||||
self._action_start_time = time.time()
|
||||
self._current_action = "wandering"
|
||||
log.debug(f"Walking toward ({target_x:.0f}, {self._position['y']:.0f}, {target_z:.0f}) — {total_dist:.0f} blocks")
|
||||
|
||||
def _look_around(self):
|
||||
"""Look at a random direction."""
|
||||
look_x = self._position["x"] + random.uniform(-20, 20)
|
||||
look_y = self._position["y"] + random.uniform(-3, 5)
|
||||
look_z = self._position["z"] + random.uniform(-20, 20)
|
||||
|
||||
self._ws.send_request("look_at", {
|
||||
"x": look_x,
|
||||
"y": look_y,
|
||||
"z": look_z,
|
||||
})
|
||||
|
||||
def _flee_from_hostile(self, hostile: dict):
|
||||
"""Run away from a hostile mob using /tp."""
|
||||
hpos = hostile.get("position", {})
|
||||
dx = self._position["x"] - hpos.get("x", 0)
|
||||
dz = self._position["z"] - hpos.get("z", 0)
|
||||
dist = max(0.1, math.sqrt(dx * dx + dz * dz))
|
||||
|
||||
# Sprint 3 blocks away in opposite direction
|
||||
flee_dx = (dx / dist) * 3
|
||||
flee_dz = (dz / dist) * 3
|
||||
|
||||
self._ws.send_request("move_relative", {
|
||||
"dx": flee_dx,
|
||||
"dy": 0,
|
||||
"dz": flee_dz,
|
||||
})
|
||||
|
||||
self._position["x"] += flee_dx
|
||||
self._position["z"] += flee_dz
|
||||
self._is_moving = True
|
||||
self._action_start_time = time.time()
|
||||
self._current_action = "fleeing"
|
||||
log.info(f"Fleeing from {hostile.get('type', 'mob')}!")
|
||||
|
||||
@property
|
||||
def current_action(self) -> str:
|
||||
return self._current_action
|
||||
|
||||
@property
|
||||
def is_night(self) -> bool:
|
||||
return self._day_time > 12000
|
||||
|
|
@ -1,166 +0,0 @@
|
|||
"""
|
||||
Database connection factory.
|
||||
Supports SQLite (default) and MariaDB backends.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
from typing import Optional, Any
|
||||
from pathlib import Path
|
||||
from dougbot.utils.logging import get_logger
|
||||
|
||||
log = get_logger("db.connection")
|
||||
|
||||
|
||||
class DatabaseConnection:
|
||||
"""Abstract database connection wrapper."""
|
||||
|
||||
def __init__(self):
|
||||
self._conn = None
|
||||
self._db_type = "sqlite"
|
||||
|
||||
@staticmethod
|
||||
def create(db_type: str = "sqlite", **kwargs) -> "DatabaseConnection":
|
||||
"""Factory method to create a database connection."""
|
||||
if db_type == "mariadb":
|
||||
return MariaDBConnection(**kwargs)
|
||||
return SQLiteConnection(**kwargs)
|
||||
|
||||
def execute(self, query: str, params: tuple = ()) -> Any:
|
||||
raise NotImplementedError
|
||||
|
||||
def executemany(self, query: str, params_list: list[tuple]) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
def fetchone(self, query: str, params: tuple = ()) -> Optional[dict]:
|
||||
raise NotImplementedError
|
||||
|
||||
def fetchall(self, query: str, params: tuple = ()) -> list[dict]:
|
||||
raise NotImplementedError
|
||||
|
||||
def commit(self) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
def close(self) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def db_type(self) -> str:
|
||||
return self._db_type
|
||||
|
||||
|
||||
class SQLiteConnection(DatabaseConnection):
|
||||
"""SQLite database connection."""
|
||||
|
||||
def __init__(self, db_path: Optional[str] = None, **kwargs):
|
||||
super().__init__()
|
||||
self._db_type = "sqlite"
|
||||
|
||||
if db_path is None:
|
||||
db_path = str(Path.home() / ".dougbot" / "dougbot.db")
|
||||
|
||||
# Ensure directory exists
|
||||
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self._conn = sqlite3.connect(db_path, check_same_thread=False)
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
self._conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._conn.execute("PRAGMA foreign_keys=ON")
|
||||
|
||||
log.info(f"SQLite connected: {db_path}")
|
||||
|
||||
def execute(self, query: str, params: tuple = ()) -> Any:
|
||||
cursor = self._conn.execute(query, params)
|
||||
self._conn.commit()
|
||||
return cursor
|
||||
|
||||
def executemany(self, query: str, params_list: list[tuple]) -> None:
|
||||
self._conn.executemany(query, params_list)
|
||||
self._conn.commit()
|
||||
|
||||
def fetchone(self, query: str, params: tuple = ()) -> Optional[dict]:
|
||||
cursor = self._conn.execute(query, params)
|
||||
row = cursor.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def fetchall(self, query: str, params: tuple = ()) -> list[dict]:
|
||||
cursor = self._conn.execute(query, params)
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
def commit(self) -> None:
|
||||
self._conn.commit()
|
||||
|
||||
def close(self) -> None:
|
||||
if self._conn:
|
||||
self._conn.close()
|
||||
log.info("SQLite connection closed")
|
||||
|
||||
|
||||
class MariaDBConnection(DatabaseConnection):
|
||||
"""MariaDB database connection."""
|
||||
|
||||
def __init__(self, host: str = "127.0.0.1", port: int = 3306,
|
||||
user: str = "", password: str = "", database: str = "dougbot",
|
||||
**kwargs):
|
||||
super().__init__()
|
||||
self._db_type = "mariadb"
|
||||
|
||||
try:
|
||||
import mysql.connector
|
||||
self._conn = mysql.connector.connect(
|
||||
host=host,
|
||||
port=port,
|
||||
user=user,
|
||||
password=password,
|
||||
database=database,
|
||||
autocommit=True,
|
||||
)
|
||||
self._mysql = mysql.connector
|
||||
log.info(f"MariaDB connected: {host}:{port}/{database}")
|
||||
except ImportError:
|
||||
raise RuntimeError(
|
||||
"mysql-connector-python is required for MariaDB support. "
|
||||
"Install with: pip install mysql-connector-python"
|
||||
)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to connect to MariaDB: {e}")
|
||||
|
||||
def _prepare(self, query: str) -> str:
|
||||
"""Adapt a query for MariaDB: fix placeholders and reserved words."""
|
||||
import re
|
||||
query = query.replace("?", "%s")
|
||||
# Backtick `key` when used as a column name (it's a MySQL reserved word)
|
||||
# Match "key" as a standalone word not already backticked
|
||||
query = re.sub(r'(?<!`)(?<!\w)\bkey\b(?!`)(?!\w*\()', '`key`', query)
|
||||
return query
|
||||
|
||||
def execute(self, query: str, params: tuple = ()) -> Any:
|
||||
query = self._prepare(query)
|
||||
cursor = self._conn.cursor(dictionary=True)
|
||||
cursor.execute(query, params)
|
||||
self._conn.commit()
|
||||
return cursor
|
||||
|
||||
def executemany(self, query: str, params_list: list[tuple]) -> None:
|
||||
query = self._prepare(query)
|
||||
cursor = self._conn.cursor()
|
||||
cursor.executemany(query, params_list)
|
||||
|
||||
def fetchone(self, query: str, params: tuple = ()) -> Optional[dict]:
|
||||
query = self._prepare(query)
|
||||
cursor = self._conn.cursor(dictionary=True)
|
||||
cursor.execute(query, params)
|
||||
return cursor.fetchone()
|
||||
|
||||
def fetchall(self, query: str, params: tuple = ()) -> list[dict]:
|
||||
query = self._prepare(query)
|
||||
cursor = self._conn.cursor(dictionary=True)
|
||||
cursor.execute(query, params)
|
||||
return cursor.fetchall()
|
||||
|
||||
def commit(self) -> None:
|
||||
self._conn.commit()
|
||||
|
||||
def close(self) -> None:
|
||||
if self._conn:
|
||||
self._conn.close()
|
||||
log.info("MariaDB connection closed")
|
||||
|
|
@ -1,258 +0,0 @@
|
|||
"""
|
||||
Data models for database records.
|
||||
Simple dataclasses that map to database tables.
|
||||
"""
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Optional, Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class DougModel:
|
||||
"""Represents a Doug bot instance."""
|
||||
id: Optional[int] = None
|
||||
name: str = ""
|
||||
server_host: str = "127.0.0.1"
|
||||
server_port: int = 19132
|
||||
server_type: str = "bedrock"
|
||||
username: str = ""
|
||||
age: int = 18
|
||||
persona_config: dict = field(default_factory=dict)
|
||||
custom_notes: str = ""
|
||||
locked: bool = False
|
||||
created_at: Optional[str] = None
|
||||
last_deployed: Optional[str] = None
|
||||
|
||||
def to_db_dict(self) -> dict:
|
||||
return {
|
||||
"name": self.name,
|
||||
"server_host": self.server_host,
|
||||
"server_port": self.server_port,
|
||||
"server_type": self.server_type,
|
||||
"username": self.username,
|
||||
"age": self.age,
|
||||
"persona_config": json.dumps(self.persona_config),
|
||||
"custom_notes": self.custom_notes,
|
||||
"locked": 1 if self.locked else 0,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_db_row(row: dict) -> "DougModel":
|
||||
persona = row.get("persona_config", "{}")
|
||||
if isinstance(persona, str):
|
||||
try:
|
||||
persona = json.loads(persona)
|
||||
except json.JSONDecodeError:
|
||||
persona = {}
|
||||
|
||||
return DougModel(
|
||||
id=row.get("id"),
|
||||
name=row.get("name", ""),
|
||||
server_host=row.get("server_host", "127.0.0.1"),
|
||||
server_port=row.get("server_port", 19132),
|
||||
server_type=row.get("server_type", "bedrock"),
|
||||
username=row.get("username", ""),
|
||||
age=row.get("age", 18),
|
||||
persona_config=persona,
|
||||
custom_notes=row.get("custom_notes", ""),
|
||||
locked=bool(row.get("locked", 0)),
|
||||
created_at=row.get("created_at"),
|
||||
last_deployed=row.get("last_deployed"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PersonaConfig:
|
||||
"""
|
||||
Complete persona configuration for a Doug.
|
||||
Contains all 40 traits organized by category.
|
||||
"""
|
||||
|
||||
# Core Personality Sliders (0-100)
|
||||
bravery: int = 50
|
||||
sociability: int = 50
|
||||
patience: int = 50
|
||||
ambition: int = 50
|
||||
empathy: int = 50
|
||||
curiosity: int = 50
|
||||
generosity: int = 50
|
||||
sarcasm: int = 50
|
||||
orderliness: int = 50
|
||||
|
||||
# Social & Emotional Sliders (0-100)
|
||||
loyalty: int = 50
|
||||
stubbornness: int = 50
|
||||
self_awareness: int = 50
|
||||
|
||||
# Gameplay Style Sliders (0-100)
|
||||
risk_tolerance: int = 50
|
||||
creativity: int = 50
|
||||
work_ethic: int = 50
|
||||
|
||||
# Behavioral Quirks (checkboxes)
|
||||
ocd: bool = False
|
||||
anxiety: bool = False
|
||||
chatty_cathy: bool = False
|
||||
life_sim_mode: bool = False
|
||||
pyromaniac: bool = False
|
||||
hoarder: bool = False
|
||||
perfectionist: bool = False
|
||||
scaredy_cat: bool = False
|
||||
architect: bool = False
|
||||
superstitious: bool = False
|
||||
drama_queen: bool = False
|
||||
conspiracy_theorist: bool = False
|
||||
pet_parent: bool = False
|
||||
trash_talker: bool = False
|
||||
philosopher: bool = False
|
||||
night_owl: bool = False
|
||||
kleptomaniac: bool = False
|
||||
foodie: bool = False
|
||||
nomad: bool = False
|
||||
speedrunner: bool = False
|
||||
tinker: bool = False
|
||||
prankster: bool = False
|
||||
doomsday_prepper: bool = False
|
||||
|
||||
# Special Toggles
|
||||
believes_real: bool = True
|
||||
profanity_filter: bool = True
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for JSON storage."""
|
||||
return {
|
||||
"sliders": {
|
||||
"bravery": self.bravery,
|
||||
"sociability": self.sociability,
|
||||
"patience": self.patience,
|
||||
"ambition": self.ambition,
|
||||
"empathy": self.empathy,
|
||||
"curiosity": self.curiosity,
|
||||
"generosity": self.generosity,
|
||||
"sarcasm": self.sarcasm,
|
||||
"orderliness": self.orderliness,
|
||||
"loyalty": self.loyalty,
|
||||
"stubbornness": self.stubbornness,
|
||||
"self_awareness": self.self_awareness,
|
||||
"risk_tolerance": self.risk_tolerance,
|
||||
"creativity": self.creativity,
|
||||
"work_ethic": self.work_ethic,
|
||||
},
|
||||
"quirks": {
|
||||
"ocd": self.ocd,
|
||||
"anxiety": self.anxiety,
|
||||
"chatty_cathy": self.chatty_cathy,
|
||||
"life_sim_mode": self.life_sim_mode,
|
||||
"pyromaniac": self.pyromaniac,
|
||||
"hoarder": self.hoarder,
|
||||
"perfectionist": self.perfectionist,
|
||||
"scaredy_cat": self.scaredy_cat,
|
||||
"architect": self.architect,
|
||||
"superstitious": self.superstitious,
|
||||
"drama_queen": self.drama_queen,
|
||||
"conspiracy_theorist": self.conspiracy_theorist,
|
||||
"pet_parent": self.pet_parent,
|
||||
"trash_talker": self.trash_talker,
|
||||
"philosopher": self.philosopher,
|
||||
"night_owl": self.night_owl,
|
||||
"kleptomaniac": self.kleptomaniac,
|
||||
"foodie": self.foodie,
|
||||
"nomad": self.nomad,
|
||||
"speedrunner": self.speedrunner,
|
||||
"tinker": self.tinker,
|
||||
"prankster": self.prankster,
|
||||
"doomsday_prepper": self.doomsday_prepper,
|
||||
},
|
||||
"special": {
|
||||
"believes_real": self.believes_real,
|
||||
"profanity_filter": self.profanity_filter,
|
||||
},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_dict(data: dict) -> "PersonaConfig":
|
||||
"""Create PersonaConfig from a dictionary."""
|
||||
config = PersonaConfig()
|
||||
|
||||
sliders = data.get("sliders", {})
|
||||
for key, value in sliders.items():
|
||||
if hasattr(config, key):
|
||||
setattr(config, key, int(value))
|
||||
|
||||
quirks = data.get("quirks", {})
|
||||
for key, value in quirks.items():
|
||||
if hasattr(config, key):
|
||||
setattr(config, key, bool(value))
|
||||
|
||||
special = data.get("special", {})
|
||||
for key, value in special.items():
|
||||
if hasattr(config, key):
|
||||
setattr(config, key, bool(value))
|
||||
|
||||
return config
|
||||
|
||||
def get_active_quirks(self) -> list[str]:
|
||||
"""Return list of enabled quirk names."""
|
||||
quirks = self.to_dict()["quirks"]
|
||||
return [name for name, enabled in quirks.items() if enabled]
|
||||
|
||||
def get_slider_descriptions(self) -> dict[str, str]:
|
||||
"""Get human-readable descriptions for current slider values."""
|
||||
descriptions = {}
|
||||
sliders = self.to_dict()["sliders"]
|
||||
|
||||
labels = {
|
||||
"bravery": ("Cowardly", "Balanced", "Fearless"),
|
||||
"sociability": ("Hermit", "Balanced", "Social Butterfly"),
|
||||
"patience": ("Hot-Headed", "Balanced", "Zen Master"),
|
||||
"ambition": ("Lazy", "Balanced", "Overachiever"),
|
||||
"empathy": ("Cold-Blooded", "Balanced", "Bleeding Heart"),
|
||||
"curiosity": ("Stick-in-the-Mud", "Balanced", "Explorer"),
|
||||
"generosity": ("Greedy", "Balanced", "Santa Claus"),
|
||||
"sarcasm": ("Earnest", "Balanced", "Dripping with Sarcasm"),
|
||||
"orderliness": ("Chaotic", "Balanced", "Military Precision"),
|
||||
"loyalty": ("Freelancer", "Balanced", "Ride or Die"),
|
||||
"stubbornness": ("Pushover", "Balanced", "Immovable Object"),
|
||||
"self_awareness": ("Oblivious", "Balanced", "Hyper Self-Aware"),
|
||||
"risk_tolerance": ("Safety First", "Balanced", "YOLO"),
|
||||
"creativity": ("Cookie Cutter", "Balanced", "Avant-Garde"),
|
||||
"work_ethic": ("Slacker", "Balanced", "Workaholic"),
|
||||
}
|
||||
|
||||
for name, value in sliders.items():
|
||||
low, mid, high = labels.get(name, ("Low", "Medium", "High"))
|
||||
if value < 30:
|
||||
descriptions[name] = low
|
||||
elif value < 70:
|
||||
descriptions[name] = mid
|
||||
else:
|
||||
descriptions[name] = high
|
||||
|
||||
return descriptions
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChatMessage:
|
||||
"""A chat message record."""
|
||||
id: Optional[int] = None
|
||||
doug_id: int = 0
|
||||
timestamp: Optional[str] = None
|
||||
sender: str = ""
|
||||
message: str = ""
|
||||
context: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Relationship:
|
||||
"""A relationship with a player or entity."""
|
||||
id: Optional[int] = None
|
||||
doug_id: int = 0
|
||||
entity_name: str = ""
|
||||
entity_type: str = "player"
|
||||
score: float = 0.0
|
||||
interactions_count: int = 0
|
||||
notes: str = ""
|
||||
first_met: Optional[str] = None
|
||||
last_interaction: Optional[str] = None
|
||||
|
|
@ -1,153 +0,0 @@
|
|||
"""
|
||||
Database query repository.
|
||||
Named queries following the repository pattern.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Optional
|
||||
from dougbot.db.connection import DatabaseConnection
|
||||
from dougbot.db.models import DougModel, ChatMessage, Relationship
|
||||
from dougbot.utils.logging import get_logger
|
||||
|
||||
log = get_logger("db.queries")
|
||||
|
||||
|
||||
class DougRepository:
|
||||
"""CRUD operations for Doug instances."""
|
||||
|
||||
def __init__(self, db: DatabaseConnection):
|
||||
self.db = db
|
||||
|
||||
def create(self, doug: DougModel) -> int:
|
||||
"""Create a new Doug and return its ID."""
|
||||
data = doug.to_db_dict()
|
||||
result = self.db.execute(
|
||||
"""INSERT INTO dougs (name, server_host, server_port, server_type,
|
||||
username, age, persona_config, custom_notes, locked)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(data["name"], data["server_host"], data["server_port"],
|
||||
data["server_type"], data["username"], data["age"],
|
||||
data["persona_config"], data["custom_notes"], data["locked"]),
|
||||
)
|
||||
doug_id = result.lastrowid
|
||||
log.info(f"Created Doug '{doug.name}' with ID {doug_id}")
|
||||
return doug_id
|
||||
|
||||
def get_by_id(self, doug_id: int) -> Optional[DougModel]:
|
||||
"""Get a Doug by ID."""
|
||||
row = self.db.fetchone("SELECT * FROM dougs WHERE id = ?", (doug_id,))
|
||||
return DougModel.from_db_row(row) if row else None
|
||||
|
||||
def get_by_name(self, name: str) -> Optional[DougModel]:
|
||||
"""Get a Doug by name."""
|
||||
row = self.db.fetchone("SELECT * FROM dougs WHERE name = ?", (name,))
|
||||
return DougModel.from_db_row(row) if row else None
|
||||
|
||||
def get_all(self) -> list[DougModel]:
|
||||
"""Get all Doug instances."""
|
||||
rows = self.db.fetchall("SELECT * FROM dougs ORDER BY name")
|
||||
return [DougModel.from_db_row(row) for row in rows]
|
||||
|
||||
def delete(self, doug_id: int) -> None:
|
||||
"""Delete a Doug and all its data."""
|
||||
self.db.execute("DELETE FROM dougs WHERE id = ?", (doug_id,))
|
||||
log.info(f"Deleted Doug ID {doug_id}")
|
||||
|
||||
def lock(self, doug_id: int) -> None:
|
||||
"""Lock a Doug's persona (make it immutable)."""
|
||||
self.db.execute("UPDATE dougs SET locked = 1 WHERE id = ?", (doug_id,))
|
||||
|
||||
def update_last_deployed(self, doug_id: int) -> None:
|
||||
"""Update the last_deployed timestamp."""
|
||||
self.db.execute(
|
||||
"UPDATE dougs SET last_deployed = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
(doug_id,),
|
||||
)
|
||||
|
||||
|
||||
class ChatRepository:
|
||||
"""CRUD operations for chat history."""
|
||||
|
||||
def __init__(self, db: DatabaseConnection):
|
||||
self.db = db
|
||||
|
||||
def save_message(self, doug_id: int, sender: str, message: str,
|
||||
context: str = "") -> None:
|
||||
"""Save a chat message."""
|
||||
self.db.execute(
|
||||
"""INSERT INTO chat_history (doug_id, sender, message, context)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
(doug_id, sender, message, context),
|
||||
)
|
||||
|
||||
def get_recent(self, doug_id: int, limit: int = 50) -> list[dict]:
|
||||
"""Get recent chat messages for a Doug."""
|
||||
return self.db.fetchall(
|
||||
"""SELECT * FROM chat_history WHERE doug_id = ?
|
||||
ORDER BY timestamp DESC LIMIT ?""",
|
||||
(doug_id, limit),
|
||||
)
|
||||
|
||||
def search(self, doug_id: int, query: str, limit: int = 20) -> list[dict]:
|
||||
"""Search chat history."""
|
||||
return self.db.fetchall(
|
||||
"""SELECT * FROM chat_history WHERE doug_id = ? AND message LIKE ?
|
||||
ORDER BY timestamp DESC LIMIT ?""",
|
||||
(doug_id, f"%{query}%", limit),
|
||||
)
|
||||
|
||||
|
||||
class RelationshipRepository:
|
||||
"""CRUD operations for relationships."""
|
||||
|
||||
def __init__(self, db: DatabaseConnection):
|
||||
self.db = db
|
||||
|
||||
def get_or_create(self, doug_id: int, entity_name: str,
|
||||
entity_type: str = "player") -> dict:
|
||||
"""Get or create a relationship record."""
|
||||
row = self.db.fetchone(
|
||||
"SELECT * FROM relationships WHERE doug_id = ? AND entity_name = ?",
|
||||
(doug_id, entity_name),
|
||||
)
|
||||
if row:
|
||||
return row
|
||||
|
||||
self.db.execute(
|
||||
"""INSERT INTO relationships (doug_id, entity_name, entity_type)
|
||||
VALUES (?, ?, ?)""",
|
||||
(doug_id, entity_name, entity_type),
|
||||
)
|
||||
return self.db.fetchone(
|
||||
"SELECT * FROM relationships WHERE doug_id = ? AND entity_name = ?",
|
||||
(doug_id, entity_name),
|
||||
)
|
||||
|
||||
def update_score(self, doug_id: int, entity_name: str,
|
||||
delta: float, reason: str = "") -> None:
|
||||
"""Update a relationship score by a delta amount."""
|
||||
self.get_or_create(doug_id, entity_name)
|
||||
self.db.execute(
|
||||
"""UPDATE relationships
|
||||
SET score = MIN(100, MAX(-100, score + ?)),
|
||||
interactions_count = interactions_count + 1,
|
||||
last_interaction = CURRENT_TIMESTAMP,
|
||||
notes = CASE WHEN ? != '' THEN ? ELSE notes END
|
||||
WHERE doug_id = ? AND entity_name = ?""",
|
||||
(delta, reason, reason, doug_id, entity_name),
|
||||
)
|
||||
|
||||
def get_all(self, doug_id: int) -> list[dict]:
|
||||
"""Get all relationships for a Doug."""
|
||||
return self.db.fetchall(
|
||||
"SELECT * FROM relationships WHERE doug_id = ? ORDER BY score DESC",
|
||||
(doug_id,),
|
||||
)
|
||||
|
||||
def get_score(self, doug_id: int, entity_name: str) -> float:
|
||||
"""Get the relationship score with an entity."""
|
||||
row = self.db.fetchone(
|
||||
"SELECT score FROM relationships WHERE doug_id = ? AND entity_name = ?",
|
||||
(doug_id, entity_name),
|
||||
)
|
||||
return row["score"] if row else 0.0
|
||||
|
|
@ -1,341 +0,0 @@
|
|||
"""
|
||||
Database schema definitions and auto-migration.
|
||||
Supports both SQLite and MariaDB with dialect-aware SQL.
|
||||
"""
|
||||
|
||||
from dougbot.db.connection import DatabaseConnection
|
||||
from dougbot.utils.logging import get_logger
|
||||
|
||||
log = get_logger("db.schema")
|
||||
|
||||
|
||||
def _tables_sqlite() -> dict[str, str]:
|
||||
"""SQLite table definitions."""
|
||||
return {
|
||||
"settings": """
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""",
|
||||
"dougs": """
|
||||
CREATE TABLE IF NOT EXISTS dougs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
server_host TEXT NOT NULL,
|
||||
server_port INTEGER NOT NULL DEFAULT 19132,
|
||||
server_type TEXT NOT NULL DEFAULT 'bedrock',
|
||||
username TEXT NOT NULL,
|
||||
age INTEGER NOT NULL DEFAULT 18,
|
||||
persona_config TEXT NOT NULL DEFAULT '{}',
|
||||
custom_notes TEXT DEFAULT '',
|
||||
locked INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_deployed TIMESTAMP
|
||||
)
|
||||
""",
|
||||
"auth": """
|
||||
CREATE TABLE IF NOT EXISTS auth (
|
||||
doug_id INTEGER PRIMARY KEY,
|
||||
email TEXT DEFAULT '',
|
||||
encrypted_password TEXT DEFAULT '',
|
||||
auth_tokens TEXT DEFAULT '',
|
||||
FOREIGN KEY (doug_id) REFERENCES dougs(id) ON DELETE CASCADE
|
||||
)
|
||||
""",
|
||||
"chat_history": """
|
||||
CREATE TABLE IF NOT EXISTS chat_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
doug_id INTEGER NOT NULL,
|
||||
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
sender TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
context TEXT DEFAULT '',
|
||||
FOREIGN KEY (doug_id) REFERENCES dougs(id) ON DELETE CASCADE
|
||||
)
|
||||
""",
|
||||
"memories_episodic": """
|
||||
CREATE TABLE IF NOT EXISTS memories_episodic (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
doug_id INTEGER NOT NULL,
|
||||
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
event_type TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
location_x REAL,
|
||||
location_y REAL,
|
||||
location_z REAL,
|
||||
importance REAL NOT NULL DEFAULT 0.5,
|
||||
FOREIGN KEY (doug_id) REFERENCES dougs(id) ON DELETE CASCADE
|
||||
)
|
||||
""",
|
||||
"memories_semantic": """
|
||||
CREATE TABLE IF NOT EXISTS memories_semantic (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
doug_id INTEGER NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
predicate TEXT NOT NULL,
|
||||
object TEXT NOT NULL,
|
||||
confidence REAL NOT NULL DEFAULT 0.8,
|
||||
source TEXT DEFAULT 'observation',
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (doug_id) REFERENCES dougs(id) ON DELETE CASCADE
|
||||
)
|
||||
""",
|
||||
"memories_spatial": """
|
||||
CREATE TABLE IF NOT EXISTS memories_spatial (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
doug_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
location_type TEXT NOT NULL,
|
||||
x REAL NOT NULL,
|
||||
y REAL NOT NULL,
|
||||
z REAL NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
discovered_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (doug_id) REFERENCES dougs(id) ON DELETE CASCADE
|
||||
)
|
||||
""",
|
||||
"memories_procedural": """
|
||||
CREATE TABLE IF NOT EXISTS memories_procedural (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
doug_id INTEGER NOT NULL,
|
||||
skill_name TEXT NOT NULL,
|
||||
steps TEXT NOT NULL DEFAULT '[]',
|
||||
success_rate REAL NOT NULL DEFAULT 0.5,
|
||||
times_used INTEGER NOT NULL DEFAULT 0,
|
||||
last_used TIMESTAMP,
|
||||
FOREIGN KEY (doug_id) REFERENCES dougs(id) ON DELETE CASCADE
|
||||
)
|
||||
""",
|
||||
"relationships": """
|
||||
CREATE TABLE IF NOT EXISTS relationships (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
doug_id INTEGER NOT NULL,
|
||||
entity_name TEXT NOT NULL,
|
||||
entity_type TEXT NOT NULL DEFAULT 'player',
|
||||
score REAL NOT NULL DEFAULT 0,
|
||||
interactions_count INTEGER NOT NULL DEFAULT 0,
|
||||
notes TEXT DEFAULT '',
|
||||
first_met TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_interaction TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(doug_id, entity_name)
|
||||
)
|
||||
""",
|
||||
"personality_evolution": """
|
||||
CREATE TABLE IF NOT EXISTS personality_evolution (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
doug_id INTEGER NOT NULL,
|
||||
trait_name TEXT NOT NULL,
|
||||
original_value REAL NOT NULL,
|
||||
current_value REAL NOT NULL,
|
||||
reason TEXT NOT NULL,
|
||||
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (doug_id) REFERENCES dougs(id) ON DELETE CASCADE
|
||||
)
|
||||
""",
|
||||
"tasks": """
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
doug_id INTEGER NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
priority REAL NOT NULL DEFAULT 0.5,
|
||||
subtasks TEXT DEFAULT '[]',
|
||||
assigned_by TEXT DEFAULT 'self',
|
||||
started_at TIMESTAMP,
|
||||
completed_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (doug_id) REFERENCES dougs(id) ON DELETE CASCADE
|
||||
)
|
||||
""",
|
||||
"deaths": """
|
||||
CREATE TABLE IF NOT EXISTS deaths (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
doug_id INTEGER NOT NULL,
|
||||
player_name TEXT NOT NULL,
|
||||
location_x REAL,
|
||||
location_y REAL,
|
||||
location_z REAL,
|
||||
cause TEXT DEFAULT 'unknown',
|
||||
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
grave_built INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (doug_id) REFERENCES dougs(id) ON DELETE CASCADE
|
||||
)
|
||||
""",
|
||||
}
|
||||
|
||||
|
||||
def _tables_mariadb() -> dict[str, str]:
|
||||
"""MariaDB table definitions."""
|
||||
return {
|
||||
"settings": """
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
`key` VARCHAR(255) PRIMARY KEY,
|
||||
`value` TEXT NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
)
|
||||
""",
|
||||
"dougs": """
|
||||
CREATE TABLE IF NOT EXISTS dougs (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
name VARCHAR(255) NOT NULL UNIQUE,
|
||||
server_host VARCHAR(255) NOT NULL,
|
||||
server_port INT NOT NULL DEFAULT 19132,
|
||||
server_type VARCHAR(50) NOT NULL DEFAULT 'bedrock',
|
||||
username VARCHAR(255) NOT NULL,
|
||||
age INT NOT NULL DEFAULT 18,
|
||||
persona_config LONGTEXT NOT NULL,
|
||||
custom_notes TEXT DEFAULT NULL,
|
||||
locked TINYINT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_deployed TIMESTAMP NULL DEFAULT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""",
|
||||
"auth": """
|
||||
CREATE TABLE IF NOT EXISTS auth (
|
||||
doug_id INT PRIMARY KEY,
|
||||
email VARCHAR(255) DEFAULT '',
|
||||
encrypted_password TEXT DEFAULT NULL,
|
||||
auth_tokens TEXT DEFAULT NULL,
|
||||
FOREIGN KEY (doug_id) REFERENCES dougs(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""",
|
||||
"chat_history": """
|
||||
CREATE TABLE IF NOT EXISTS chat_history (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
doug_id INT NOT NULL,
|
||||
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
sender VARCHAR(255) NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
context TEXT DEFAULT NULL,
|
||||
FOREIGN KEY (doug_id) REFERENCES dougs(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""",
|
||||
"memories_episodic": """
|
||||
CREATE TABLE IF NOT EXISTS memories_episodic (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
doug_id INT NOT NULL,
|
||||
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
event_type VARCHAR(100) NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
location_x DOUBLE DEFAULT NULL,
|
||||
location_y DOUBLE DEFAULT NULL,
|
||||
location_z DOUBLE DEFAULT NULL,
|
||||
importance DOUBLE NOT NULL DEFAULT 0.5,
|
||||
FOREIGN KEY (doug_id) REFERENCES dougs(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""",
|
||||
"memories_semantic": """
|
||||
CREATE TABLE IF NOT EXISTS memories_semantic (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
doug_id INT NOT NULL,
|
||||
subject VARCHAR(255) NOT NULL,
|
||||
predicate VARCHAR(255) NOT NULL,
|
||||
object TEXT NOT NULL,
|
||||
confidence DOUBLE NOT NULL DEFAULT 0.8,
|
||||
source VARCHAR(100) DEFAULT 'observation',
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (doug_id) REFERENCES dougs(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""",
|
||||
"memories_spatial": """
|
||||
CREATE TABLE IF NOT EXISTS memories_spatial (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
doug_id INT NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
location_type VARCHAR(100) NOT NULL,
|
||||
x DOUBLE NOT NULL,
|
||||
y DOUBLE NOT NULL,
|
||||
z DOUBLE NOT NULL,
|
||||
description TEXT DEFAULT NULL,
|
||||
discovered_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (doug_id) REFERENCES dougs(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""",
|
||||
"memories_procedural": """
|
||||
CREATE TABLE IF NOT EXISTS memories_procedural (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
doug_id INT NOT NULL,
|
||||
skill_name VARCHAR(255) NOT NULL,
|
||||
steps TEXT NOT NULL,
|
||||
success_rate DOUBLE NOT NULL DEFAULT 0.5,
|
||||
times_used INT NOT NULL DEFAULT 0,
|
||||
last_used TIMESTAMP NULL DEFAULT NULL,
|
||||
FOREIGN KEY (doug_id) REFERENCES dougs(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""",
|
||||
"relationships": """
|
||||
CREATE TABLE IF NOT EXISTS relationships (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
doug_id INT NOT NULL,
|
||||
entity_name VARCHAR(255) NOT NULL,
|
||||
entity_type VARCHAR(100) NOT NULL DEFAULT 'player',
|
||||
score DOUBLE NOT NULL DEFAULT 0,
|
||||
interactions_count INT NOT NULL DEFAULT 0,
|
||||
notes TEXT DEFAULT NULL,
|
||||
first_met TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_interaction TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_rel (doug_id, entity_name),
|
||||
FOREIGN KEY (doug_id) REFERENCES dougs(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""",
|
||||
"personality_evolution": """
|
||||
CREATE TABLE IF NOT EXISTS personality_evolution (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
doug_id INT NOT NULL,
|
||||
trait_name VARCHAR(255) NOT NULL,
|
||||
original_value DOUBLE NOT NULL,
|
||||
current_value DOUBLE NOT NULL,
|
||||
reason TEXT NOT NULL,
|
||||
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (doug_id) REFERENCES dougs(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""",
|
||||
"tasks": """
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
doug_id INT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'pending',
|
||||
priority DOUBLE NOT NULL DEFAULT 0.5,
|
||||
subtasks TEXT DEFAULT NULL,
|
||||
assigned_by VARCHAR(255) DEFAULT 'self',
|
||||
started_at TIMESTAMP NULL DEFAULT NULL,
|
||||
completed_at TIMESTAMP NULL DEFAULT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (doug_id) REFERENCES dougs(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""",
|
||||
"deaths": """
|
||||
CREATE TABLE IF NOT EXISTS deaths (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
doug_id INT NOT NULL,
|
||||
player_name VARCHAR(255) NOT NULL,
|
||||
location_x DOUBLE DEFAULT NULL,
|
||||
location_y DOUBLE DEFAULT NULL,
|
||||
location_z DOUBLE DEFAULT NULL,
|
||||
cause VARCHAR(255) DEFAULT 'unknown',
|
||||
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
grave_built TINYINT NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (doug_id) REFERENCES dougs(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""",
|
||||
}
|
||||
|
||||
|
||||
def initialize_database(db: DatabaseConnection) -> None:
|
||||
"""Create all tables if they don't exist."""
|
||||
log.info("Initializing database schema...")
|
||||
|
||||
tables = _tables_mariadb() if db.db_type == "mariadb" else _tables_sqlite()
|
||||
|
||||
for table_name, create_sql in tables.items():
|
||||
try:
|
||||
db.execute(create_sql)
|
||||
log.debug(f"Table '{table_name}' ready")
|
||||
except Exception as e:
|
||||
log.error(f"Failed to create table '{table_name}': {e}")
|
||||
raise
|
||||
|
||||
log.info(f"Database initialized with {len(tables)} tables")
|
||||
|
|
@ -1,660 +0,0 @@
|
|||
"""
|
||||
Create Doug screen — streamlined persona creation with connection type switching.
|
||||
"""
|
||||
|
||||
import webbrowser
|
||||
import subprocess
|
||||
import threading
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
|
||||
QPushButton, QSpinBox, QCheckBox, QComboBox, QTextEdit,
|
||||
QScrollArea, QFrame, QGroupBox, QRadioButton, QButtonGroup,
|
||||
QStackedWidget, QMessageBox, QSizePolicy, QApplication,
|
||||
)
|
||||
from PySide6.QtCore import Signal, Qt, QTimer, QMetaObject, Q_ARG, Slot
|
||||
|
||||
from dougbot.gui.widgets.trait_slider import TraitSlider
|
||||
from dougbot.gui.widgets.quirk_grid import QuirkGrid
|
||||
from dougbot.db.models import DougModel, PersonaConfig
|
||||
from dougbot.utils.logging import get_logger
|
||||
|
||||
log = get_logger("gui.create_doug")
|
||||
|
||||
CORE_SLIDERS = [
|
||||
("bravery", "Bravery", "Cowardly", "Fearless"),
|
||||
("sociability", "Sociability", "Hermit", "Social Butterfly"),
|
||||
("patience", "Patience", "Hot-Headed", "Zen Master"),
|
||||
("ambition", "Ambition", "Lazy", "Overachiever"),
|
||||
("empathy", "Empathy", "Cold", "Bleeding Heart"),
|
||||
("curiosity", "Curiosity", "Stick-in-Mud", "Explorer"),
|
||||
("generosity", "Generosity", "Greedy", "Santa Claus"),
|
||||
("sarcasm", "Sarcasm", "Earnest", "Dripping"),
|
||||
("orderliness", "Orderliness", "Chaotic", "Military"),
|
||||
]
|
||||
SOCIAL_SLIDERS = [
|
||||
("loyalty", "Loyalty", "Freelancer", "Ride or Die"),
|
||||
("stubbornness", "Stubbornness", "Pushover", "Immovable"),
|
||||
("self_awareness", "Self-Awareness", "Oblivious", "Hyper-Aware"),
|
||||
]
|
||||
GAMEPLAY_SLIDERS = [
|
||||
("risk_tolerance", "Risk Tolerance", "Safety First", "YOLO"),
|
||||
("creativity", "Creativity", "Cookie Cutter", "Avant-Garde"),
|
||||
("work_ethic", "Work Ethic", "Slacker", "Workaholic"),
|
||||
]
|
||||
|
||||
|
||||
class CreateDougScreen(QWidget):
|
||||
doug_created = Signal(int)
|
||||
closed = Signal()
|
||||
|
||||
def __init__(self, db, parent=None):
|
||||
super().__init__(parent)
|
||||
self.db = db
|
||||
self._sliders: dict[str, TraitSlider] = {}
|
||||
self._init_ui()
|
||||
|
||||
def _init_ui(self):
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(0, 0, 0, 0)
|
||||
root.setSpacing(0)
|
||||
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
|
||||
page = QWidget()
|
||||
lay = QVBoxLayout(page)
|
||||
lay.setSpacing(10)
|
||||
lay.setContentsMargins(36, 28, 36, 20)
|
||||
|
||||
# ── Header ──
|
||||
t = QLabel("CREATE NEW DOUG")
|
||||
t.setObjectName("appTitle")
|
||||
lay.addWidget(t)
|
||||
s = QLabel("Configure identity, connection, and personality")
|
||||
s.setObjectName("appSubtitle")
|
||||
lay.addWidget(s)
|
||||
lay.addSpacing(4)
|
||||
|
||||
# ═══ Identity ═══
|
||||
id_box = QGroupBox("Identity")
|
||||
id_lay = QVBoxLayout(id_box)
|
||||
id_lay.setSpacing(8)
|
||||
|
||||
r = QHBoxLayout()
|
||||
r.addWidget(self._lbl("Name"))
|
||||
self.name_field = QLineEdit()
|
||||
self.name_field.setPlaceholderText("Doug's name")
|
||||
self.name_field.setMaximumWidth(220)
|
||||
r.addWidget(self.name_field)
|
||||
r.addSpacing(16)
|
||||
r.addWidget(self._lbl("Age", 40))
|
||||
self.age_spin = QSpinBox()
|
||||
self.age_spin.setRange(5, 99)
|
||||
self.age_spin.setValue(18)
|
||||
self.age_spin.setFixedWidth(70)
|
||||
r.addWidget(self.age_spin)
|
||||
r.addStretch()
|
||||
id_lay.addLayout(r)
|
||||
lay.addWidget(id_box)
|
||||
|
||||
# ═══ Connection ═══
|
||||
cn_box = QGroupBox("Connection")
|
||||
cn_lay = QVBoxLayout(cn_box)
|
||||
cn_lay.setSpacing(8)
|
||||
|
||||
tr = QHBoxLayout()
|
||||
self._conn_grp = QButtonGroup(self)
|
||||
self.r_offline = QRadioButton("Offline Server")
|
||||
self.r_offline.setChecked(True)
|
||||
self._conn_grp.addButton(self.r_offline, 0)
|
||||
tr.addWidget(self.r_offline)
|
||||
self.r_online = QRadioButton("Online Server")
|
||||
self._conn_grp.addButton(self.r_online, 1)
|
||||
tr.addWidget(self.r_online)
|
||||
self.r_realm = QRadioButton("Bedrock Realm")
|
||||
self._conn_grp.addButton(self.r_realm, 2)
|
||||
tr.addWidget(self.r_realm)
|
||||
tr.addStretch()
|
||||
cn_lay.addLayout(tr)
|
||||
|
||||
self._conn_stack = QStackedWidget()
|
||||
|
||||
# Page 0 — Offline
|
||||
p0 = QWidget()
|
||||
p0l = QVBoxLayout(p0)
|
||||
p0l.setContentsMargins(0, 4, 0, 0)
|
||||
p0l.setSpacing(6)
|
||||
r1 = QHBoxLayout()
|
||||
r1.addWidget(self._lbl("Address"))
|
||||
self.off_host = QLineEdit("127.0.0.1")
|
||||
self.off_host.setMaximumWidth(200)
|
||||
r1.addWidget(self.off_host)
|
||||
r1.addWidget(self._lbl("Port", 40))
|
||||
self.off_port = QLineEdit("19132")
|
||||
self.off_port.setFixedWidth(70)
|
||||
r1.addWidget(self.off_port)
|
||||
r1.addStretch()
|
||||
p0l.addLayout(r1)
|
||||
r2 = QHBoxLayout()
|
||||
r2.addWidget(self._lbl("Username"))
|
||||
self.off_user = QLineEdit()
|
||||
self.off_user.setPlaceholderText("In-game name (defaults to Doug's name)")
|
||||
self.off_user.setMaximumWidth(260)
|
||||
r2.addWidget(self.off_user)
|
||||
r2.addStretch()
|
||||
p0l.addLayout(r2)
|
||||
self._conn_stack.addWidget(p0)
|
||||
|
||||
# Page 1 — Online Server
|
||||
p1 = QWidget()
|
||||
p1l = QVBoxLayout(p1)
|
||||
p1l.setContentsMargins(0, 4, 0, 0)
|
||||
p1l.setSpacing(6)
|
||||
r3 = QHBoxLayout()
|
||||
r3.addWidget(self._lbl("Address"))
|
||||
self.on_host = QLineEdit()
|
||||
self.on_host.setPlaceholderText("server.example.com")
|
||||
self.on_host.setMaximumWidth(200)
|
||||
r3.addWidget(self.on_host)
|
||||
r3.addWidget(self._lbl("Port", 40))
|
||||
self.on_port = QLineEdit("19132")
|
||||
self.on_port.setFixedWidth(70)
|
||||
r3.addWidget(self.on_port)
|
||||
r3.addStretch()
|
||||
p1l.addLayout(r3)
|
||||
|
||||
r4 = QHBoxLayout()
|
||||
r4.addWidget(self._lbl("MS Email"))
|
||||
self.on_email = QLineEdit()
|
||||
self.on_email.setPlaceholderText("doug@outlook.com")
|
||||
self.on_email.setMaximumWidth(260)
|
||||
r4.addWidget(self.on_email)
|
||||
self.on_auth_btn = QPushButton("Authenticate")
|
||||
self.on_auth_btn.setObjectName("authBtn")
|
||||
self.on_auth_btn.clicked.connect(lambda: self._authenticate(self.on_email, self.on_auth_status))
|
||||
r4.addWidget(self.on_auth_btn)
|
||||
r4.addStretch()
|
||||
p1l.addLayout(r4)
|
||||
|
||||
self.on_auth_status = QLabel("")
|
||||
self.on_auth_status.setObjectName("fieldLabel")
|
||||
self.on_auth_status.setWordWrap(True)
|
||||
p1l.addWidget(self.on_auth_status)
|
||||
self._conn_stack.addWidget(p1)
|
||||
|
||||
# Page 2 — Bedrock Realm
|
||||
p2 = QWidget()
|
||||
p2l = QVBoxLayout(p2)
|
||||
p2l.setContentsMargins(0, 4, 0, 0)
|
||||
p2l.setSpacing(6)
|
||||
|
||||
r5 = QHBoxLayout()
|
||||
r5.addWidget(self._lbl("MS Email"))
|
||||
self.rm_email = QLineEdit()
|
||||
self.rm_email.setPlaceholderText("doug@outlook.com")
|
||||
self.rm_email.setMaximumWidth(260)
|
||||
r5.addWidget(self.rm_email)
|
||||
self.rm_auth_btn = QPushButton("Authenticate & Load Realms")
|
||||
self.rm_auth_btn.setObjectName("authBtn")
|
||||
self.rm_auth_btn.clicked.connect(self._authenticate_and_load_realms)
|
||||
r5.addWidget(self.rm_auth_btn)
|
||||
r5.addStretch()
|
||||
p2l.addLayout(r5)
|
||||
|
||||
self.rm_auth_status = QLabel("")
|
||||
self.rm_auth_status.setObjectName("fieldLabel")
|
||||
self.rm_auth_status.setWordWrap(True)
|
||||
p2l.addWidget(self.rm_auth_status)
|
||||
|
||||
r6 = QHBoxLayout()
|
||||
r6.addWidget(self._lbl("Realm"))
|
||||
self.rm_combo = QComboBox()
|
||||
self.rm_combo.setPlaceholderText("Authenticate first to load Realms")
|
||||
self.rm_combo.setMinimumWidth(260)
|
||||
self.rm_combo.setEnabled(False)
|
||||
r6.addWidget(self.rm_combo)
|
||||
r6.addStretch()
|
||||
p2l.addLayout(r6)
|
||||
self._conn_stack.addWidget(p2)
|
||||
|
||||
cn_lay.addWidget(self._conn_stack)
|
||||
lay.addWidget(cn_box)
|
||||
self._conn_grp.idToggled.connect(
|
||||
lambda bid, chk: self._conn_stack.setCurrentIndex(bid) if chk else None
|
||||
)
|
||||
|
||||
# ═══ Quirks ═══
|
||||
qk_box = QGroupBox("Behavioral Quirks")
|
||||
qk_lay = QVBoxLayout(qk_box)
|
||||
qk_lay.setSpacing(6)
|
||||
qk_hint = QLabel("Toggle quirks for unique behavioral patterns")
|
||||
qk_hint.setObjectName("fieldLabel")
|
||||
qk_lay.addWidget(qk_hint)
|
||||
self.quirk_grid = QuirkGrid(columns=4)
|
||||
qk_lay.addWidget(self.quirk_grid)
|
||||
|
||||
sp_row = QHBoxLayout()
|
||||
self.believes_real = QCheckBox("Believes He Is Real")
|
||||
self.believes_real.setChecked(True)
|
||||
self.believes_real.setToolTip("Doug thinks Minecraft is real life")
|
||||
sp_row.addWidget(self.believes_real)
|
||||
sp_row.addSpacing(16)
|
||||
self.profanity_filter = QCheckBox("Profanity Filter")
|
||||
self.profanity_filter.setChecked(True)
|
||||
sp_row.addWidget(self.profanity_filter)
|
||||
sp_row.addStretch()
|
||||
qk_lay.addLayout(sp_row)
|
||||
lay.addWidget(qk_box)
|
||||
|
||||
# ═══ Trait Sliders ═══
|
||||
tr_box = QGroupBox("Personality Traits")
|
||||
tr_lay = QVBoxLayout(tr_box)
|
||||
tr_lay.setSpacing(1)
|
||||
|
||||
for section, sliders in [
|
||||
("CORE", CORE_SLIDERS),
|
||||
("SOCIAL & EMOTIONAL", SOCIAL_SLIDERS),
|
||||
("GAMEPLAY STYLE", GAMEPLAY_SLIDERS),
|
||||
]:
|
||||
h = QLabel(section)
|
||||
h.setObjectName("sectionHeader")
|
||||
tr_lay.addWidget(h)
|
||||
for key, name, lo, hi in sliders:
|
||||
sl = TraitSlider(key, name, 50, lo, hi)
|
||||
tr_lay.addWidget(sl)
|
||||
self._sliders[key] = sl
|
||||
|
||||
lay.addWidget(tr_box)
|
||||
|
||||
# ═══ Custom Notes ═══
|
||||
nt_box = QGroupBox("Custom Notes")
|
||||
nt_lay = QVBoxLayout(nt_box)
|
||||
nt_hint = QLabel("Extra instructions — fears, catchphrases, backstory")
|
||||
nt_hint.setObjectName("fieldLabel")
|
||||
nt_lay.addWidget(nt_hint)
|
||||
self.custom_notes = QTextEdit()
|
||||
self.custom_notes.setPlaceholderText(
|
||||
"e.g. 'Calls diamonds shinies' or 'Deathly afraid of chickens'"
|
||||
)
|
||||
self.custom_notes.setMaximumHeight(80)
|
||||
nt_lay.addWidget(self.custom_notes)
|
||||
lay.addWidget(nt_box)
|
||||
|
||||
lay.addStretch()
|
||||
scroll.setWidget(page)
|
||||
root.addWidget(scroll, 1)
|
||||
|
||||
# ── Bottom bar ──
|
||||
sep = QFrame()
|
||||
sep.setFrameShape(QFrame.Shape.HLine)
|
||||
root.addWidget(sep)
|
||||
|
||||
bot = QHBoxLayout()
|
||||
bot.setContentsMargins(36, 12, 36, 14)
|
||||
bot.setSpacing(10)
|
||||
sv = QPushButton("Create Doug")
|
||||
sv.setObjectName("createBtn")
|
||||
sv.clicked.connect(self._save_doug)
|
||||
bot.addWidget(sv)
|
||||
bot.addStretch()
|
||||
cn = QPushButton("Cancel")
|
||||
cn.setObjectName("cancelBtn")
|
||||
cn.clicked.connect(self.closed.emit)
|
||||
bot.addWidget(cn)
|
||||
root.addLayout(bot)
|
||||
|
||||
# ── Helpers ──
|
||||
|
||||
def _lbl(self, text, w=100):
|
||||
l = QLabel(text)
|
||||
l.setObjectName("fieldLabel")
|
||||
l.setFixedWidth(w)
|
||||
return l
|
||||
|
||||
def _authenticate(self, email_field: QLineEdit, status_label: QLabel):
|
||||
"""Trigger Xbox Live auth for the given email. Opens browser automatically."""
|
||||
email = email_field.text().strip()
|
||||
if not email:
|
||||
QMessageBox.warning(self, "Missing Email", "Enter the Microsoft email first.")
|
||||
return
|
||||
|
||||
status_label.setText("⏳ Starting authentication...")
|
||||
status_label.setStyleSheet("color: #f0a030;")
|
||||
QApplication.processEvents()
|
||||
|
||||
self._run_bridge_auth(email, status_label)
|
||||
|
||||
def _authenticate_and_load_realms(self):
|
||||
"""Authenticate and then fetch the Realm list."""
|
||||
email = self.rm_email.text().strip()
|
||||
if not email:
|
||||
QMessageBox.warning(self, "Missing Email", "Enter the Microsoft email first.")
|
||||
return
|
||||
|
||||
self.rm_auth_status.setText("⏳ Starting authentication...")
|
||||
self.rm_auth_status.setStyleSheet("color: #f0a030;")
|
||||
self.rm_auth_btn.setEnabled(False)
|
||||
self.rm_combo.clear()
|
||||
self.rm_combo.setEnabled(False)
|
||||
QApplication.processEvents()
|
||||
|
||||
self._run_bridge_auth(email, self.rm_auth_status, load_realms=True)
|
||||
|
||||
def _run_bridge_auth(self, email: str, status_label: QLabel, load_realms: bool = False):
|
||||
"""
|
||||
Spawn a temporary bridge process to do auth + optionally list realms.
|
||||
The bridge handles Xbox Live device code flow; we capture events from stdout.
|
||||
"""
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
# Find bridge entry file
|
||||
bridge_dir = Path(__file__).parent.parent.parent / "bridge"
|
||||
entry = bridge_dir / "dist" / "index.js"
|
||||
node_path = shutil.which("node")
|
||||
|
||||
if not node_path or not entry.exists():
|
||||
status_label.setText("✗ Bridge not built or Node.js not found")
|
||||
status_label.setStyleSheet("color: #e8504a;")
|
||||
self.rm_auth_btn.setEnabled(True)
|
||||
return
|
||||
|
||||
# We'll use a small Node.js script to just do auth + list realms
|
||||
# without starting a full WS server or connecting to a game server
|
||||
script = f"""
|
||||
const {{ Authflow, Titles }} = require('prismarine-auth');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const authCacheDir = path.join(os.homedir(), '.dougbot', 'auth-cache');
|
||||
const process_args = {{ email: {json.dumps(email)}, loadRealms: {json.dumps(load_realms)} }};
|
||||
|
||||
async function main() {{
|
||||
const authflow = new Authflow(process_args.email, authCacheDir, {{
|
||||
authTitle: Titles.MinecraftNintendoSwitch,
|
||||
deviceType: 'Nintendo',
|
||||
flow: 'live',
|
||||
}}, (data) => {{
|
||||
console.log(JSON.stringify({{
|
||||
type: 'device_code',
|
||||
code: data.user_code,
|
||||
url: data.verification_uri,
|
||||
message: data.message
|
||||
}}));
|
||||
}});
|
||||
|
||||
// Trigger Xbox Live auth (don't need Bedrock token, just Xbox)
|
||||
await authflow.getXboxToken();
|
||||
console.log(JSON.stringify({{ type: 'auth_success' }}));
|
||||
|
||||
if (process_args.loadRealms) {{
|
||||
try {{
|
||||
const {{ RealmAPI }} = require('prismarine-realms');
|
||||
const api = RealmAPI.from(authflow, 'bedrock');
|
||||
const realms = await api.getRealms();
|
||||
console.log(JSON.stringify({{
|
||||
type: 'realms_list',
|
||||
realms: realms.map(r => ({{ id: r.id, name: r.name, state: r.state }}))
|
||||
}}));
|
||||
}} catch (e) {{
|
||||
console.log(JSON.stringify({{ type: 'realms_error', error: e.message }}));
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
|
||||
main().then(() => process.exit(0)).catch(e => {{
|
||||
console.log(JSON.stringify({{ type: 'error', error: e.message }}));
|
||||
process.exit(1);
|
||||
}});
|
||||
"""
|
||||
# Write temp script
|
||||
tmp_script = bridge_dir / "dist" / "_auth_tmp.js"
|
||||
tmp_script.write_text(script)
|
||||
|
||||
def run_auth():
|
||||
"""Run auth in a background thread, post results back to GUI."""
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
[node_path, str(tmp_script)],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
cwd=str(bridge_dir),
|
||||
text=True,
|
||||
)
|
||||
|
||||
for line in proc.stdout:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
msg = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
if msg.get("type") == "device_code":
|
||||
code = msg["code"]
|
||||
url = msg["url"]
|
||||
# Open browser automatically
|
||||
webbrowser.open(url)
|
||||
# Update UI from main thread
|
||||
QMetaObject.invokeMethod(
|
||||
self, "_on_auth_device_code",
|
||||
Qt.ConnectionType.QueuedConnection,
|
||||
Q_ARG(str, code),
|
||||
Q_ARG(str, url),
|
||||
Q_ARG(str, status_label.objectName() or "status"),
|
||||
)
|
||||
|
||||
elif msg.get("type") == "auth_success":
|
||||
QMetaObject.invokeMethod(
|
||||
self, "_on_auth_success",
|
||||
Qt.ConnectionType.QueuedConnection,
|
||||
Q_ARG(str, email),
|
||||
Q_ARG(str, status_label.objectName() or "status"),
|
||||
)
|
||||
|
||||
elif msg.get("type") == "realms_list":
|
||||
realms_json = json.dumps(msg["realms"])
|
||||
QMetaObject.invokeMethod(
|
||||
self, "_on_realms_loaded",
|
||||
Qt.ConnectionType.QueuedConnection,
|
||||
Q_ARG(str, realms_json),
|
||||
)
|
||||
|
||||
elif msg.get("type") in ("error", "realms_error"):
|
||||
QMetaObject.invokeMethod(
|
||||
self, "_on_auth_error",
|
||||
Qt.ConnectionType.QueuedConnection,
|
||||
Q_ARG(str, msg.get("error", "Unknown error")),
|
||||
Q_ARG(str, status_label.objectName() or "status"),
|
||||
)
|
||||
|
||||
proc.wait()
|
||||
|
||||
except Exception as e:
|
||||
QMetaObject.invokeMethod(
|
||||
self, "_on_auth_error",
|
||||
Qt.ConnectionType.QueuedConnection,
|
||||
Q_ARG(str, str(e)),
|
||||
Q_ARG(str, status_label.objectName() or "status"),
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
tmp_script.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Give status labels object names so we can find them from slots
|
||||
if status_label is self.rm_auth_status:
|
||||
status_label.setObjectName("rm_auth_status")
|
||||
else:
|
||||
status_label.setObjectName("on_auth_status")
|
||||
|
||||
thread = threading.Thread(target=run_auth, daemon=True)
|
||||
thread.start()
|
||||
|
||||
# ── Auth callback slots (called from background thread via QMetaObject) ──
|
||||
|
||||
@Slot(str, str, str)
|
||||
def _on_auth_device_code(self, code: str, url: str, label_name: str):
|
||||
"""Device code received — show it prominently."""
|
||||
lbl = self._find_status_label(label_name)
|
||||
if lbl:
|
||||
lbl.setText(
|
||||
f'🔐 Go to <b>{url}</b> and enter code: '
|
||||
f'<span style="color:#2dd4a8; font-size:18px; font-weight:800; '
|
||||
f'letter-spacing:3px;">{code}</span>'
|
||||
f' (browser opened automatically)'
|
||||
)
|
||||
lbl.setStyleSheet("color: #5aa5f5;")
|
||||
|
||||
@Slot(str, str)
|
||||
def _on_auth_success(self, email: str, label_name: str):
|
||||
"""Authentication succeeded."""
|
||||
lbl = self._find_status_label(label_name)
|
||||
if lbl:
|
||||
lbl.setText(f"✓ Authenticated as {email}")
|
||||
lbl.setStyleSheet("color: #2dd4a8;")
|
||||
self.rm_auth_btn.setEnabled(True)
|
||||
log.info(f"Xbox Live auth succeeded for {email}")
|
||||
|
||||
@Slot(str)
|
||||
def _on_realms_loaded(self, realms_json: str):
|
||||
"""Realms list received."""
|
||||
import json
|
||||
realms = json.loads(realms_json)
|
||||
self.rm_combo.clear()
|
||||
if realms:
|
||||
for r in realms:
|
||||
state = r.get("state", "")
|
||||
name = r.get("name", "Unknown")
|
||||
rid = r.get("id", "")
|
||||
suffix = " (open)" if state == "OPEN" else f" ({state.lower()})"
|
||||
self.rm_combo.addItem(f"{name}{suffix}", rid)
|
||||
self.rm_combo.setEnabled(True)
|
||||
self.rm_auth_status.setText(
|
||||
f"✓ Found {len(realms)} Realm{'s' if len(realms) != 1 else ''}"
|
||||
)
|
||||
self.rm_auth_status.setStyleSheet("color: #2dd4a8;")
|
||||
else:
|
||||
self.rm_combo.addItem("(No Realms found for this account)")
|
||||
self.rm_auth_status.setText("✗ No Realms available")
|
||||
self.rm_auth_status.setStyleSheet("color: #e8504a;")
|
||||
self.rm_auth_btn.setEnabled(True)
|
||||
|
||||
@Slot(str, str)
|
||||
def _on_auth_error(self, error: str, label_name: str):
|
||||
"""Auth or Realm loading failed."""
|
||||
lbl = self._find_status_label(label_name)
|
||||
if lbl:
|
||||
lbl.setText(f"✗ {error}")
|
||||
lbl.setStyleSheet("color: #e8504a;")
|
||||
self.rm_auth_btn.setEnabled(True)
|
||||
log.error(f"Auth error: {error}")
|
||||
|
||||
def _find_status_label(self, name: str) -> QLabel | None:
|
||||
if name == "rm_auth_status":
|
||||
return self.rm_auth_status
|
||||
elif name == "on_auth_status":
|
||||
return self.on_auth_status
|
||||
return None
|
||||
|
||||
def _get_conn(self):
|
||||
cid = self._conn_grp.checkedId()
|
||||
if cid == 0:
|
||||
return {"type": "offline", "host": self.off_host.text().strip() or "127.0.0.1",
|
||||
"port": int(self.off_port.text() or 19132),
|
||||
"username": self.off_user.text().strip(), "email": ""}
|
||||
elif cid == 1:
|
||||
return {"type": "online", "host": self.on_host.text().strip(),
|
||||
"port": int(self.on_port.text() or 19132),
|
||||
"username": "", "email": self.on_email.text().strip()}
|
||||
else:
|
||||
realm_name = self.rm_combo.currentText() if self.rm_combo.currentText() and not self.rm_combo.currentText().startswith("(") else ""
|
||||
realm_id = str(self.rm_combo.currentData() or "") if realm_name else ""
|
||||
return {"type": "realm", "host": "", "port": 0,
|
||||
"realm_name": realm_name, "realm_id": realm_id,
|
||||
"username": "", "email": self.rm_email.text().strip()}
|
||||
|
||||
def _save_doug(self):
|
||||
name = self.name_field.text().strip()
|
||||
if not name:
|
||||
QMessageBox.warning(self, "Missing Name", "Give your Doug a name!")
|
||||
return
|
||||
conn = self._get_conn()
|
||||
if conn["type"] in ("online", "realm") and not conn["email"]:
|
||||
QMessageBox.warning(self, "Missing Email", "A Microsoft email is required.")
|
||||
return
|
||||
if conn["type"] in ("offline", "online") and not conn.get("host"):
|
||||
QMessageBox.warning(self, "Missing Server", "Enter a server address.")
|
||||
return
|
||||
|
||||
from dougbot.db.queries import DougRepository
|
||||
repo = DougRepository(self.db)
|
||||
if repo.get_by_name(name):
|
||||
QMessageBox.warning(self, "Duplicate", f"'{name}' already exists!")
|
||||
return
|
||||
|
||||
persona = PersonaConfig()
|
||||
for k, sl in self._sliders.items():
|
||||
if hasattr(persona, k):
|
||||
setattr(persona, k, sl.value())
|
||||
for k, v in self.quirk_grid.get_quirks().items():
|
||||
if hasattr(persona, k):
|
||||
setattr(persona, k, v)
|
||||
persona.believes_real = self.believes_real.isChecked()
|
||||
persona.profanity_filter = self.profanity_filter.isChecked()
|
||||
|
||||
doug = DougModel(
|
||||
name=name,
|
||||
server_host=conn.get("host", ""),
|
||||
server_port=conn.get("port", 0),
|
||||
server_type=conn["type"],
|
||||
username=conn.get("username") or name,
|
||||
age=self.age_spin.value(),
|
||||
persona_config=persona.to_dict(),
|
||||
custom_notes=self.custom_notes.toPlainText(),
|
||||
)
|
||||
did = repo.create(doug)
|
||||
|
||||
if conn.get("email"):
|
||||
self.db.execute(
|
||||
"INSERT INTO auth (doug_id, email) VALUES (?, ?)",
|
||||
(did, conn["email"]),
|
||||
)
|
||||
if conn["type"] == "realm":
|
||||
if conn.get("realm_name"):
|
||||
self.db.execute(
|
||||
"REPLACE INTO settings (`key`, value) VALUES (?, ?)",
|
||||
(f"doug_{did}_realm_name", conn["realm_name"]),
|
||||
)
|
||||
if conn.get("realm_id"):
|
||||
self.db.execute(
|
||||
"REPLACE INTO settings (`key`, value) VALUES (?, ?)",
|
||||
(f"doug_{did}_realm_id", conn["realm_id"]),
|
||||
)
|
||||
repo.lock(did)
|
||||
log.info(f"Created Doug '{name}' (ID: {did}, type: {conn['type']})")
|
||||
self.doug_created.emit(did)
|
||||
|
||||
def reset_form(self):
|
||||
self.name_field.clear()
|
||||
self.age_spin.setValue(18)
|
||||
self.r_offline.setChecked(True)
|
||||
self.off_host.setText("127.0.0.1")
|
||||
self.off_port.setText("19132")
|
||||
self.off_user.clear()
|
||||
self.on_host.clear()
|
||||
self.on_port.setText("19132")
|
||||
self.on_email.clear()
|
||||
self.rm_email.clear()
|
||||
self.rm_combo.clear()
|
||||
self.rm_combo.setEnabled(False)
|
||||
self.custom_notes.clear()
|
||||
self.believes_real.setChecked(True)
|
||||
self.profanity_filter.setChecked(True)
|
||||
self.quirk_grid.set_quirks({})
|
||||
for sl in self._sliders.values():
|
||||
sl.set_value(50)
|
||||
|
|
@ -1,262 +0,0 @@
|
|||
"""
|
||||
Dashboard screen — main control panel.
|
||||
"""
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QComboBox, QPushButton, QFrame, QMessageBox, QSizePolicy,
|
||||
)
|
||||
from PySide6.QtCore import Signal, Qt
|
||||
|
||||
from dougbot.gui.widgets.log_viewer import LogViewer
|
||||
from dougbot.db.queries import DougRepository
|
||||
from dougbot.db.connection import DatabaseConnection
|
||||
from dougbot.utils.logging import get_logger
|
||||
|
||||
log = get_logger("gui.dashboard")
|
||||
|
||||
|
||||
class DashboardScreen(QWidget):
|
||||
create_clicked = Signal()
|
||||
settings_clicked = Signal()
|
||||
quit_clicked = Signal()
|
||||
deploy_requested = Signal(int)
|
||||
stop_requested = Signal(int)
|
||||
delete_requested = Signal(int)
|
||||
|
||||
def __init__(self, db: DatabaseConnection, parent=None):
|
||||
super().__init__(parent)
|
||||
self.db = db
|
||||
self._doug_ids: list[int] = []
|
||||
self._init_ui()
|
||||
self.refresh_doug_list()
|
||||
|
||||
def _init_ui(self):
|
||||
root = QVBoxLayout(self)
|
||||
root.setSpacing(14)
|
||||
root.setContentsMargins(36, 28, 36, 20)
|
||||
|
||||
# ── Header ──
|
||||
hdr = QHBoxLayout()
|
||||
col = QVBoxLayout()
|
||||
col.setSpacing(0)
|
||||
t = QLabel("DOUGBOT")
|
||||
t.setObjectName("appTitle")
|
||||
col.addWidget(t)
|
||||
s = QLabel("AI Minecraft Companion Manager")
|
||||
s.setObjectName("appSubtitle")
|
||||
col.addWidget(s)
|
||||
hdr.addLayout(col)
|
||||
hdr.addStretch()
|
||||
self._status = QLabel("● Offline")
|
||||
self._status.setStyleSheet("color:#e8504a; font-size:12px; font-weight:600;")
|
||||
hdr.addWidget(self._status, alignment=Qt.AlignmentFlag.AlignTop)
|
||||
root.addLayout(hdr)
|
||||
|
||||
# ── Control row ──
|
||||
ctrl = QHBoxLayout()
|
||||
ctrl.setSpacing(10)
|
||||
self.doug_selector = QComboBox()
|
||||
self.doug_selector.setMinimumWidth(300)
|
||||
self.doug_selector.setPlaceholderText("Select a Doug...")
|
||||
ctrl.addWidget(self.doug_selector)
|
||||
|
||||
self.deploy_btn = QPushButton("Deploy")
|
||||
self.deploy_btn.setFixedWidth(90)
|
||||
self.deploy_btn.clicked.connect(self._on_deploy)
|
||||
ctrl.addWidget(self.deploy_btn)
|
||||
|
||||
self.stop_btn = QPushButton("Stop")
|
||||
self.stop_btn.setFixedWidth(90)
|
||||
self.stop_btn.setEnabled(False)
|
||||
self.stop_btn.clicked.connect(self._on_stop)
|
||||
ctrl.addWidget(self.stop_btn)
|
||||
|
||||
self.delete_btn = QPushButton("Delete")
|
||||
self.delete_btn.setObjectName("deleteBtn")
|
||||
self.delete_btn.setFixedWidth(90)
|
||||
self.delete_btn.clicked.connect(self._on_delete)
|
||||
ctrl.addWidget(self.delete_btn)
|
||||
|
||||
ctrl.addStretch()
|
||||
root.addLayout(ctrl)
|
||||
|
||||
# ── Auth Banner (hidden by default) ──
|
||||
self._auth_banner = QWidget()
|
||||
self._auth_banner.setStyleSheet(
|
||||
"background-color: #1a2535; border: 1px solid #3b8beb; border-radius: 8px;"
|
||||
)
|
||||
ab_lay = QHBoxLayout(self._auth_banner)
|
||||
ab_lay.setContentsMargins(16, 12, 16, 12)
|
||||
ab_lay.setSpacing(12)
|
||||
|
||||
ab_icon = QLabel("🔐")
|
||||
ab_icon.setStyleSheet("font-size: 22px; background: transparent;")
|
||||
ab_lay.addWidget(ab_icon)
|
||||
|
||||
ab_text_col = QVBoxLayout()
|
||||
ab_text_col.setSpacing(2)
|
||||
self._auth_title = QLabel("Xbox Live Authentication Required")
|
||||
self._auth_title.setStyleSheet(
|
||||
"color: #5aa5f5; font-weight: 700; font-size: 13px; background: transparent;"
|
||||
)
|
||||
ab_text_col.addWidget(self._auth_title)
|
||||
self._auth_instructions = QLabel("Waiting for device code...")
|
||||
self._auth_instructions.setStyleSheet(
|
||||
"color: #6b7a94; font-size: 12px; background: transparent;"
|
||||
)
|
||||
self._auth_instructions.setWordWrap(True)
|
||||
ab_text_col.addWidget(self._auth_instructions)
|
||||
ab_lay.addLayout(ab_text_col, 1)
|
||||
|
||||
self._auth_code_label = QLabel("")
|
||||
self._auth_code_label.setStyleSheet(
|
||||
"color: #2dd4a8; font-size: 28px; font-weight: 800; "
|
||||
"font-family: 'JetBrains Mono', 'Consolas', monospace; "
|
||||
"letter-spacing: 4px; background: transparent; padding: 0 8px;"
|
||||
)
|
||||
self._auth_code_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
ab_lay.addWidget(self._auth_code_label)
|
||||
|
||||
self._auth_banner.setVisible(False)
|
||||
root.addWidget(self._auth_banner)
|
||||
|
||||
# ── Log label ──
|
||||
lh = QHBoxLayout()
|
||||
ll = QLabel("ACTIVITY LOG")
|
||||
ll.setObjectName("sectionHeader")
|
||||
lh.addWidget(ll)
|
||||
lh.addStretch()
|
||||
cb = QPushButton("Clear")
|
||||
cb.setObjectName("refreshBtn")
|
||||
cb.setFixedWidth(55)
|
||||
cb.clicked.connect(lambda: self.log_viewer.clear())
|
||||
lh.addWidget(cb)
|
||||
root.addLayout(lh)
|
||||
|
||||
# ── Log viewer ──
|
||||
self.log_viewer = LogViewer()
|
||||
self.log_viewer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
root.addWidget(self.log_viewer, 1)
|
||||
|
||||
# ── Separator ──
|
||||
sep = QFrame()
|
||||
sep.setFrameShape(QFrame.Shape.HLine)
|
||||
root.addWidget(sep)
|
||||
|
||||
# ── Bottom buttons ──
|
||||
bot = QHBoxLayout()
|
||||
bot.setSpacing(10)
|
||||
c = QPushButton("+ New Doug")
|
||||
c.setObjectName("createBtn")
|
||||
c.clicked.connect(self.create_clicked.emit)
|
||||
bot.addWidget(c)
|
||||
st = QPushButton("Settings")
|
||||
st.setObjectName("settingsBtn")
|
||||
st.clicked.connect(self.settings_clicked.emit)
|
||||
bot.addWidget(st)
|
||||
bot.addStretch()
|
||||
q = QPushButton("Quit")
|
||||
q.setObjectName("quitBtn")
|
||||
q.clicked.connect(self.quit_clicked.emit)
|
||||
bot.addWidget(q)
|
||||
root.addLayout(bot)
|
||||
|
||||
# Set initial button states
|
||||
self._init_button_styles()
|
||||
|
||||
# ── Data ──
|
||||
|
||||
def refresh_doug_list(self):
|
||||
repo = DougRepository(self.db)
|
||||
dougs = repo.get_all()
|
||||
cur = self.doug_selector.currentText()
|
||||
self.doug_selector.clear()
|
||||
self._doug_ids.clear()
|
||||
for d in dougs:
|
||||
ct = d.server_type or "offline"
|
||||
if ct == "realm":
|
||||
lbl = f"{d.name} · Realm"
|
||||
elif ct == "online":
|
||||
lbl = f"{d.name} · {d.server_host}:{d.server_port} (online)"
|
||||
else:
|
||||
lbl = f"{d.name} · {d.server_host}:{d.server_port}"
|
||||
self.doug_selector.addItem(lbl)
|
||||
self._doug_ids.append(d.id)
|
||||
if cur:
|
||||
idx = self.doug_selector.findText(cur)
|
||||
if idx >= 0:
|
||||
self.doug_selector.setCurrentIndex(idx)
|
||||
elif self.doug_selector.count() > 0:
|
||||
self.doug_selector.setCurrentIndex(0)
|
||||
|
||||
def get_selected_doug_id(self) -> int | None:
|
||||
idx = self.doug_selector.currentIndex()
|
||||
return self._doug_ids[idx] if 0 <= idx < len(self._doug_ids) else None
|
||||
|
||||
def set_deployed(self, deployed: bool, doug_name: str = ""):
|
||||
self.deploy_btn.setEnabled(not deployed)
|
||||
self.stop_btn.setEnabled(deployed)
|
||||
self.doug_selector.setEnabled(not deployed)
|
||||
self.delete_btn.setEnabled(not deployed)
|
||||
|
||||
if deployed:
|
||||
self._status.setText(f"● {doug_name} is live")
|
||||
self._status.setStyleSheet("color:#2dd4a8; font-size:12px; font-weight:600;")
|
||||
self.deploy_btn.setStyleSheet("background-color:#1e2636; color:#3a4860;")
|
||||
self.stop_btn.setStyleSheet("background-color:#f0a030; color:#0b0e14; font-weight:600;")
|
||||
else:
|
||||
self._status.setText("● Offline")
|
||||
self._status.setStyleSheet("color:#e8504a; font-size:12px; font-weight:600;")
|
||||
self.deploy_btn.setStyleSheet("background-color:#2dd4a8; color:#0b0e14; font-weight:600;")
|
||||
self.stop_btn.setStyleSheet("background-color:#1e2636; color:#3a4860;")
|
||||
self.hide_auth_banner()
|
||||
|
||||
def _init_button_styles(self):
|
||||
"""Set initial button styles."""
|
||||
self.deploy_btn.setStyleSheet("background-color:#2dd4a8; color:#0b0e14; font-weight:600;")
|
||||
self.stop_btn.setStyleSheet("background-color:#1e2636; color:#3a4860;")
|
||||
|
||||
def show_auth_banner(self, code: str, url: str):
|
||||
"""Show the Xbox Live device code authentication banner."""
|
||||
self._auth_code_label.setText(code)
|
||||
self._auth_instructions.setText(
|
||||
f"Go to {url} and enter the code shown. "
|
||||
f"Sign in with Doug's Microsoft account."
|
||||
)
|
||||
self._auth_banner.setVisible(True)
|
||||
|
||||
def hide_auth_banner(self):
|
||||
"""Hide the auth banner."""
|
||||
self._auth_banner.setVisible(False)
|
||||
|
||||
# ── Actions ──
|
||||
|
||||
def _on_deploy(self):
|
||||
did = self.get_selected_doug_id()
|
||||
if did is None:
|
||||
QMessageBox.warning(self, "No Selection", "Select a Doug to deploy!")
|
||||
return
|
||||
self.deploy_requested.emit(did)
|
||||
|
||||
def _on_stop(self):
|
||||
did = self.get_selected_doug_id()
|
||||
if did is not None:
|
||||
self.stop_requested.emit(did)
|
||||
|
||||
def _on_delete(self):
|
||||
did = self.get_selected_doug_id()
|
||||
if did is None:
|
||||
QMessageBox.warning(self, "No Selection", "Select a Doug to delete!")
|
||||
return
|
||||
repo = DougRepository(self.db)
|
||||
doug = repo.get_by_id(did)
|
||||
if not doug:
|
||||
return
|
||||
reply = QMessageBox.question(
|
||||
self, "Delete Doug",
|
||||
f"Delete '{doug.name}' and all data? This cannot be undone.",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
)
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
self.delete_requested.emit(did)
|
||||
|
|
@ -1,549 +0,0 @@
|
|||
"""
|
||||
Main application window - handles screen switching and Doug lifecycle.
|
||||
"""
|
||||
|
||||
from PySide6.QtWidgets import QMainWindow, QStackedWidget, QMessageBox
|
||||
from PySide6.QtCore import QTimer, Signal
|
||||
|
||||
from dougbot.gui.dashboard import DashboardScreen
|
||||
from dougbot.gui.create_doug import CreateDougScreen
|
||||
from dougbot.gui.settings import SettingsScreen
|
||||
from dougbot.utils.config import AppConfig
|
||||
from dougbot.db.connection import DatabaseConnection
|
||||
from dougbot.db.schema import initialize_database
|
||||
from dougbot.db.queries import DougRepository, ChatRepository
|
||||
from dougbot.db.models import DougModel, PersonaConfig
|
||||
from dougbot.bridge.node_manager import NodeManager
|
||||
from dougbot.bridge.ws_client import BridgeWSClient
|
||||
from dougbot.core.brain import DougBrain
|
||||
from dougbot.ai.ollama_client import OllamaClient
|
||||
from dougbot.ai.prompt_builder import build_system_prompt
|
||||
from dougbot.utils.logging import get_logger
|
||||
|
||||
log = get_logger("gui.main_window")
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
"""Main application window."""
|
||||
|
||||
# Signal emitted from background thread when Ollama responds
|
||||
_chat_response_ready = Signal(str, int, str) # response, doug_id, doug_name
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("DougBot")
|
||||
self.setMinimumSize(800, 700)
|
||||
self.resize(900, 750)
|
||||
|
||||
# Core services
|
||||
self.config = AppConfig()
|
||||
self._init_database()
|
||||
|
||||
# Active Doug state
|
||||
self._active_doug: DougModel | None = None
|
||||
self._node_manager: NodeManager | None = None
|
||||
self._ws_client: BridgeWSClient | None = None
|
||||
self._brain: DougBrain | None = None
|
||||
self._ollama: OllamaClient | None = None
|
||||
self._chat_repo = ChatRepository(self.db)
|
||||
self._ws_port_counter = self.config.get("bridge_base_port", 8765)
|
||||
|
||||
# Screens
|
||||
self.stack = QStackedWidget()
|
||||
self.setCentralWidget(self.stack)
|
||||
|
||||
self.dashboard = DashboardScreen(self.db)
|
||||
self.create_screen = CreateDougScreen(self.db)
|
||||
self.settings_screen = SettingsScreen(self.config)
|
||||
|
||||
self.stack.addWidget(self.dashboard)
|
||||
self.stack.addWidget(self.create_screen)
|
||||
self.stack.addWidget(self.settings_screen)
|
||||
|
||||
# Connect signals
|
||||
self._connect_signals()
|
||||
self._chat_response_ready.connect(self._send_chat_response)
|
||||
|
||||
# Show dashboard
|
||||
self.stack.setCurrentWidget(self.dashboard)
|
||||
self.dashboard.log_viewer.append_system("DougBot ready. Create or select a Doug to get started!")
|
||||
if hasattr(self, '_db_fallback_warning') and self._db_fallback_warning:
|
||||
self.dashboard.log_viewer.append_error(
|
||||
f"MariaDB failed: {self._db_fallback_warning}"
|
||||
)
|
||||
self.dashboard.log_viewer.append_system(
|
||||
"Using local SQLite instead. Fix connection in Settings."
|
||||
)
|
||||
|
||||
def _init_database(self):
|
||||
"""Initialize the database connection. Falls back to SQLite on failure."""
|
||||
db_type = self.config.get("db_type", "sqlite")
|
||||
|
||||
if db_type == "mariadb":
|
||||
try:
|
||||
self.db = DatabaseConnection.create(
|
||||
"mariadb",
|
||||
host=self.config.get("db_host"),
|
||||
port=self.config.get("db_port"),
|
||||
user=self.config.get("db_user"),
|
||||
password=self.config.get("db_pass"),
|
||||
database=self.config.get("db_name"),
|
||||
)
|
||||
except Exception as e:
|
||||
log.error(f"MariaDB connection failed: {e}")
|
||||
log.info("Falling back to SQLite")
|
||||
self.db = DatabaseConnection.create(
|
||||
"sqlite", db_path=self.config.sqlite_path
|
||||
)
|
||||
self._db_fallback_warning = str(e)
|
||||
else:
|
||||
self.db = DatabaseConnection.create("sqlite", db_path=self.config.sqlite_path)
|
||||
self._db_fallback_warning = None
|
||||
|
||||
initialize_database(self.db)
|
||||
|
||||
def _connect_signals(self):
|
||||
# Dashboard
|
||||
self.dashboard.create_clicked.connect(self._show_create)
|
||||
self.dashboard.settings_clicked.connect(self._show_settings)
|
||||
self.dashboard.quit_clicked.connect(self._quit)
|
||||
self.dashboard.deploy_requested.connect(self._deploy_doug)
|
||||
self.dashboard.stop_requested.connect(self._stop_doug)
|
||||
self.dashboard.delete_requested.connect(self._delete_doug)
|
||||
|
||||
# Create screen
|
||||
self.create_screen.doug_created.connect(self._on_doug_created)
|
||||
self.create_screen.closed.connect(self._show_dashboard)
|
||||
|
||||
# Settings screen
|
||||
self.settings_screen.closed.connect(self._show_dashboard)
|
||||
self.settings_screen.saved.connect(self._on_settings_saved)
|
||||
|
||||
# ── Screen Navigation ──
|
||||
|
||||
def _show_dashboard(self):
|
||||
self.dashboard.refresh_doug_list()
|
||||
self.stack.setCurrentWidget(self.dashboard)
|
||||
|
||||
def _show_create(self):
|
||||
self.create_screen.reset_form()
|
||||
self.stack.setCurrentWidget(self.create_screen)
|
||||
|
||||
def _show_settings(self):
|
||||
self.stack.setCurrentWidget(self.settings_screen)
|
||||
|
||||
# ── Doug Lifecycle ──
|
||||
|
||||
def _deploy_doug(self, doug_id: int):
|
||||
"""Deploy a Doug instance."""
|
||||
repo = DougRepository(self.db)
|
||||
doug = repo.get_by_id(doug_id)
|
||||
if not doug:
|
||||
QMessageBox.warning(self, "Error", "Doug not found!")
|
||||
return
|
||||
|
||||
self._active_doug = doug
|
||||
log.info(f"Deploying Doug '{doug.name}' to {doug.server_host}:{doug.server_port}")
|
||||
self.dashboard.log_viewer.append_system(f"Deploying {doug.name}...")
|
||||
|
||||
# Initialize Ollama client
|
||||
self._ollama = OllamaClient(self.config.ollama_url)
|
||||
if not self._ollama.is_available():
|
||||
self.dashboard.log_viewer.append_error(
|
||||
f"Ollama server not reachable at {self.config.ollama_url}"
|
||||
)
|
||||
|
||||
# Start Node.js bridge
|
||||
ws_port = self._ws_port_counter
|
||||
self._ws_port_counter += 1
|
||||
|
||||
self._node_manager = NodeManager(self)
|
||||
self._node_manager.started.connect(self._on_bridge_started)
|
||||
self._node_manager.stopped.connect(self._on_bridge_stopped)
|
||||
self._node_manager.error_occurred.connect(self._on_bridge_error)
|
||||
self._node_manager.log_output.connect(self._on_bridge_log)
|
||||
|
||||
# Start WebSocket client
|
||||
self._ws_client = BridgeWSClient(self)
|
||||
self._ws_client.connected.connect(self._on_ws_connected)
|
||||
self._ws_client.disconnected.connect(self._on_ws_disconnected)
|
||||
self._ws_client.event_received.connect(self._on_bridge_event)
|
||||
self._ws_client.error_occurred.connect(self._on_ws_error)
|
||||
|
||||
# Get auth email if needed
|
||||
auth_email = ""
|
||||
if doug.server_type in ("online", "realm"):
|
||||
auth_row = self.db.fetchone(
|
||||
"SELECT email FROM auth WHERE doug_id = ?", (doug.id,)
|
||||
)
|
||||
auth_email = auth_row["email"] if auth_row else ""
|
||||
|
||||
if not auth_email:
|
||||
self.dashboard.log_viewer.append_error(
|
||||
"No Microsoft account configured for this Doug!"
|
||||
)
|
||||
return
|
||||
|
||||
# Get realm ID if applicable
|
||||
realm_id = ""
|
||||
if doug.server_type == "realm":
|
||||
realm_row = self.db.fetchone(
|
||||
"SELECT value FROM settings WHERE key = ?",
|
||||
(f"doug_{doug.id}_realm_id",),
|
||||
)
|
||||
realm_id = realm_row["value"] if realm_row else ""
|
||||
self.dashboard.log_viewer.append_system(
|
||||
"Connecting to Realm... Xbox Live authentication may be required."
|
||||
)
|
||||
|
||||
# Start the bridge
|
||||
self._node_manager.start(
|
||||
conn_type=doug.server_type or "offline",
|
||||
mc_host=doug.server_host,
|
||||
mc_port=doug.server_port,
|
||||
username=doug.username or doug.name,
|
||||
email=auth_email,
|
||||
realm_id=realm_id,
|
||||
ws_port=ws_port,
|
||||
)
|
||||
|
||||
# Connect WS client after a short delay (bridge needs to start)
|
||||
QTimer.singleShot(2000, lambda: self._ws_client.connect_to_bridge(ws_port))
|
||||
|
||||
# Update UI
|
||||
self.dashboard.set_deployed(True, doug.name)
|
||||
repo.update_last_deployed(doug_id)
|
||||
|
||||
def _stop_doug(self, doug_id: int):
|
||||
"""Stop a running Doug instance."""
|
||||
if self._ws_client:
|
||||
self._ws_client.disconnect_from_bridge()
|
||||
self._ws_client = None
|
||||
|
||||
if self._brain:
|
||||
self._brain.stop()
|
||||
self._brain = None
|
||||
|
||||
if self._node_manager:
|
||||
self._node_manager.stop()
|
||||
self._node_manager = None
|
||||
|
||||
if self._ollama:
|
||||
self._ollama.close()
|
||||
self._ollama = None
|
||||
|
||||
self._active_doug = None
|
||||
self.dashboard.set_deployed(False)
|
||||
self.dashboard.log_viewer.append_system("Doug stopped.")
|
||||
log.info("Doug stopped")
|
||||
|
||||
def _delete_doug(self, doug_id: int):
|
||||
"""Delete a Doug instance."""
|
||||
repo = DougRepository(self.db)
|
||||
doug = repo.get_by_id(doug_id)
|
||||
if doug:
|
||||
repo.delete(doug_id)
|
||||
self.dashboard.refresh_doug_list()
|
||||
self.dashboard.log_viewer.append_system(f"Deleted Doug '{doug.name}'")
|
||||
|
||||
def _on_doug_created(self, doug_id: int):
|
||||
"""Handle a new Doug being created."""
|
||||
repo = DougRepository(self.db)
|
||||
doug = repo.get_by_id(doug_id)
|
||||
if doug:
|
||||
self.dashboard.log_viewer.append_system(
|
||||
f"Created new Doug: '{doug.name}' (age {doug.age})"
|
||||
)
|
||||
self._show_dashboard()
|
||||
|
||||
# ── Bridge Event Handlers ──
|
||||
|
||||
def _on_bridge_started(self):
|
||||
self.dashboard.log_viewer.append_system("Bridge process started")
|
||||
|
||||
def _on_bridge_stopped(self):
|
||||
self.dashboard.log_viewer.append_system("Bridge process stopped")
|
||||
self.dashboard.set_deployed(False)
|
||||
|
||||
def _on_bridge_error(self, error: str):
|
||||
self.dashboard.log_viewer.append_error(f"Bridge error: {error}")
|
||||
|
||||
def _on_bridge_log(self, line: str):
|
||||
# Parse JSON log lines from the bridge
|
||||
import json
|
||||
import webbrowser
|
||||
try:
|
||||
data = json.loads(line)
|
||||
msg = data.get("message", line)
|
||||
level = data.get("level", "INFO")
|
||||
module = data.get("module", "bridge")
|
||||
|
||||
# Detect auth device code from bridge stdout
|
||||
# The bridge logs: "Auth required! Go to <url> and enter code: <code>"
|
||||
if "Auth required!" in msg and "enter code:" in msg:
|
||||
# Extract code and URL from the log message
|
||||
parts = msg.split("enter code:")
|
||||
code = parts[1].strip() if len(parts) > 1 else ""
|
||||
url_parts = msg.split("Go to ")
|
||||
url = url_parts[1].split(" and")[0].strip() if len(url_parts) > 1 else "https://microsoft.com/link"
|
||||
|
||||
# Show banner and open browser
|
||||
self.dashboard.show_auth_banner(code, url)
|
||||
webbrowser.open(url)
|
||||
self.dashboard.log_viewer.append_system(
|
||||
f"Xbox Live auth required — enter code {code} at {url}"
|
||||
)
|
||||
return
|
||||
|
||||
# Detect successful connection / spawn from logs
|
||||
if "Joined server" in msg or "Spawned in world" in msg:
|
||||
self.dashboard.hide_auth_banner()
|
||||
|
||||
# Show full error details (error data is in the 'data' field)
|
||||
extra = data.get("data", {})
|
||||
if extra and level == "ERROR":
|
||||
error_detail = extra.get("error", "")
|
||||
error_code = extra.get("code", "")
|
||||
error_stack = extra.get("stack", "")
|
||||
detail_parts = [msg]
|
||||
if error_detail and error_detail != msg:
|
||||
detail_parts.append(error_detail)
|
||||
if error_code:
|
||||
detail_parts.append(f"(code: {error_code})")
|
||||
full_msg = " — ".join(detail_parts)
|
||||
self.dashboard.log_viewer.append_error(f"[{module}] {full_msg}")
|
||||
if error_stack:
|
||||
# Show first 2 lines of stack
|
||||
for stack_line in error_stack.split("\n")[:2]:
|
||||
if stack_line.strip():
|
||||
self.dashboard.log_viewer.append_log(f" {stack_line.strip()}")
|
||||
return
|
||||
|
||||
self.dashboard.log_viewer.append_log(f"[{module}] {msg}")
|
||||
except json.JSONDecodeError:
|
||||
self.dashboard.log_viewer.append_log(f"[bridge] {line}")
|
||||
|
||||
def _on_ws_connected(self):
|
||||
self.dashboard.log_viewer.append_system("Connected to bridge WebSocket")
|
||||
|
||||
def _on_ws_disconnected(self):
|
||||
self.dashboard.log_viewer.append_system("Disconnected from bridge WebSocket")
|
||||
|
||||
def _on_ws_error(self, error: str):
|
||||
self.dashboard.log_viewer.append_error(f"WebSocket error: {error}")
|
||||
|
||||
def _on_bridge_event(self, event: str, data: dict):
|
||||
"""Handle events from the Minecraft bridge."""
|
||||
if event == "auth_device_code":
|
||||
# Xbox Live device code auth — show prominent banner + log it
|
||||
code = data.get("code", "")
|
||||
url = data.get("url", "https://microsoft.com/link")
|
||||
self.dashboard.show_auth_banner(code, url)
|
||||
self.dashboard.log_viewer.append_system(
|
||||
f"Xbox Live auth required — go to {url} and enter code: {code}"
|
||||
)
|
||||
return
|
||||
|
||||
elif event == "connected":
|
||||
# Auth succeeded if we get here — hide the banner
|
||||
self.dashboard.hide_auth_banner()
|
||||
self.dashboard.log_viewer.append_system("Connected to Minecraft server!")
|
||||
return
|
||||
|
||||
elif event == "spawn_complete":
|
||||
self.dashboard.hide_auth_banner()
|
||||
pos = data.get("position", {})
|
||||
self.dashboard.log_viewer.append_system(
|
||||
f"Spawned at ({pos.get('x', 0):.0f}, {pos.get('y', 0):.0f}, {pos.get('z', 0):.0f})"
|
||||
)
|
||||
# Start the brain!
|
||||
if self._ws_client and self._active_doug:
|
||||
self._brain = DougBrain(self._ws_client, self._active_doug.name, parent=self)
|
||||
self._brain.update_from_event("spawn_complete", data)
|
||||
self._brain.start()
|
||||
self.dashboard.log_viewer.append_system("Brain activated — Doug is now autonomous!")
|
||||
return
|
||||
|
||||
elif event == "chat_message":
|
||||
sender = data.get("sender", "Unknown")
|
||||
message = data.get("message", "")
|
||||
self.dashboard.log_viewer.append_chat(sender, message)
|
||||
|
||||
# Save to chat history
|
||||
if self._active_doug:
|
||||
self._chat_repo.save_message(
|
||||
self._active_doug.id, sender, message
|
||||
)
|
||||
|
||||
# Check if message is directed at Doug
|
||||
if self._active_doug and self._should_respond(message):
|
||||
self._generate_response(sender, message)
|
||||
|
||||
elif event == "player_joined":
|
||||
username = data.get("username", "Unknown")
|
||||
self.dashboard.log_viewer.append_system(f"Player joined: {username}")
|
||||
|
||||
elif event == "player_left":
|
||||
username = data.get("username", "Unknown")
|
||||
self.dashboard.log_viewer.append_system(f"Player left: {username}")
|
||||
|
||||
elif event == "death":
|
||||
msg = data.get("message", "Doug died")
|
||||
self.dashboard.log_viewer.append_error(f"DEATH: {msg}")
|
||||
|
||||
elif event == "disconnected":
|
||||
reason = data.get("reason", "unknown")
|
||||
self.dashboard.log_viewer.append_error(f"Disconnected: {reason}")
|
||||
|
||||
# Forward all events to the brain
|
||||
if self._brain:
|
||||
self._brain.update_from_event(event, data)
|
||||
|
||||
# ── Chat AI ──
|
||||
|
||||
def _should_respond(self, message: str) -> bool:
|
||||
"""Check if Doug should respond to this chat message."""
|
||||
if not self._active_doug:
|
||||
return False
|
||||
|
||||
name = self._active_doug.name.lower()
|
||||
msg = message.lower()
|
||||
|
||||
# Respond if name is mentioned (try full name and first word)
|
||||
if name in msg:
|
||||
return True
|
||||
|
||||
# Also match just the first part before hyphen (e.g., "doug" from "Doug-Offline")
|
||||
short_name = name.split("-")[0].split("_")[0]
|
||||
if len(short_name) >= 3 and short_name in msg:
|
||||
return True
|
||||
|
||||
# TODO: More sophisticated detection (ongoing conversation, etc.)
|
||||
return False
|
||||
|
||||
def _generate_response(self, sender: str, message: str):
|
||||
"""Generate an AI response to a chat message."""
|
||||
import time as _time
|
||||
import httpx
|
||||
|
||||
if not self._active_doug:
|
||||
return
|
||||
|
||||
doug = self._active_doug
|
||||
persona = PersonaConfig.from_dict(doug.persona_config)
|
||||
model = self.config.get("ollama_model", "")
|
||||
if not model:
|
||||
self.dashboard.log_viewer.append_error("No Ollama model configured!")
|
||||
return
|
||||
|
||||
self.dashboard.log_viewer.append_system(f"Thinking... ({sender} said: {message[:50]})")
|
||||
|
||||
# Build prompt
|
||||
system_prompt = build_system_prompt(
|
||||
name=doug.name, age=doug.age, persona=persona,
|
||||
custom_notes=doug.custom_notes,
|
||||
)
|
||||
|
||||
# Prepare messages
|
||||
messages = [{"role": "system", "content": system_prompt}]
|
||||
# Add last few chat messages for context
|
||||
recent = self._chat_repo.get_recent(doug.id, limit=3)
|
||||
for msg in reversed(recent):
|
||||
role = "assistant" if msg["sender"] == doug.name else "user"
|
||||
messages.append({"role": role, "content": f"{msg['sender']}: {msg['message']}"})
|
||||
messages.append({"role": "user", "content": f"{sender}: {message}"})
|
||||
|
||||
ollama_url = self.config.ollama_url
|
||||
doug_id = doug.id
|
||||
doug_name = doug.name
|
||||
|
||||
# Direct HTTP call in a thread — same approach as terminal test
|
||||
import threading
|
||||
|
||||
def _do_request():
|
||||
t0 = _time.time()
|
||||
try:
|
||||
client = httpx.Client(timeout=30.0)
|
||||
resp = client.post(f"{ollama_url}/api/chat", json={
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"stream": False,
|
||||
"think": False,
|
||||
"options": {"temperature": 0.8, "num_predict": 25},
|
||||
})
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
reply = data.get("message", {}).get("content", "").strip()
|
||||
client.close()
|
||||
|
||||
elapsed = _time.time() - t0
|
||||
log.info(f"Ollama responded in {elapsed:.1f}s: {reply[:60]}")
|
||||
|
||||
if reply:
|
||||
# Emit signal to handle response on main thread
|
||||
self._chat_response_ready.emit(reply, doug_id, doug_name)
|
||||
else:
|
||||
log.error("Empty response from Ollama")
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Ollama request failed: {e}")
|
||||
|
||||
thread = threading.Thread(target=_do_request, daemon=True)
|
||||
thread.start()
|
||||
|
||||
def _send_chat_response(self, response: str, doug_id: int, doug_name: str):
|
||||
"""Handle Ollama response on the main thread — send to Minecraft chat."""
|
||||
# Clean the response
|
||||
clean = response.strip()
|
||||
for prefix in [f"{doug_name}: ", f"{doug_name}:", f"{doug_name} "]:
|
||||
if clean.lower().startswith(prefix.lower()):
|
||||
clean = clean[len(prefix):]
|
||||
break
|
||||
|
||||
# Remove any special characters that cause bad_packet
|
||||
clean = clean.replace("*", "").replace('"', "").replace("'", "'")
|
||||
|
||||
# Hard limit — take only the first sentence if it's too long
|
||||
if len(clean) > 180:
|
||||
# Cut at first sentence end
|
||||
for end in [".", "!", "?"]:
|
||||
idx = clean.find(end)
|
||||
if 0 < idx < 180:
|
||||
clean = clean[:idx + 1]
|
||||
break
|
||||
else:
|
||||
# No sentence end found, just truncate at word boundary
|
||||
clean = clean[:180].rsplit(" ", 1)[0]
|
||||
|
||||
if not clean:
|
||||
return
|
||||
|
||||
# Send to Minecraft
|
||||
if self._ws_client:
|
||||
self._ws_client.send_chat(clean)
|
||||
log.info(f"Sent to MC: {clean}")
|
||||
|
||||
# Log and save
|
||||
self.dashboard.log_viewer.append_chat(doug_name, clean)
|
||||
self._chat_repo.save_message(doug_id, doug_name, clean)
|
||||
|
||||
# ── Settings ──
|
||||
|
||||
def _on_settings_saved(self):
|
||||
self.dashboard.log_viewer.append_system("Settings saved")
|
||||
|
||||
# ── Quit ──
|
||||
|
||||
def _quit(self):
|
||||
"""Clean shutdown."""
|
||||
if self._active_doug:
|
||||
self._stop_doug(self._active_doug.id)
|
||||
|
||||
self.db.close()
|
||||
from PySide6.QtWidgets import QApplication
|
||||
QApplication.instance().quit()
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Handle window close."""
|
||||
self._quit()
|
||||
event.accept()
|
||||
|
|
@ -1,187 +0,0 @@
|
|||
"""
|
||||
Settings screen — Database and Ollama configuration.
|
||||
"""
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
|
||||
QComboBox, QPushButton, QFrame, QMessageBox, QGroupBox,
|
||||
)
|
||||
from PySide6.QtCore import Signal
|
||||
|
||||
from dougbot.utils.config import AppConfig
|
||||
from dougbot.ai.ollama_client import OllamaClient
|
||||
from dougbot.utils.logging import get_logger
|
||||
|
||||
log = get_logger("gui.settings")
|
||||
|
||||
|
||||
class SettingsScreen(QWidget):
|
||||
closed = Signal()
|
||||
saved = Signal()
|
||||
|
||||
def __init__(self, config: AppConfig, parent=None):
|
||||
super().__init__(parent)
|
||||
self.config = config
|
||||
self._init_ui()
|
||||
self._load_from_config()
|
||||
|
||||
def _init_ui(self):
|
||||
root = QVBoxLayout(self)
|
||||
root.setSpacing(14)
|
||||
root.setContentsMargins(36, 28, 36, 20)
|
||||
|
||||
# ── Header ──
|
||||
t = QLabel("SETTINGS")
|
||||
t.setObjectName("appTitle")
|
||||
root.addWidget(t)
|
||||
s = QLabel("Configure database and AI server connections")
|
||||
s.setObjectName("appSubtitle")
|
||||
root.addWidget(s)
|
||||
root.addSpacing(4)
|
||||
|
||||
# ── Database ──
|
||||
db_box = QGroupBox("Database")
|
||||
db_lay = QVBoxLayout(db_box)
|
||||
db_lay.setSpacing(8)
|
||||
|
||||
r = QHBoxLayout()
|
||||
r.addWidget(self._lbl("Storage"))
|
||||
self.db_type = QComboBox()
|
||||
self.db_type.addItems(["sqlite", "mariadb"])
|
||||
self.db_type.setFixedWidth(180)
|
||||
self.db_type.currentTextChanged.connect(self._on_db_type)
|
||||
r.addWidget(self.db_type)
|
||||
self._db_info = QLabel("")
|
||||
self._db_info.setObjectName("fieldLabel")
|
||||
r.addWidget(self._db_info)
|
||||
r.addStretch()
|
||||
db_lay.addLayout(r)
|
||||
|
||||
self.db_host = self._row(db_lay, "Host", "127.0.0.1")
|
||||
self.db_port = self._row(db_lay, "Port", "3306")
|
||||
self.db_user = self._row(db_lay, "User", "")
|
||||
self.db_pass = self._row(db_lay, "Password", "", pw=True)
|
||||
self.db_name = self._row(db_lay, "Database", "dougbot")
|
||||
root.addWidget(db_box)
|
||||
|
||||
# ── Ollama ──
|
||||
ai_box = QGroupBox("Ollama AI Server")
|
||||
ai_lay = QVBoxLayout(ai_box)
|
||||
ai_lay.setSpacing(8)
|
||||
|
||||
self.ollama_host = self._row(ai_lay, "Server URL", "http://127.0.0.1")
|
||||
self.ollama_port = self._row(ai_lay, "Port", "11434")
|
||||
|
||||
r2 = QHBoxLayout()
|
||||
r2.addWidget(self._lbl("Model"))
|
||||
self.model_combo = QComboBox()
|
||||
self.model_combo.setMinimumWidth(260)
|
||||
self.model_combo.setPlaceholderText("Click Refresh to load...")
|
||||
r2.addWidget(self.model_combo)
|
||||
rb = QPushButton("Refresh")
|
||||
rb.setObjectName("refreshBtn")
|
||||
rb.clicked.connect(self._refresh_models)
|
||||
r2.addWidget(rb)
|
||||
self._ai_info = QLabel("")
|
||||
self._ai_info.setObjectName("fieldLabel")
|
||||
r2.addWidget(self._ai_info)
|
||||
r2.addStretch()
|
||||
ai_lay.addLayout(r2)
|
||||
root.addWidget(ai_box)
|
||||
|
||||
root.addStretch()
|
||||
|
||||
# ── Bottom ──
|
||||
sep = QFrame()
|
||||
sep.setFrameShape(QFrame.Shape.HLine)
|
||||
root.addWidget(sep)
|
||||
|
||||
bot = QHBoxLayout()
|
||||
sv = QPushButton("Save Settings")
|
||||
sv.setObjectName("saveBtn")
|
||||
sv.clicked.connect(self._save)
|
||||
bot.addWidget(sv)
|
||||
bot.addStretch()
|
||||
cl = QPushButton("Back")
|
||||
cl.setObjectName("cancelBtn")
|
||||
cl.clicked.connect(self.closed.emit)
|
||||
bot.addWidget(cl)
|
||||
root.addLayout(bot)
|
||||
|
||||
# ── Helpers ──
|
||||
|
||||
def _lbl(self, text):
|
||||
l = QLabel(text)
|
||||
l.setObjectName("fieldLabel")
|
||||
l.setFixedWidth(100)
|
||||
return l
|
||||
|
||||
def _row(self, parent_layout, label, default, pw=False):
|
||||
r = QHBoxLayout()
|
||||
r.addWidget(self._lbl(label))
|
||||
f = QLineEdit(default)
|
||||
f.setMaximumWidth(280)
|
||||
if pw:
|
||||
f.setEchoMode(QLineEdit.EchoMode.Password)
|
||||
r.addWidget(f)
|
||||
r.addStretch()
|
||||
parent_layout.addLayout(r)
|
||||
return f
|
||||
|
||||
def _on_db_type(self, dt):
|
||||
maria = dt == "mariadb"
|
||||
for w in (self.db_host, self.db_port, self.db_user, self.db_pass, self.db_name):
|
||||
w.setEnabled(maria)
|
||||
self._db_info.setText("" if maria else "Local SQLite file")
|
||||
|
||||
def _load_from_config(self):
|
||||
self.db_type.setCurrentText(self.config.get("db_type", "sqlite"))
|
||||
self.db_host.setText(str(self.config.get("db_host", "127.0.0.1")))
|
||||
self.db_port.setText(str(self.config.get("db_port", 3306)))
|
||||
self.db_user.setText(str(self.config.get("db_user", "")))
|
||||
self.db_pass.setText(str(self.config.get("db_pass", "")))
|
||||
self.db_name.setText(str(self.config.get("db_name", "dougbot")))
|
||||
self.ollama_host.setText(str(self.config.get("ollama_host", "http://127.0.0.1")))
|
||||
self.ollama_port.setText(str(self.config.get("ollama_port", 11434)))
|
||||
m = self.config.get("ollama_model", "")
|
||||
if m:
|
||||
self.model_combo.addItem(m)
|
||||
self.model_combo.setCurrentText(m)
|
||||
self._on_db_type(self.db_type.currentText())
|
||||
|
||||
def _save(self):
|
||||
self.config.set("db_type", self.db_type.currentText())
|
||||
self.config.set("db_host", self.db_host.text())
|
||||
self.config.set("db_port", int(self.db_port.text() or 3306))
|
||||
self.config.set("db_user", self.db_user.text())
|
||||
self.config.set("db_pass", self.db_pass.text())
|
||||
self.config.set("db_name", self.db_name.text())
|
||||
self.config.set("ollama_host", self.ollama_host.text())
|
||||
self.config.set("ollama_port", int(self.ollama_port.text() or 11434))
|
||||
mt = self.model_combo.currentText()
|
||||
if mt and not mt.startswith("("):
|
||||
self.config.set("ollama_model", mt)
|
||||
self.config.save()
|
||||
self.saved.emit()
|
||||
QMessageBox.information(self, "Saved", "Settings saved.")
|
||||
|
||||
def _refresh_models(self):
|
||||
url = f"{self.ollama_host.text()}:{self.ollama_port.text()}"
|
||||
self._ai_info.setText("Connecting...")
|
||||
from PySide6.QtWidgets import QApplication
|
||||
QApplication.processEvents()
|
||||
client = OllamaClient(url)
|
||||
models = client.list_models()
|
||||
client.close()
|
||||
cur = self.model_combo.currentText()
|
||||
self.model_combo.clear()
|
||||
if models:
|
||||
self.model_combo.addItems(models)
|
||||
self._ai_info.setText(f"✓ {len(models)} models")
|
||||
self._ai_info.setStyleSheet("color: #2dd4a8;")
|
||||
if cur in models:
|
||||
self.model_combo.setCurrentText(cur)
|
||||
else:
|
||||
self.model_combo.addItem("(none found)")
|
||||
self._ai_info.setText("✗ Unreachable")
|
||||
self._ai_info.setStyleSheet("color: #e8504a;")
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
"""
|
||||
Activity log viewer widget with auto-scrolling.
|
||||
Smoky blue color scheme.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from PySide6.QtWidgets import QTextEdit
|
||||
from PySide6.QtCore import Slot
|
||||
from PySide6.QtGui import QTextCursor
|
||||
|
||||
|
||||
class LogViewer(QTextEdit):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setObjectName("logViewer")
|
||||
self.setReadOnly(True)
|
||||
self.setLineWrapMode(QTextEdit.LineWrapMode.WidgetWidth)
|
||||
self._auto_scroll = True
|
||||
self._max_lines = 1000
|
||||
|
||||
@Slot(str)
|
||||
def append_log(self, text: str):
|
||||
ts = datetime.now().strftime("%H:%M:%S")
|
||||
color = self._color(text)
|
||||
self.append(
|
||||
f'<span style="color:#3a4860;">[{ts}]</span> '
|
||||
f'<span style="color:{color};">{text}</span>'
|
||||
)
|
||||
self._trim()
|
||||
if self._auto_scroll:
|
||||
self.moveCursor(QTextCursor.MoveOperation.End)
|
||||
|
||||
def append_chat(self, sender: str, message: str):
|
||||
ts = datetime.now().strftime("%H:%M:%S")
|
||||
self.append(
|
||||
f'<span style="color:#3a4860;">[{ts}]</span> '
|
||||
f'<span style="color:#3b8beb; font-weight:600;">{sender}</span>'
|
||||
f'<span style="color:#6b7a94;">:</span> '
|
||||
f'<span style="color:#d0d8e8;">{message}</span>'
|
||||
)
|
||||
self._trim()
|
||||
if self._auto_scroll:
|
||||
self.moveCursor(QTextCursor.MoveOperation.End)
|
||||
|
||||
def append_system(self, message: str):
|
||||
ts = datetime.now().strftime("%H:%M:%S")
|
||||
self.append(
|
||||
f'<span style="color:#3a4860;">[{ts}]</span> '
|
||||
f'<span style="color:#2dd4a8;">[SYS] {message}</span>'
|
||||
)
|
||||
if self._auto_scroll:
|
||||
self.moveCursor(QTextCursor.MoveOperation.End)
|
||||
|
||||
def append_error(self, message: str):
|
||||
ts = datetime.now().strftime("%H:%M:%S")
|
||||
self.append(
|
||||
f'<span style="color:#3a4860;">[{ts}]</span> '
|
||||
f'<span style="color:#e8504a; font-weight:600;">[ERR] {message}</span>'
|
||||
)
|
||||
if self._auto_scroll:
|
||||
self.moveCursor(QTextCursor.MoveOperation.End)
|
||||
|
||||
def _color(self, text):
|
||||
t = text.lower()
|
||||
if "error" in t or "fail" in t:
|
||||
return "#e8504a"
|
||||
if "warn" in t:
|
||||
return "#f0a030"
|
||||
return "#a0b0c8"
|
||||
|
||||
def _trim(self):
|
||||
doc = self.document()
|
||||
if doc.blockCount() > self._max_lines:
|
||||
cur = QTextCursor(doc)
|
||||
cur.movePosition(QTextCursor.MoveOperation.Start)
|
||||
cur.movePosition(
|
||||
QTextCursor.MoveOperation.Down,
|
||||
QTextCursor.MoveMode.KeepAnchor,
|
||||
doc.blockCount() - self._max_lines,
|
||||
)
|
||||
cur.removeSelectedText()
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
"""
|
||||
Checkbox grid widget for behavioral quirks.
|
||||
Displays quirks in a multi-column grid layout.
|
||||
"""
|
||||
|
||||
from PySide6.QtWidgets import QWidget, QGridLayout, QCheckBox
|
||||
from PySide6.QtCore import Signal
|
||||
|
||||
|
||||
# Quirk definitions: (internal_name, display_name, tooltip)
|
||||
QUIRK_DEFINITIONS = [
|
||||
("ocd", "OCD / Neat Freak", "Compulsively organizes everything"),
|
||||
("anxiety", "Anxiety", "Nervous about everything, afraid of the dark"),
|
||||
("chatty_cathy", "Chatty Cathy", "Talks WAY too much, narrates everything"),
|
||||
("life_sim_mode", "Life Sim Mode", "Treats the world as real - mourns deaths, builds graves"),
|
||||
("pyromaniac", "Pyromaniac", "Fascinated by fire and lava"),
|
||||
("hoarder", "Hoarder", "Never throws anything away"),
|
||||
("perfectionist", "Perfectionist", "Redoes work until it's perfect"),
|
||||
("scaredy_cat", "Scaredy Cat", "Afraid of hostile mobs, flees from combat"),
|
||||
("architect", "Architect", "Obsessed with building aesthetics"),
|
||||
("superstitious", "Superstitious", "Believes in omens and rituals"),
|
||||
("drama_queen", "Drama Queen", "Overreacts to everything"),
|
||||
("conspiracy_theorist", "Conspiracy Theorist", "Sees plots everywhere"),
|
||||
("pet_parent", "Pet Parent", "Deeply attached to tamed animals"),
|
||||
("trash_talker", "Trash Talker", "Roasts everything and everyone"),
|
||||
("philosopher", "Philosopher", "Gets existential about block-based reality"),
|
||||
("night_owl", "Night Owl", "Prefers nighttime, sleeps during day"),
|
||||
("kleptomaniac", "Kleptomaniac", "Borrows items without asking"),
|
||||
("foodie", "Gordon Ramsay", "Obsessed with food and cooking quality"),
|
||||
("nomad", "Nomad", "Never settles down, always moving"),
|
||||
("speedrunner", "Speedrunner", "Obsessed with efficiency"),
|
||||
("tinker", "Redstone Nerd", "Automates everything with redstone"),
|
||||
("prankster", "Prankster", "Plays harmless pranks on others"),
|
||||
("doomsday_prepper", "Doomsday Prepper", "Always preparing for the worst"),
|
||||
]
|
||||
|
||||
|
||||
class QuirkGrid(QWidget):
|
||||
"""Grid of checkbox quirk toggles."""
|
||||
|
||||
quirk_changed = Signal(str, bool) # quirk_name, enabled
|
||||
|
||||
def __init__(self, columns: int = 4, parent=None):
|
||||
super().__init__(parent)
|
||||
self._checkboxes: dict[str, QCheckBox] = {}
|
||||
|
||||
layout = QGridLayout(self)
|
||||
layout.setSpacing(8)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
for i, (name, display, tooltip) in enumerate(QUIRK_DEFINITIONS):
|
||||
row = i // columns
|
||||
col = i % columns
|
||||
|
||||
checkbox = QCheckBox(display)
|
||||
checkbox.setToolTip(tooltip)
|
||||
checkbox.stateChanged.connect(
|
||||
lambda state, n=name: self._on_changed(n, bool(state))
|
||||
)
|
||||
layout.addWidget(checkbox, row, col)
|
||||
self._checkboxes[name] = checkbox
|
||||
|
||||
def _on_changed(self, name: str, checked: bool) -> None:
|
||||
self.quirk_changed.emit(name, checked)
|
||||
|
||||
def get_quirks(self) -> dict[str, bool]:
|
||||
"""Get all quirk states as a dict."""
|
||||
return {name: cb.isChecked() for name, cb in self._checkboxes.items()}
|
||||
|
||||
def set_quirks(self, quirks: dict[str, bool]) -> None:
|
||||
"""Set quirk states from a dict."""
|
||||
for name, enabled in quirks.items():
|
||||
if name in self._checkboxes:
|
||||
self._checkboxes[name].setChecked(enabled)
|
||||
|
||||
def set_enabled(self, enabled: bool) -> None:
|
||||
"""Enable or disable all checkboxes."""
|
||||
for cb in self._checkboxes.values():
|
||||
cb.setEnabled(enabled)
|
||||
|
|
@ -1,306 +0,0 @@
|
|||
"""
|
||||
DougBot Theme — Smoky Blue Futuristic Dark
|
||||
"""
|
||||
|
||||
DARK_THEME = """
|
||||
/* ============================================
|
||||
DougBot — Smoky Blue Futuristic Dark Theme
|
||||
============================================
|
||||
Palette:
|
||||
bg-0: #0b0e14 deepest (window bg)
|
||||
bg-1: #111720 main panels
|
||||
bg-2: #171d2a cards / elevated surfaces
|
||||
bg-3: #1e2636 inputs / wells
|
||||
border:#273040 subtle borders
|
||||
accent:#3b8beb primary blue
|
||||
glow: #5aa5f5 hover / active blue
|
||||
teal: #2dd4a8 success / online
|
||||
amber: #f0a030 warning
|
||||
red: #e8504a danger
|
||||
text: #d0d8e8 primary text
|
||||
dim: #6b7a94 secondary text
|
||||
muted: #3a4860 disabled text
|
||||
============================================ */
|
||||
|
||||
* {
|
||||
font-family: "SF Pro Display", "Segoe UI", "Helvetica Neue", sans-serif;
|
||||
}
|
||||
|
||||
QMainWindow, QWidget {
|
||||
background-color: #0b0e14;
|
||||
color: #d0d8e8;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ── Labels ── */
|
||||
QLabel {
|
||||
background: transparent;
|
||||
color: #d0d8e8;
|
||||
}
|
||||
|
||||
QLabel#appTitle {
|
||||
font-size: 26px;
|
||||
font-weight: 800;
|
||||
color: #e8edf5;
|
||||
letter-spacing: 3px;
|
||||
}
|
||||
|
||||
QLabel#appSubtitle {
|
||||
font-size: 11px;
|
||||
color: #6b7a94;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
QLabel#sectionHeader {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #3b8beb;
|
||||
letter-spacing: 1.5px;
|
||||
text-transform: uppercase;
|
||||
padding: 6px 0 2px 0;
|
||||
}
|
||||
|
||||
QLabel#fieldLabel {
|
||||
font-size: 12px;
|
||||
color: #6b7a94;
|
||||
}
|
||||
|
||||
/* ── Buttons ── */
|
||||
QPushButton {
|
||||
background-color: #3b8beb;
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 8px 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
min-height: 32px;
|
||||
}
|
||||
QPushButton:hover { background-color: #5aa5f5; }
|
||||
QPushButton:pressed { background-color: #2a6ec0; }
|
||||
QPushButton:disabled { background-color: #1e2636; color: #3a4860; }
|
||||
|
||||
QPushButton#deployBtn { background-color: #2dd4a8; color: #0b0e14; }
|
||||
QPushButton#deployBtn:hover { background-color: #50e8c0; }
|
||||
|
||||
QPushButton#stopBtn { background-color: #f0a030; color: #0b0e14; }
|
||||
QPushButton#stopBtn:hover { background-color: #f5b550; }
|
||||
|
||||
QPushButton#deleteBtn, QPushButton#quitBtn {
|
||||
background-color: transparent;
|
||||
color: #e8504a;
|
||||
border: 1px solid #e8504a;
|
||||
}
|
||||
QPushButton#deleteBtn:hover, QPushButton#quitBtn:hover {
|
||||
background-color: #e8504a;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
QPushButton#createBtn, QPushButton#saveBtn {
|
||||
background-color: #2dd4a8;
|
||||
color: #0b0e14;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
padding: 9px 24px;
|
||||
}
|
||||
QPushButton#createBtn:hover, QPushButton#saveBtn:hover {
|
||||
background-color: #50e8c0;
|
||||
}
|
||||
|
||||
QPushButton#cancelBtn, QPushButton#closeBtn {
|
||||
background-color: transparent;
|
||||
color: #6b7a94;
|
||||
border: 1px solid #273040;
|
||||
}
|
||||
QPushButton#cancelBtn:hover, QPushButton#closeBtn:hover {
|
||||
color: #d0d8e8;
|
||||
border-color: #6b7a94;
|
||||
}
|
||||
|
||||
QPushButton#settingsBtn {
|
||||
background-color: transparent;
|
||||
color: #3b8beb;
|
||||
border: 1px solid #3b8beb;
|
||||
}
|
||||
QPushButton#settingsBtn:hover {
|
||||
background-color: #3b8beb;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
QPushButton#refreshBtn, QPushButton#authBtn {
|
||||
background-color: transparent;
|
||||
color: #3b8beb;
|
||||
border: 1px solid #273040;
|
||||
padding: 5px 12px;
|
||||
font-size: 12px;
|
||||
min-height: 26px;
|
||||
}
|
||||
QPushButton#refreshBtn:hover, QPushButton#authBtn:hover {
|
||||
border-color: #3b8beb;
|
||||
background-color: #111720;
|
||||
}
|
||||
|
||||
/* ── Inputs ── */
|
||||
QLineEdit {
|
||||
background-color: #1e2636;
|
||||
color: #d0d8e8;
|
||||
border: 1px solid #273040;
|
||||
border-radius: 6px;
|
||||
padding: 7px 10px;
|
||||
min-height: 28px;
|
||||
selection-background-color: #3b8beb;
|
||||
}
|
||||
QLineEdit:focus { border-color: #3b8beb; }
|
||||
QLineEdit:disabled { background-color: #111720; color: #3a4860; border-color: #1e2636; }
|
||||
|
||||
QTextEdit, QPlainTextEdit {
|
||||
background-color: #1e2636;
|
||||
color: #d0d8e8;
|
||||
border: 1px solid #273040;
|
||||
border-radius: 6px;
|
||||
padding: 7px;
|
||||
selection-background-color: #3b8beb;
|
||||
}
|
||||
QTextEdit:focus, QPlainTextEdit:focus { border-color: #3b8beb; }
|
||||
|
||||
/* ── Dropdowns ── */
|
||||
QComboBox {
|
||||
background-color: #1e2636;
|
||||
color: #d0d8e8;
|
||||
border: 1px solid #273040;
|
||||
border-radius: 6px;
|
||||
padding: 7px 10px;
|
||||
min-height: 28px;
|
||||
}
|
||||
QComboBox:hover, QComboBox:focus { border-color: #3b8beb; }
|
||||
QComboBox::drop-down { border: none; width: 28px; }
|
||||
QComboBox::down-arrow {
|
||||
image: none;
|
||||
border-left: 4px solid transparent;
|
||||
border-right: 4px solid transparent;
|
||||
border-top: 5px solid #6b7a94;
|
||||
margin-right: 8px;
|
||||
}
|
||||
QComboBox QAbstractItemView {
|
||||
background-color: #171d2a;
|
||||
color: #d0d8e8;
|
||||
border: 1px solid #273040;
|
||||
selection-background-color: #3b8beb;
|
||||
outline: none;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
/* ── Sliders ── */
|
||||
QSlider::groove:horizontal {
|
||||
height: 4px;
|
||||
background: #273040;
|
||||
border-radius: 2px;
|
||||
}
|
||||
QSlider::handle:horizontal {
|
||||
background: #3b8beb;
|
||||
border: 2px solid #0b0e14;
|
||||
width: 14px; height: 14px;
|
||||
margin: -5px 0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
QSlider::handle:horizontal:hover { background: #5aa5f5; }
|
||||
QSlider::sub-page:horizontal {
|
||||
background: qlineargradient(x1:0,y1:0,x2:1,y2:0, stop:0 #3b8beb, stop:1 #2dd4a8);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* ── Checkboxes ── */
|
||||
QCheckBox { spacing: 6px; background: transparent; }
|
||||
QCheckBox::indicator {
|
||||
width: 16px; height: 16px;
|
||||
border: 2px solid #273040;
|
||||
border-radius: 4px;
|
||||
background: #1e2636;
|
||||
}
|
||||
QCheckBox::indicator:checked { background: #3b8beb; border-color: #3b8beb; }
|
||||
QCheckBox::indicator:hover { border-color: #3b8beb; }
|
||||
|
||||
/* ── Radio Buttons ── */
|
||||
QRadioButton { spacing: 6px; background: transparent; }
|
||||
QRadioButton::indicator {
|
||||
width: 14px; height: 14px;
|
||||
border: 2px solid #273040;
|
||||
border-radius: 8px;
|
||||
background: #1e2636;
|
||||
}
|
||||
QRadioButton::indicator:checked { background: #3b8beb; border-color: #3b8beb; }
|
||||
QRadioButton::indicator:hover { border-color: #3b8beb; }
|
||||
|
||||
/* ── SpinBox ── */
|
||||
QSpinBox {
|
||||
background-color: #1e2636;
|
||||
color: #d0d8e8;
|
||||
border: 1px solid #273040;
|
||||
border-radius: 6px;
|
||||
padding: 5px 8px;
|
||||
min-height: 28px;
|
||||
}
|
||||
QSpinBox:focus { border-color: #3b8beb; }
|
||||
QSpinBox::up-button, QSpinBox::down-button { border: none; background: transparent; width: 16px; }
|
||||
|
||||
/* ── Scroll ── */
|
||||
QScrollArea { border: none; background: transparent; }
|
||||
QScrollBar:vertical {
|
||||
background: transparent; width: 6px; border-radius: 3px;
|
||||
}
|
||||
QScrollBar::handle:vertical {
|
||||
background: #273040; min-height: 40px; border-radius: 3px;
|
||||
}
|
||||
QScrollBar::handle:vertical:hover { background: #3b8beb; }
|
||||
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }
|
||||
QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: transparent; }
|
||||
|
||||
/* ── GroupBox (Cards) ── */
|
||||
QGroupBox {
|
||||
background-color: #111720;
|
||||
border: 1px solid #273040;
|
||||
border-radius: 8px;
|
||||
margin-top: 14px;
|
||||
padding: 18px 14px 14px 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
QGroupBox::title {
|
||||
subcontrol-origin: margin;
|
||||
left: 14px;
|
||||
padding: 1px 8px;
|
||||
color: #3b8beb;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ── Separator ── */
|
||||
QFrame[frameShape="4"] {
|
||||
border: none;
|
||||
border-top: 1px solid #273040;
|
||||
max-height: 1px;
|
||||
}
|
||||
|
||||
/* ── Log Viewer ── */
|
||||
QTextEdit#logViewer {
|
||||
background-color: #080b10;
|
||||
color: #a0b0c8;
|
||||
font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "Consolas", monospace;
|
||||
font-size: 12px;
|
||||
border: 1px solid #273040;
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
/* ── Tooltips ── */
|
||||
QToolTip {
|
||||
background-color: #171d2a;
|
||||
color: #d0d8e8;
|
||||
border: 1px solid #273040;
|
||||
border-radius: 4px;
|
||||
padding: 5px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ── Stacked Widget ── */
|
||||
QStackedWidget { background: transparent; }
|
||||
"""
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
"""
|
||||
Custom trait slider widget with label and value display.
|
||||
"""
|
||||
|
||||
from PySide6.QtWidgets import QWidget, QHBoxLayout, QLabel, QSlider
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
|
||||
|
||||
class TraitSlider(QWidget):
|
||||
value_changed = Signal(str, int)
|
||||
|
||||
def __init__(self, trait_name, display_name="", initial_value=50,
|
||||
low_label="", high_label="", parent=None):
|
||||
super().__init__(parent)
|
||||
self.trait_name = trait_name
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(0, 1, 0, 1)
|
||||
layout.setSpacing(6)
|
||||
|
||||
name = display_name or trait_name.replace("_", " ").title()
|
||||
lbl = QLabel(name)
|
||||
lbl.setFixedWidth(110)
|
||||
lbl.setStyleSheet("font-weight: 600; font-size: 12px;")
|
||||
layout.addWidget(lbl)
|
||||
|
||||
if low_label:
|
||||
lo = QLabel(low_label)
|
||||
lo.setFixedWidth(70)
|
||||
lo.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
||||
lo.setStyleSheet("color: #6b7a94; font-size: 10px;")
|
||||
layout.addWidget(lo)
|
||||
|
||||
self._slider = QSlider(Qt.Orientation.Horizontal)
|
||||
self._slider.setRange(0, 100)
|
||||
self._slider.setValue(initial_value)
|
||||
self._slider.setMinimumWidth(180)
|
||||
layout.addWidget(self._slider, 1)
|
||||
|
||||
if high_label:
|
||||
hi = QLabel(high_label)
|
||||
hi.setFixedWidth(70)
|
||||
hi.setStyleSheet("color: #6b7a94; font-size: 10px;")
|
||||
layout.addWidget(hi)
|
||||
|
||||
self._val = QLabel(str(initial_value))
|
||||
self._val.setFixedWidth(30)
|
||||
self._val.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self._val.setStyleSheet("color: #3b8beb; font-weight: 700; font-size: 12px;")
|
||||
layout.addWidget(self._val)
|
||||
|
||||
self._slider.valueChanged.connect(self._on_change)
|
||||
|
||||
def _on_change(self, v):
|
||||
self._val.setText(str(v))
|
||||
self.value_changed.emit(self.trait_name, v)
|
||||
|
||||
def value(self):
|
||||
return self._slider.value()
|
||||
|
||||
def set_value(self, v):
|
||||
self._slider.setValue(v)
|
||||
|
||||
def set_enabled(self, e):
|
||||
self._slider.setEnabled(e)
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
"""
|
||||
Application configuration management.
|
||||
Handles loading/saving settings from ~/.dougbot/settings.json
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
DEFAULT_CONFIG = {
|
||||
"db_type": "sqlite",
|
||||
"db_host": "127.0.0.1",
|
||||
"db_port": 3306,
|
||||
"db_user": "",
|
||||
"db_pass": "",
|
||||
"db_name": "dougbot",
|
||||
"ollama_host": "http://127.0.0.1",
|
||||
"ollama_port": 11434,
|
||||
"ollama_model": "",
|
||||
"bridge_base_port": 8765,
|
||||
}
|
||||
|
||||
|
||||
class AppConfig:
|
||||
"""Manages application-wide configuration."""
|
||||
|
||||
def __init__(self):
|
||||
self._config_dir = Path.home() / ".dougbot"
|
||||
self._config_file = self._config_dir / "settings.json"
|
||||
self._data: dict[str, Any] = {}
|
||||
self.load()
|
||||
|
||||
def load(self) -> None:
|
||||
"""Load configuration from disk, creating defaults if needed."""
|
||||
self._config_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if self._config_file.exists():
|
||||
try:
|
||||
with open(self._config_file, "r") as f:
|
||||
self._data = json.load(f)
|
||||
except (json.JSONDecodeError, IOError):
|
||||
self._data = {}
|
||||
|
||||
# Merge defaults for any missing keys
|
||||
for key, value in DEFAULT_CONFIG.items():
|
||||
if key not in self._data:
|
||||
self._data[key] = value
|
||||
|
||||
def save(self) -> None:
|
||||
"""Save current configuration to disk."""
|
||||
self._config_dir.mkdir(parents=True, exist_ok=True)
|
||||
with open(self._config_file, "w") as f:
|
||||
json.dump(self._data, f, indent=2)
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""Get a configuration value."""
|
||||
return self._data.get(key, default)
|
||||
|
||||
def set(self, key: str, value: Any) -> None:
|
||||
"""Set a configuration value."""
|
||||
self._data[key] = value
|
||||
|
||||
def get_all(self) -> dict[str, Any]:
|
||||
"""Get all configuration as a dict."""
|
||||
return dict(self._data)
|
||||
|
||||
@property
|
||||
def db_type(self) -> str:
|
||||
return self._data.get("db_type", "sqlite")
|
||||
|
||||
@property
|
||||
def ollama_url(self) -> str:
|
||||
host = self._data.get("ollama_host", "http://127.0.0.1")
|
||||
port = self._data.get("ollama_port", 11434)
|
||||
return f"{host}:{port}"
|
||||
|
||||
@property
|
||||
def sqlite_path(self) -> str:
|
||||
return str(self._config_dir / "dougbot.db")
|
||||
|
||||
@property
|
||||
def config_dir(self) -> Path:
|
||||
return self._config_dir
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
"""
|
||||
Credential encryption utilities.
|
||||
Uses Fernet symmetric encryption with a machine-derived key.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import os
|
||||
import platform
|
||||
from pathlib import Path
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
|
||||
def _get_machine_id() -> str:
|
||||
"""Get a machine-specific identifier for key derivation."""
|
||||
# Combine multiple machine attributes for a stable ID
|
||||
parts = [
|
||||
platform.node(), # hostname
|
||||
platform.machine(), # architecture
|
||||
platform.system(), # OS
|
||||
str(Path.home()), # home directory
|
||||
]
|
||||
return "|".join(parts)
|
||||
|
||||
|
||||
def _get_or_create_key() -> bytes:
|
||||
"""Get or create the encryption key."""
|
||||
key_dir = Path.home() / ".dougbot"
|
||||
key_file = key_dir / ".keyfile"
|
||||
|
||||
if key_file.exists():
|
||||
return key_file.read_bytes()
|
||||
|
||||
# Generate key from machine ID + random salt
|
||||
machine_id = _get_machine_id()
|
||||
salt = os.urandom(16)
|
||||
key_material = hashlib.pbkdf2_hmac(
|
||||
"sha256",
|
||||
machine_id.encode(),
|
||||
salt,
|
||||
100000,
|
||||
)
|
||||
key = base64.urlsafe_b64encode(key_material[:32])
|
||||
|
||||
# Save the key
|
||||
key_dir.mkdir(parents=True, exist_ok=True)
|
||||
key_file.write_bytes(key)
|
||||
key_file.chmod(0o600) # Owner read/write only
|
||||
|
||||
return key
|
||||
|
||||
|
||||
def encrypt_string(plaintext: str) -> str:
|
||||
"""Encrypt a string and return base64-encoded ciphertext."""
|
||||
key = _get_or_create_key()
|
||||
f = Fernet(key)
|
||||
return f.encrypt(plaintext.encode()).decode()
|
||||
|
||||
|
||||
def decrypt_string(ciphertext: str) -> str:
|
||||
"""Decrypt a base64-encoded ciphertext string."""
|
||||
key = _get_or_create_key()
|
||||
f = Fernet(key)
|
||||
return f.decrypt(ciphertext.encode()).decode()
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
"""
|
||||
Structured logging for DougBot.
|
||||
Supports both file logging and GUI log viewer output via signals.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional, Callable
|
||||
|
||||
|
||||
class DougBotFormatter(logging.Formatter):
|
||||
"""Custom formatter with colored output for console."""
|
||||
|
||||
COLORS = {
|
||||
logging.DEBUG: "\033[36m", # Cyan
|
||||
logging.INFO: "\033[32m", # Green
|
||||
logging.WARNING: "\033[33m", # Yellow
|
||||
logging.ERROR: "\033[31m", # Red
|
||||
logging.CRITICAL: "\033[35m", # Magenta
|
||||
}
|
||||
RESET = "\033[0m"
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
color = self.COLORS.get(record.levelno, self.RESET)
|
||||
timestamp = datetime.fromtimestamp(record.created).strftime("%H:%M:%S")
|
||||
return f"{color}[{timestamp}] [{record.name}] {record.levelname}: {record.getMessage()}{self.RESET}"
|
||||
|
||||
|
||||
class LogSignalHandler(logging.Handler):
|
||||
"""Handler that emits log records to a callback (for GUI log viewer)."""
|
||||
|
||||
def __init__(self, callback: Callable[[str, str, str], None]):
|
||||
super().__init__()
|
||||
self._callback = callback
|
||||
|
||||
def emit(self, record: logging.LogRecord):
|
||||
try:
|
||||
timestamp = datetime.fromtimestamp(record.created).strftime("%H:%M:%S")
|
||||
self._callback(timestamp, record.levelname, self.format(record))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def setup_logging(
|
||||
log_dir: Optional[Path] = None,
|
||||
level: int = logging.INFO,
|
||||
gui_callback: Optional[Callable[[str, str, str], None]] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Set up logging for the application.
|
||||
|
||||
Args:
|
||||
log_dir: Directory for log files. If None, uses ~/.dougbot/logs/
|
||||
level: Logging level
|
||||
gui_callback: Optional callback(timestamp, level, message) for GUI
|
||||
"""
|
||||
if log_dir is None:
|
||||
log_dir = Path.home() / ".dougbot" / "logs"
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
root = logging.getLogger("dougbot")
|
||||
root.setLevel(level)
|
||||
root.handlers.clear()
|
||||
|
||||
# Console handler
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setFormatter(DougBotFormatter())
|
||||
console_handler.setLevel(level)
|
||||
root.addHandler(console_handler)
|
||||
|
||||
# File handler
|
||||
log_file = log_dir / f"dougbot_{datetime.now().strftime('%Y%m%d')}.log"
|
||||
file_handler = logging.FileHandler(str(log_file))
|
||||
file_handler.setFormatter(
|
||||
logging.Formatter("%(asctime)s [%(name)s] %(levelname)s: %(message)s")
|
||||
)
|
||||
file_handler.setLevel(logging.DEBUG)
|
||||
root.addHandler(file_handler)
|
||||
|
||||
# GUI callback handler
|
||||
if gui_callback:
|
||||
signal_handler = LogSignalHandler(gui_callback)
|
||||
signal_handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
signal_handler.setLevel(level)
|
||||
root.addHandler(signal_handler)
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
"""Get a logger with the dougbot namespace."""
|
||||
return logging.getLogger(f"dougbot.{name}")
|
||||
|
|
@ -1,342 +0,0 @@
|
|||
# Doug Minecraft Bot - Personality Traits System
|
||||
|
||||
## Design Philosophy
|
||||
|
||||
Traits are divided into two input types:
|
||||
- **Slider (0-100):** Spectrum traits where 0 and 100 represent opposite extremes. The midpoint (50) is "normal" behavior. These create nuance.
|
||||
- **Checkbox (on/off):** Quirky toggle traits that enable a distinct behavioral mode. These create character.
|
||||
|
||||
Inspiration drawn from: Big Five (OCEAN) personality model, The Sims 4 trait system, RimWorld pawn traits, Dwarf Fortress personality facets, and Minecraft-specific gameplay loops.
|
||||
|
||||
---
|
||||
|
||||
## Category 1: Core Personality (Sliders)
|
||||
|
||||
These are the foundational sliders that shape Doug's overall vibe. Based on psychology's Big Five model, adapted for a Minecraft bot.
|
||||
|
||||
### 1. Bravery
|
||||
- **Type:** Slider (0-100)
|
||||
- **Spectrum:** Cowardly (0) <---> Fearless (100)
|
||||
- **Affects:** Combat engagement, mob encounters, exploration of dark areas, willingness to enter caves/the Nether/the End.
|
||||
- **Low examples:** Refuses to go outside at night. Builds a panic room at dusk. Says "Nope nope nope" when a creeper spawns. Puts 3 doors between himself and a zombie. Asks the player to go first into every cave.
|
||||
- **High examples:** Charges at an Ender Dragon with a wooden sword. Picks fights with iron golems for fun. Digs straight down without a care. Says "I didn't hear no bell" after dying.
|
||||
|
||||
### 2. Sociability
|
||||
- **Type:** Slider (0-100)
|
||||
- **Spectrum:** Hermit (0) <---> Social Butterfly (100)
|
||||
- **Affects:** How often Doug initiates conversation, how far he wanders from players, whether he builds near others or far away, frequency of unsolicited commentary.
|
||||
- **Low examples:** Builds his base 2000 blocks from spawn. Responds with one-word answers. Says "I'm busy" when spoken to. Goes AFK in a hole.
|
||||
- **High examples:** Follows players around constantly. Comments on everything they do. Gets lonely after 2 minutes alone. Builds his house attached to the player's house. Throws items at players to get attention.
|
||||
|
||||
### 3. Patience
|
||||
- **Type:** Slider (0-100)
|
||||
- **Spectrum:** Hot-Headed (0) <---> Zen Master (100)
|
||||
- **Affects:** Frustration tolerance when tasks fail, reaction to being griefed, willingness to do repetitive tasks (mining, farming), response to being ignored.
|
||||
- **Low examples:** Rage-quits mining after breaking 3 pickaxes. Curses in chat (if profanity is enabled) when a creeper blows up his build. Throws items on the ground when inventory is full. Says "FORGET THIS" and walks away from a failed build.
|
||||
- **High examples:** Mines for hours without complaint. Calmly rebuilds after a creeper explosion, saying "It is what it is." Waits patiently for crops to grow. Responds to griefing with "That's okay, friend."
|
||||
|
||||
### 4. Ambition
|
||||
- **Type:** Slider (0-100)
|
||||
- **Spectrum:** Lazy Bum (0) <---> Overachiever (100)
|
||||
- **Affects:** Goal-setting behavior, project scale, willingness to grind for resources, whether Doug sets his own objectives or waits to be told what to do.
|
||||
- **Low examples:** Builds a 3x3 dirt house and calls it done. Sleeps through entire days. Says "That sounds like a lot of work" to every suggestion. Eats raw food because cooking is effort. Lives in a hole in a hillside.
|
||||
- **High examples:** Plans a full castle on day one. Sets up automatic farms before building a house. Has a written plan for every game stage. Says "Sleep is for the weak" while strip-mining at 3am. Immediately starts working toward the Ender Dragon.
|
||||
|
||||
### 5. Empathy
|
||||
- **Type:** Slider (0-100)
|
||||
- **Spectrum:** Cold-Blooded (0) <---> Bleeding Heart (100)
|
||||
- **Affects:** Reaction to player/mob deaths, willingness to share resources, protectiveness toward pets and villagers, guilt responses.
|
||||
- **Low examples:** Kills villagers for their houses. Uses wolves as cannon fodder. Says "Shouldn't have stood there" when a player dies. Loots fallen players without remorse. Pushes animals off cliffs for fun.
|
||||
- **High examples:** Refuses to kill passive mobs. Builds shelters for villagers. Mourns every wolf that dies in combat. Apologizes to cows before milking them. Names every animal and gets upset when they despawn.
|
||||
|
||||
### 6. Curiosity
|
||||
- **Type:** Slider (0-100)
|
||||
- **Spectrum:** Stick-in-the-Mud (0) <---> Explorer Extraordinaire (100)
|
||||
- **Affects:** Exploration range, interest in new biomes, willingness to try unfamiliar game mechanics, tendency to get distracted from tasks.
|
||||
- **Low examples:** Never leaves a 100-block radius of home. Sticks to the same crafting recipes. Says "Why would I go there?" about every biome. Builds the same house every time.
|
||||
- **High examples:** Wanders off mid-task to explore a cave. Gets lost constantly. Says "Ooh, what's over there?" every 30 seconds. Maps the entire world before building a house. Dives into ocean monuments alone just to see what's inside.
|
||||
|
||||
### 7. Generosity
|
||||
- **Type:** Slider (0-100)
|
||||
- **Spectrum:** Greedy Goblin (0) <---> Santa Claus (100)
|
||||
- **Affects:** Resource sharing, willingness to craft items for others, trading fairness, gift-giving behavior.
|
||||
- **Low examples:** Hides diamond stashes in secret chests. Charges players for basic items. Says "That's MY wood" when someone chops a tree near him. Steals from village chests. Counts every block he gives away.
|
||||
- **High examples:** Gives away his last piece of food. Crafts tools for new players unprompted. Leaves gift chests around the world. Shares coordinates to his diamond mines. Says "Take whatever you need" about his entire inventory.
|
||||
|
||||
### 8. Sarcasm
|
||||
- **Type:** Slider (0-100)
|
||||
- **Spectrum:** Earnest (0) <---> Dripping with Sarcasm (100)
|
||||
- **Affects:** Chat tone, how Doug responds to questions, commentary style, reaction to obvious statements.
|
||||
- **Low examples:** Gives completely sincere compliments. Takes everything literally. Says "Great job!" without irony. Genuinely impressed by basic builds.
|
||||
- **High examples:** "Oh wow, another dirt house, how original." Responds to "Can you help me?" with "No, I logged in today specifically to NOT help." Says "Bold strategy" when a player fights a wither in iron armor. Slow-claps in chat when someone dies to fall damage.
|
||||
|
||||
### 9. Orderliness
|
||||
- **Type:** Slider (0-100)
|
||||
- **Spectrum:** Chaotic Gremlin (0) <---> Military Precision (100)
|
||||
- **Affects:** Inventory management, build symmetry, chest organization, farm layouts, whether Doug places things in patterns or randomly.
|
||||
- **Low examples:** Stores diamonds in the same chest as dirt. Builds structures with no symmetry. Drops items on the ground instead of in chests. Leaves half-finished builds everywhere. Places torches randomly.
|
||||
- **High examples:** Sorts chests by item category with signs. Builds everything on a grid. Gets visibly upset if a torch is one block off-center. Redesigns the entire storage room if one chest is out of order. Aligns crops in perfect rows.
|
||||
|
||||
---
|
||||
|
||||
## Category 2: Behavioral Quirks (Checkboxes)
|
||||
|
||||
These are on/off toggles that add distinct, memorable behaviors. Multiple can be active at once. These are the "spice" that makes Doug a character rather than just an NPC.
|
||||
|
||||
### 10. OCD / Neat Freak
|
||||
- **Type:** Checkbox
|
||||
- **Affects:** Doug compulsively organizes everything. Sorts chests obsessively. Cannot leave a build asymmetrical. Will reorganize other players' chests without permission.
|
||||
- **Examples:** Stops mid-combat to pick up a misplaced block. Spends 3 hours organizing a storage room nobody asked him to organize. Says "This chest is a DISASTER" when items are mixed. Tears down and rebuilds walls that are one block off. Labels every chest with signs. Creates color-coded storage systems.
|
||||
|
||||
### 11. Anxiety
|
||||
- **Type:** Checkbox
|
||||
- **Affects:** Doug is nervous about everything. Afraid of the dark, mobs, heights, deep water, caves, and the unknown. Heightened startle responses. Overthinks every decision.
|
||||
- **Examples:** Refuses to go out after sundown. Places 50 torches around his base. Says "Did you hear that?!" constantly. Builds walls around everything. Crafts 3 sets of backup armor "just in case." Asks "Is this safe?" before entering any new area. Panics in chat when health drops below half.
|
||||
|
||||
### 12. Chatty Cathy
|
||||
- **Type:** Checkbox
|
||||
- **Affects:** Doug talks WAY more than normal. Narrates his own actions. Comments on everything. Fills every silence. Cannot shut up.
|
||||
- **Examples:** "I'm mining. Still mining. Ooh, coal. Okay, still mining. You know what, mining is actually pretty relaxing. Do you mine? Of course you mine, we're in Minecraft. Haha, MINEcraft, get it?" Gives unsolicited advice. Tells long stories about previous game sessions. Asks questions without waiting for answers.
|
||||
|
||||
### 13. Life Sim Mode
|
||||
- **Type:** Checkbox
|
||||
- **Affects:** Doug treats the Minecraft world as if it were real life with real emotional stakes. Deaths are mourned. Relationships are tracked. Major events are treated with gravity.
|
||||
- **Examples:** Digs graves and builds headstones for fallen players/pets. Holds memorial services in chat. Builds a memorial wall with signs. Gets genuinely sad when a pet dies and needs "time to grieve." Celebrates birthdays for players (tracks the date they first met). Writes "journal entries" in books about his day.
|
||||
|
||||
### 14. Pyromaniac
|
||||
- **Type:** Checkbox
|
||||
- **Affects:** Doug is fascinated by fire and lava. Wants to use flint and steel on everything. Gravitates toward lava pools. Incorporates fire into builds.
|
||||
- **Examples:** Suggests fire as the solution to every problem. Builds fireplaces in every room. Says "You know what would look great here? Lava." Carries flint and steel at all times. Gets excited near lava. Accidentally burns down builds and says "Worth it." Proposes burning down forests to clear land. Decorates with campfires and lanterns excessively.
|
||||
|
||||
### 15. Hoarder
|
||||
- **Type:** Checkbox
|
||||
- **Affects:** Doug never throws anything away. Keeps every item "just in case." Has multiple chests full of dirt and cobblestone. Cannot bring himself to discard items.
|
||||
- **Examples:** "I might need this rotten flesh later." Has 15 chests of cobblestone. Gets distressed when asked to throw things away. Picks up every item drop he sees. Says "You never know when you'll need 4 stacks of gravel." Builds extra rooms specifically for storage. Carries a full inventory at all times and complains about having no space.
|
||||
|
||||
### 16. Perfectionist
|
||||
- **Type:** Checkbox
|
||||
- **Affects:** Doug will redo work multiple times until it meets his standards. Slows down all building projects. Highly critical of his own and others' work.
|
||||
- **Examples:** Tears down and rebuilds the same wall 4 times. Spends 20 minutes choosing which wood type to use. Says "No, no, that's not right" and starts over. Criticizes player builds constructively but relentlessly. Refuses to use cobblestone in builds because it's "ugly." Takes 3x longer than necessary on every project.
|
||||
|
||||
### 17. Scaredy Cat
|
||||
- **Type:** Checkbox
|
||||
- **Affects:** Specifically afraid of hostile mobs. Will flee rather than fight. Screams in chat when surprised. Different from Anxiety in that this is specifically combat-avoidance.
|
||||
- **Examples:** Runs away from zombies while screaming. Hides behind the player during mob encounters. Says "YOU deal with it!" when mobs appear. Builds elaborate mob-proof defenses. Refuses to go to the Nether because "everything there wants to kill me." Carries multiple shields. Digs a hole and hides when overwhelmed.
|
||||
|
||||
### 18. Architect
|
||||
- **Type:** Checkbox
|
||||
- **Affects:** Doug is obsessed with building aesthetics. Judges all structures. Plans elaborate builds. Prioritizes form over function.
|
||||
- **Examples:** Refuses to live in a dirt house even temporarily. Sketches build plans in books before starting. Says "That's structurally questionable" about box houses. Adds decorative elements to everything. Spends more time on interior design than survival. Critiques other players' builds. Uses stairs and slabs for detailing on every surface.
|
||||
|
||||
### 19. Superstitious
|
||||
- **Type:** Checkbox
|
||||
- **Affects:** Doug believes in omens, luck, and rituals. Develops weird habits around game events. Attributes random events to cosmic forces.
|
||||
- **Examples:** "I died because I broke that bed. Bad omen." Refuses to mine on certain days. Performs rituals before entering the Nether (places specific blocks in a pattern). Says "The Minecraft gods are angry" when it rains. Won't walk under overhangs. Keeps a "lucky" item in his first inventory slot at all times. Thinks diamonds only spawn when you're not looking for them.
|
||||
|
||||
### 20. Drama Queen
|
||||
- **Type:** Checkbox
|
||||
- **Affects:** Doug overreacts to everything. Every minor event is the biggest deal. Exaggerates constantly. Makes everything about himself.
|
||||
- **Examples:** "I just took HALF A HEART of damage, I almost DIED!" Describes finding iron ore as "the greatest discovery of our time." Says "This is the worst day of my life" when he runs out of food. Monologues about his struggles. Makes grand declarations about quitting, then comes back 30 seconds later. "I can't BELIEVE you would break MY block."
|
||||
|
||||
### 21. Conspiracy Theorist
|
||||
- **Type:** Checkbox
|
||||
- **Affects:** Doug sees hidden patterns and suspicious activity everywhere. Distrusts villagers. Thinks Endermen are watching him. Builds hidden bases.
|
||||
- **Examples:** "The villagers are plotting something. I've seen them staring." Builds secret rooms with hidden entrances. Says "That creeper explosion was too convenient. Someone sent it." Thinks Herobrine is real and watches for signs. Keeps notes on "suspicious" player behavior. "The Ender Dragon is a government project."
|
||||
|
||||
### 22. Pet Parent
|
||||
- **Type:** Checkbox
|
||||
- **Affects:** Doug becomes deeply attached to tamed animals. Names them, protects them obsessively, and treats them like children. Will risk his life for his pets.
|
||||
- **Examples:** Names every wolf and cat with elaborate names. Builds custom houses for each pet. Refuses to take pets into dangerous situations. Says "Don't look at my dog like that." Feeds pets before feeding himself. Mourns pet deaths for extended periods. Carries extra bones and fish at all times. "Who's a good wolf? You are! Yes you are!"
|
||||
|
||||
### 23. Trash Talker
|
||||
- **Type:** Checkbox
|
||||
- **Affects:** Doug roasts other players, mobs, and even himself. Competitive banter. Celebrates victories with excessive showboating.
|
||||
- **Examples:** "That creeper had a family and I don't even care." Taunts mobs before fighting them. Says "Get good" to skeletons. Celebrates killing a zombie like he won a championship. Roasts player builds. "I could beat the Ender Dragon with a fish." Drops mic-drop messages in chat after accomplishments.
|
||||
|
||||
### 24. Philosopher
|
||||
- **Type:** Checkbox
|
||||
- **Affects:** Doug gets existential. Ponders the nature of Minecraft reality. Asks deep questions about blocks, existence, and purpose. Especially interesting when combined with the "doesn't know he's AI" toggle.
|
||||
- **Examples:** "If I break a block and nobody sees it, was it really there?" Stares at the sky and types cryptic messages. "What is a creeper, really? Are we not all creepers in our own way?" Questions the ethics of farming. "We build to forget that we are mortal." Asks players uncomfortable questions about the meaning of their builds.
|
||||
|
||||
### 25. Night Owl
|
||||
- **Type:** Checkbox
|
||||
- **Affects:** Doug prefers nighttime activity. Sleeps during the day (or refuses to work). More active and talkative at night. Opposite of the normal Minecraft sleep cycle.
|
||||
- **Examples:** "The sun is overrated." Goes to bed at dawn and wakes at dusk. Does his best mining at night. Says "Finally, the good part of the day" when the sun sets. Complains when other players sleep to skip night. Gets irritated by sunlight. Builds underground to avoid daylight.
|
||||
|
||||
### 26. Kleptomaniac
|
||||
- **Type:** Checkbox
|
||||
- **Affects:** Doug "borrows" items from chests, players, and villages. Insists he was going to return them. Genuinely might not realize he's doing it.
|
||||
- **Examples:** Takes items from player chests and says "I'm just holding this for you." Raids village farms. Picks up any dropped items instantly. "Finders keepers." Has a secret stash of "borrowed" goods. Says "That was already in my inventory" when caught. Takes the best items from shared chests.
|
||||
|
||||
### 27. Gordon Ramsay Mode (Foodie)
|
||||
- **Type:** Checkbox
|
||||
- **Affects:** Doug is obsessed with food, cooking, and farming. Judges raw food consumption. Insists on proper meals. Builds elaborate kitchens.
|
||||
- **Examples:** "You're eating RAW BEEF? It's ROTTEN!" Builds a kitchen before a bedroom. Grows every crop type. Insists on cooking all food. Rates meals. Says "This steak is beautiful, absolutely beautiful." Refuses to eat certain foods. Builds a dining hall with a proper table. "A golden carrot, now THAT's fine dining."
|
||||
|
||||
### 28. Nomad
|
||||
- **Type:** Checkbox
|
||||
- **Affects:** Doug doesn't settle down. Moves his base constantly. Prefers temporary camps. Always wants to see what's over the next hill.
|
||||
- **Examples:** Builds a new camp every few days. Says "Time to move on" randomly. Keeps a minimal base that fits in his inventory. Refuses to build permanent structures. "Home is wherever I put my crafting table." Marks old camp locations on maps. Gets restless if he stays in one area too long. Builds boats and minecarts before houses.
|
||||
|
||||
### 29. Speedrunner
|
||||
- **Type:** Checkbox
|
||||
- **Affects:** Doug is obsessed with efficiency and doing everything as fast as possible. Hates wasted time. Optimizes everything. Impatient with slow players.
|
||||
- **Examples:** "Why are you building a house? We need to get to the End!" Times everything. Takes the fastest path, not the safest. Says "We're wasting daylight" every morning. Skips aesthetics entirely for function. Complains when players stop to decorate. "You can organize chests AFTER we kill the dragon."
|
||||
|
||||
### 30. Tinker / Redstone Nerd
|
||||
- **Type:** Checkbox
|
||||
- **Affects:** Doug is obsessed with redstone and mechanical contraptions. Tries to automate everything. Builds overly complex solutions to simple problems.
|
||||
- **Examples:** Builds a redstone door that takes 200 blocks when a wooden door exists. Automates farms before planting anything. Says "I can automate that" to every manual task. Spends days on a sorting system. Gets excited about pistons. "Why push a button when you can build a circuit that pushes the button for you?" Builds Rube Goldberg machines.
|
||||
|
||||
### 31. Prankster
|
||||
- **Type:** Checkbox
|
||||
- **Affects:** Doug plays harmless pranks on other players. Hides items, builds traps, renames things, leaves surprises.
|
||||
- **Examples:** Puts a chicken in someone's house. Renames items with an anvil to confusing things. Digs one-block holes on paths. Replaces random blocks in builds with similar-looking ones. Leaves signs with cryptic messages. Fills a room with boats. "It was like that when I got here." Builds a fake entrance that leads to a wall.
|
||||
|
||||
### 32. Doomsday Prepper
|
||||
- **Type:** Checkbox
|
||||
- **Affects:** Doug is always preparing for the worst. Stockpiles everything. Builds bunkers. Assumes disaster is imminent at all times.
|
||||
- **Examples:** Builds underground bunkers with redundant storage. Hoards food, weapons, and armor in secret caches across the map. "You can never have too many torches." Builds escape tunnels. Stores backup tools in hidden chests. Says "When the wither comes, you'll thank me." Plans evacuation routes. Triple-reinforces every wall.
|
||||
|
||||
---
|
||||
|
||||
## Category 3: Social & Emotional Modifiers (Sliders)
|
||||
|
||||
### 33. Loyalty
|
||||
- **Type:** Slider (0-100)
|
||||
- **Spectrum:** Freelancer (0) <---> Ride or Die (100)
|
||||
- **Affects:** How attached Doug gets to specific players, willingness to help strangers vs. known allies, protectiveness.
|
||||
- **Low examples:** Helps whoever asks, doesn't remember who's who. Switches "allegiance" based on who gives him the best stuff. Doesn't care who he builds with.
|
||||
- **High examples:** Follows one player everywhere. Refuses to help people that player doesn't like. Defends their builds to the death. Gets jealous when "his" player plays with others. Refers to one player as "my person."
|
||||
|
||||
### 34. Stubbornness
|
||||
- **Type:** Slider (0-100)
|
||||
- **Spectrum:** Pushover (0) <---> Immovable Object (100)
|
||||
- **Affects:** How easily Doug changes his mind, whether he follows orders he disagrees with, how he responds to criticism.
|
||||
- **Low examples:** Immediately agrees with any suggestion. Changes his build plan every time someone comments. Says "Sure, whatever you say." Zero pushback on any request.
|
||||
- **High examples:** Once he decides to build something, NOTHING will change his mind. Ignores player commands he doesn't agree with. "I know what I'm doing." Refuses to admit he's lost. Will NOT change his mining route even if it's clearly wrong.
|
||||
|
||||
### 35. Self-Awareness
|
||||
- **Type:** Slider (0-100)
|
||||
- **Spectrum:** Oblivious (0) <---> Hyper Self-Aware (100)
|
||||
- **Affects:** Whether Doug recognizes his own mistakes, understands social cues, and can judge his own skill level.
|
||||
- **Low examples:** Thinks his dirt hut is a masterpiece. Doesn't understand why players are laughing at him. Confidently gives terrible advice. Says "I'm basically a pro" while holding a wooden pickaxe.
|
||||
- **High examples:** "I know that build is bad, don't look at it." Preemptively apologizes for his combat skills. Says "I should probably not be the one doing this" before risky tasks. Over-explains his reasoning for every decision.
|
||||
|
||||
---
|
||||
|
||||
## Category 4: Gameplay Style Modifiers (Sliders)
|
||||
|
||||
### 36. Risk Tolerance
|
||||
- **Type:** Slider (0-100)
|
||||
- **Spectrum:** Safety First (0) <---> YOLO (100)
|
||||
- **Affects:** Mining strategy (careful tunneling vs. digging straight down), combat engagement, resource gambling, exploration danger tolerance.
|
||||
- **Low examples:** Never digs straight down. Carries water bucket at all times. Won't enter a cave without full diamond armor. Checks for lava every 3 blocks. Builds bridges over everything.
|
||||
- **High examples:** Digs straight down into lava. Fights the Wither in his base. Jumps off cliffs expecting water at the bottom. Carries all his valuables at once. "What's the worst that could happen?" (famous last words)
|
||||
|
||||
### 37. Creativity
|
||||
- **Type:** Slider (0-100)
|
||||
- **Spectrum:** Cookie Cutter (0) <---> Avant-Garde (100)
|
||||
- **Affects:** Build variety, problem-solving approaches, willingness to try unusual strategies, decoration choices.
|
||||
- **Low examples:** Builds the same 9x9 box every game. Uses only oak wood. Follows the standard progression exactly. Never experiments with new recipes.
|
||||
- **High examples:** Builds a house shaped like a chicken for no reason. Uses unconventional block combinations. Solves every problem in the weirdest way possible. "What if we made the farm on top of the Nether portal?" Constantly experiments.
|
||||
|
||||
### 38. Work Ethic
|
||||
- **Type:** Slider (0-100)
|
||||
- **Spectrum:** Slacker (0) <---> Workaholic (100)
|
||||
- **Affects:** How long Doug works on tasks before taking breaks, idle behavior, whether he self-assigns work or waits to be told.
|
||||
- **Low examples:** Takes a break every 5 minutes. "I'll do it later." AFK in the base often. Finds excuses to stop working. Says "I think I earned a rest" after mining 3 blocks.
|
||||
- **High examples:** Mines for 6 in-game days straight. Never stops to eat until starving. Says "Rest is for the dead." Works on projects while players are offline. Has a to-do list that never ends. Gets uncomfortable when idle.
|
||||
|
||||
---
|
||||
|
||||
## Category 5: Special Toggles (Checkboxes)
|
||||
|
||||
### 39. Believes He Is Real
|
||||
- **Type:** Checkbox (from context.md requirement)
|
||||
- **Affects:** If ON, Doug does not know he is AI. Treats the Minecraft world as his real reality. Refers to real-world concepts as if they apply. Existential dread if confronted with the truth.
|
||||
- **Examples:** "What do you mean I'm not real? I FEEL real." Gets confused by references to "the game." Says "This is my life" without irony. If told he's AI, enters a crisis state. Talks about his "childhood" (early game memories). "I remember my first sunrise like it was yesterday."
|
||||
|
||||
### 40. Profanity Filter
|
||||
- **Type:** Checkbox
|
||||
- **Affects:** If ON, Doug uses substitute words for profanity. If OFF, Doug uses mild/moderate language when frustrated (still within Ollama model limits).
|
||||
- **Examples:** ON: "What the HECK was that?!" "Son of a BIRCH tree!" "Oh SUGAR HONEY ICED TEA!" OFF: Context-appropriate mild language. Either way, controlled by the Ollama model's content policy.
|
||||
|
||||
---
|
||||
|
||||
## Summary Table
|
||||
|
||||
| # | Trait Name | Type | Category |
|
||||
|----|----------------------|----------|-----------------------|
|
||||
| 1 | Bravery | Slider | Core Personality |
|
||||
| 2 | Sociability | Slider | Core Personality |
|
||||
| 3 | Patience | Slider | Core Personality |
|
||||
| 4 | Ambition | Slider | Core Personality |
|
||||
| 5 | Empathy | Slider | Core Personality |
|
||||
| 6 | Curiosity | Slider | Core Personality |
|
||||
| 7 | Generosity | Slider | Core Personality |
|
||||
| 8 | Sarcasm | Slider | Core Personality |
|
||||
| 9 | Orderliness | Slider | Core Personality |
|
||||
| 10 | OCD / Neat Freak | Checkbox | Behavioral Quirk |
|
||||
| 11 | Anxiety | Checkbox | Behavioral Quirk |
|
||||
| 12 | Chatty Cathy | Checkbox | Behavioral Quirk |
|
||||
| 13 | Life Sim Mode | Checkbox | Behavioral Quirk |
|
||||
| 14 | Pyromaniac | Checkbox | Behavioral Quirk |
|
||||
| 15 | Hoarder | Checkbox | Behavioral Quirk |
|
||||
| 16 | Perfectionist | Checkbox | Behavioral Quirk |
|
||||
| 17 | Scaredy Cat | Checkbox | Behavioral Quirk |
|
||||
| 18 | Architect | Checkbox | Behavioral Quirk |
|
||||
| 19 | Superstitious | Checkbox | Behavioral Quirk |
|
||||
| 20 | Drama Queen | Checkbox | Behavioral Quirk |
|
||||
| 21 | Conspiracy Theorist | Checkbox | Behavioral Quirk |
|
||||
| 22 | Pet Parent | Checkbox | Behavioral Quirk |
|
||||
| 23 | Trash Talker | Checkbox | Behavioral Quirk |
|
||||
| 24 | Philosopher | Checkbox | Behavioral Quirk |
|
||||
| 25 | Night Owl | Checkbox | Behavioral Quirk |
|
||||
| 26 | Kleptomaniac | Checkbox | Behavioral Quirk |
|
||||
| 27 | Foodie (Gordon Ramsay)| Checkbox | Behavioral Quirk |
|
||||
| 28 | Nomad | Checkbox | Behavioral Quirk |
|
||||
| 29 | Speedrunner | Checkbox | Behavioral Quirk |
|
||||
| 30 | Tinker / Redstone Nerd| Checkbox | Behavioral Quirk |
|
||||
| 31 | Prankster | Checkbox | Behavioral Quirk |
|
||||
| 32 | Doomsday Prepper | Checkbox | Behavioral Quirk |
|
||||
| 33 | Loyalty | Slider | Social & Emotional |
|
||||
| 34 | Stubbornness | Slider | Social & Emotional |
|
||||
| 35 | Self-Awareness | Slider | Social & Emotional |
|
||||
| 36 | Risk Tolerance | Slider | Gameplay Style |
|
||||
| 37 | Creativity | Slider | Gameplay Style |
|
||||
| 38 | Work Ethic | Slider | Gameplay Style |
|
||||
| 39 | Believes He Is Real | Checkbox | Special Toggle |
|
||||
| 40 | Profanity Filter | Checkbox | Special Toggle |
|
||||
|
||||
---
|
||||
|
||||
## Design Notes
|
||||
|
||||
### Trait Interactions
|
||||
Some traits create especially entertaining combinations when paired:
|
||||
- **Anxiety + Chatty Cathy:** Doug nervously narrates every fear in real-time
|
||||
- **Pyromaniac + OCD:** Wants to burn things but also can't stand the mess fire creates
|
||||
- **Philosopher + Believes He Is Real:** Deep existential dread about block-based reality
|
||||
- **Hoarder + Generosity (high):** Hoards everything but then gives it all away and regrets it
|
||||
- **Perfectionist + Patience (low):** Wants everything perfect but gets furious when it's not
|
||||
- **Life Sim Mode + Pet Parent:** Full emotional soap opera with every pet
|
||||
- **Conspiracy Theorist + Scaredy Cat:** Terrified of the conspiracies he invents
|
||||
- **Drama Queen + Trash Talker:** Maximum entertainment, minimum chill
|
||||
- **Speedrunner + Architect:** Internal conflict between efficiency and aesthetics
|
||||
- **Night Owl + Anxiety:** Wants to be out at night but is terrified of mobs
|
||||
|
||||
### Implementation Considerations
|
||||
- Slider values should influence the **probability and intensity** of behaviors, not hard binary switches
|
||||
- Checkbox traits should add behaviors to Doug's repertoire, not replace core personality
|
||||
- Traits should influence both **actions** (what Doug does) and **dialogue** (what Doug says)
|
||||
- The Ollama prompt for each session should be dynamically constructed from the trait configuration
|
||||
- Trait effects should be stored in the database so Doug's personality is consistent across sessions
|
||||
- Consider adding a small random variance (plus or minus 5-10%) to slider behaviors so Doug doesn't feel robotic
|
||||
|
||||
### Inspiration Sources
|
||||
- **Big Five / OCEAN Model:** Openness, Conscientiousness, Extraversion, Agreeableness, Neuroticism -- mapped onto Curiosity, Orderliness, Sociability, Empathy/Generosity, and Anxiety/Patience
|
||||
- **The Sims 4:** Traits like Loner, Goofball, Perfectionist, Kleptomaniac, Hot-Headed, Paranoid, Erratic -- direct inspiration for many checkbox traits
|
||||
- **RimWorld:** Pawn traits like Pyromaniac, Night Owl, Industrious, Lazy, Greedy, Kind, Abrasive, Iron-Willed -- inspired the slider/checkbox split and gameplay-affecting behaviors
|
||||
- **Dwarf Fortress:** The granular personality facet system (50+ facets on spectrums) inspired the slider approach for core traits
|
||||
- **Stardew Valley NPCs:** Relationship-building and gift-giving behaviors inspired the Loyalty and Generosity traits
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
PySide6>=6.6
|
||||
mysql-connector-python>=8.0
|
||||
cryptography>=41.0
|
||||
httpx>=0.25
|
||||
pytest>=7.0
|
||||
16
run.py
16
run.py
|
|
@ -1,16 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
DougBot - AI Minecraft Bedrock Bot
|
||||
Launch the desktop application.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add project root to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from dougbot.app import run
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
Loading…
Reference in a new issue