feat(vida): modo autônomo, o Carinha como bichinho virtual
Novo módulo vida.py: energia, alegria, companhia e diversão caem com o tempo e sobem com presença (PIR), movimento, conversa e carinho. Delas sai o humor e, do humor, os atos: gestos, cochilo quando cansado, dormir à noite sem ninguém por perto, acenar pedindo atenção quando carente com gente perto, tocar reações da biblioteca e inventar figurinhas novas com o Gemini (no máximo 3 por dia, 90 min entre elas). - O celular recebe "humor x" e escolhe os gestos soltos por ele. - Toque na tela do celular conta como carinho/cutucada (/vida/cuidar). - Respeita E-Stop e o modo presença (ninguém em casa = quieto). - Painel: cartão Vida no Ao vivo com humor, barras, cuidados, ritmo e diário; liga por padrão. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -359,6 +359,20 @@ const r = {
|
|||||||
const SEQS = sequencias();
|
const SEQS = sequencias();
|
||||||
const LIVRES = ["feliz", "olhar_esq", "olhar_dir", "olhar_baixo",
|
const LIVRES = ["feliz", "olhar_esq", "olhar_dir", "olhar_baixo",
|
||||||
"surpreso", "coracao", "piscadinha", "bravo", "triste", "alegre"];
|
"surpreso", "coracao", "piscadinha", "bravo", "triste", "alegre"];
|
||||||
|
/* Modo vida (servidor): o humor do bichinho escolhe os gestos soltos entre um ato e outro. */
|
||||||
|
const LIVRES_HUMOR = {
|
||||||
|
feliz: ["feliz", "alegre", "piscadinha", "coracao", "olhar_esq", "olhar_dir"],
|
||||||
|
tranquilo: ["olhar_esq", "olhar_dir", "feliz", "piscadinha", "olhar_baixo"],
|
||||||
|
entediado: ["olhar_esq", "olhar_dir", "olhar_baixo", "pensar"],
|
||||||
|
carente: ["olhar_baixo", "triste", "olhar_esq", "olhar_dir"],
|
||||||
|
triste: ["triste", "olhar_baixo", "olhar_baixo"],
|
||||||
|
cansado: ["olhar_baixo", "piscar2"],
|
||||||
|
};
|
||||||
|
let HUMOR = "normal";
|
||||||
|
function gestoSolto() {
|
||||||
|
const lista = LIVRES_HUMOR[HUMOR] || LIVRES;
|
||||||
|
return lista[Math.floor(Math.random() * lista.length)];
|
||||||
|
}
|
||||||
|
|
||||||
function proximo() {
|
function proximo() {
|
||||||
if (!r.fila.length) { r.kf = null; return; }
|
if (!r.fila.length) { r.kf = null; return; }
|
||||||
@@ -527,6 +541,8 @@ function trata(ordem) {
|
|||||||
if (v.length === 3 && v.every((n) => !isNaN(n))) {
|
if (v.length === 3 && v.every((n) => !isNaN(n))) {
|
||||||
CIANO.splice(0, 3, ...v); r.cor = v;
|
CIANO.splice(0, 3, ...v); r.cor = v;
|
||||||
}
|
}
|
||||||
|
} else if (ordem.indexOf("humor ") === 0) {
|
||||||
|
HUMOR = ordem.slice(6).trim();
|
||||||
} else if (ordem === "dormir" || ordem === "sono") {
|
} else if (ordem === "dormir" || ordem === "sono") {
|
||||||
r.dormindo = true;
|
r.dormindo = true;
|
||||||
} else if (ordem === "acordar") {
|
} else if (ordem === "acordar") {
|
||||||
@@ -627,9 +643,10 @@ if (MODO_PAINEL) {
|
|||||||
if (!inicio) return;
|
if (!inicio) return;
|
||||||
const dx = e.clientX - inicio.x, dy = e.clientY - inicio.y, dur = performance.now() - inicio.t;
|
const dx = e.clientX - inicio.x, dy = e.clientY - inicio.y, dur = performance.now() - inicio.t;
|
||||||
inicio = null;
|
inicio = null;
|
||||||
if (r.dormindo) { trata('acordar'); return; }
|
if (r.dormindo) { trata('acordar'); cuidar('cutucar'); return; }
|
||||||
if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) && dur < 900) { animar('coracao'); return; }
|
if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) && dur < 900) { animar('coracao'); cuidar('carinho'); return; }
|
||||||
if (dur < 350 && Math.hypot(dx, dy) < 15) {
|
if (dur < 350 && Math.hypot(dx, dy) < 15) {
|
||||||
|
cuidar('cutucar');
|
||||||
animar('surpreso');
|
animar('surpreso');
|
||||||
setTimeout(() => animar('feliz'), 700);
|
setTimeout(() => animar('feliz'), 700);
|
||||||
}
|
}
|
||||||
@@ -736,6 +753,15 @@ function relatarSom(evento, n) {
|
|||||||
if (evento !== "adormeceu") registrarEvento("som", evento, n);
|
if (evento !== "adormeceu") registrarEvento("som", evento, n);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Toque na tela vira cuidado no modo vida (carinho/cutucada). Só o celular conta. */
|
||||||
|
function cuidar(acao) {
|
||||||
|
if (new URLSearchParams(location.search).get("aparelho") !== "1") return;
|
||||||
|
try {
|
||||||
|
fetch("/vida/cuidar", { method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ acao }) }).catch(() => {});
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
/* Histórico de detecções no servidor (o app tira a foto do evento). Só o celular registra. */
|
/* Histórico de detecções no servidor (o app tira a foto do evento). Só o celular registra. */
|
||||||
function registrarEvento(tipo, evento, nivel) {
|
function registrarEvento(tipo, evento, nivel) {
|
||||||
if (new URLSearchParams(location.search).get("aparelho") !== "1") return;
|
if (new URLSearchParams(location.search).get("aparelho") !== "1") return;
|
||||||
@@ -1105,8 +1131,10 @@ function laco(t) {
|
|||||||
animar("piscar");
|
animar("piscar");
|
||||||
proxPiscar = t + 1600 + Math.random() * 3500;
|
proxPiscar = t + 1600 + Math.random() * 3500;
|
||||||
} else if (AUTO && t >= proxGesto) {
|
} else if (AUTO && t >= proxGesto) {
|
||||||
animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]);
|
animar(gestoSolto());
|
||||||
proxGesto = t + 7000 + Math.random() * 8000;
|
// cansado e triste mexem menos; feliz, mais
|
||||||
|
const ritmo = { cansado: 1.8, triste: 1.5, feliz: 0.8 }[HUMOR] || 1;
|
||||||
|
proxGesto = t + (7000 + Math.random() * 8000) * ritmo;
|
||||||
}
|
}
|
||||||
} else if (t >= proxGesto) {
|
} else if (t >= proxGesto) {
|
||||||
proxGesto = t + 7000 + Math.random() * 8000;
|
proxGesto = t + 7000 + Math.random() * 8000;
|
||||||
|
|||||||
+32
-4
@@ -359,6 +359,20 @@ const r = {
|
|||||||
const SEQS = sequencias();
|
const SEQS = sequencias();
|
||||||
const LIVRES = ["feliz", "olhar_esq", "olhar_dir", "olhar_baixo",
|
const LIVRES = ["feliz", "olhar_esq", "olhar_dir", "olhar_baixo",
|
||||||
"surpreso", "coracao", "piscadinha", "bravo", "triste", "alegre"];
|
"surpreso", "coracao", "piscadinha", "bravo", "triste", "alegre"];
|
||||||
|
/* Modo vida (servidor): o humor do bichinho escolhe os gestos soltos entre um ato e outro. */
|
||||||
|
const LIVRES_HUMOR = {
|
||||||
|
feliz: ["feliz", "alegre", "piscadinha", "coracao", "olhar_esq", "olhar_dir"],
|
||||||
|
tranquilo: ["olhar_esq", "olhar_dir", "feliz", "piscadinha", "olhar_baixo"],
|
||||||
|
entediado: ["olhar_esq", "olhar_dir", "olhar_baixo", "pensar"],
|
||||||
|
carente: ["olhar_baixo", "triste", "olhar_esq", "olhar_dir"],
|
||||||
|
triste: ["triste", "olhar_baixo", "olhar_baixo"],
|
||||||
|
cansado: ["olhar_baixo", "piscar2"],
|
||||||
|
};
|
||||||
|
let HUMOR = "normal";
|
||||||
|
function gestoSolto() {
|
||||||
|
const lista = LIVRES_HUMOR[HUMOR] || LIVRES;
|
||||||
|
return lista[Math.floor(Math.random() * lista.length)];
|
||||||
|
}
|
||||||
|
|
||||||
function proximo() {
|
function proximo() {
|
||||||
if (!r.fila.length) { r.kf = null; return; }
|
if (!r.fila.length) { r.kf = null; return; }
|
||||||
@@ -527,6 +541,8 @@ function trata(ordem) {
|
|||||||
if (v.length === 3 && v.every((n) => !isNaN(n))) {
|
if (v.length === 3 && v.every((n) => !isNaN(n))) {
|
||||||
CIANO.splice(0, 3, ...v); r.cor = v;
|
CIANO.splice(0, 3, ...v); r.cor = v;
|
||||||
}
|
}
|
||||||
|
} else if (ordem.indexOf("humor ") === 0) {
|
||||||
|
HUMOR = ordem.slice(6).trim();
|
||||||
} else if (ordem === "dormir" || ordem === "sono") {
|
} else if (ordem === "dormir" || ordem === "sono") {
|
||||||
r.dormindo = true;
|
r.dormindo = true;
|
||||||
} else if (ordem === "acordar") {
|
} else if (ordem === "acordar") {
|
||||||
@@ -627,9 +643,10 @@ if (MODO_PAINEL) {
|
|||||||
if (!inicio) return;
|
if (!inicio) return;
|
||||||
const dx = e.clientX - inicio.x, dy = e.clientY - inicio.y, dur = performance.now() - inicio.t;
|
const dx = e.clientX - inicio.x, dy = e.clientY - inicio.y, dur = performance.now() - inicio.t;
|
||||||
inicio = null;
|
inicio = null;
|
||||||
if (r.dormindo) { trata('acordar'); return; }
|
if (r.dormindo) { trata('acordar'); cuidar('cutucar'); return; }
|
||||||
if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) && dur < 900) { animar('coracao'); return; }
|
if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) && dur < 900) { animar('coracao'); cuidar('carinho'); return; }
|
||||||
if (dur < 350 && Math.hypot(dx, dy) < 15) {
|
if (dur < 350 && Math.hypot(dx, dy) < 15) {
|
||||||
|
cuidar('cutucar');
|
||||||
animar('surpreso');
|
animar('surpreso');
|
||||||
setTimeout(() => animar('feliz'), 700);
|
setTimeout(() => animar('feliz'), 700);
|
||||||
}
|
}
|
||||||
@@ -736,6 +753,15 @@ function relatarSom(evento, n) {
|
|||||||
if (evento !== "adormeceu") registrarEvento("som", evento, n);
|
if (evento !== "adormeceu") registrarEvento("som", evento, n);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Toque na tela vira cuidado no modo vida (carinho/cutucada). Só o celular conta. */
|
||||||
|
function cuidar(acao) {
|
||||||
|
if (new URLSearchParams(location.search).get("aparelho") !== "1") return;
|
||||||
|
try {
|
||||||
|
fetch("/vida/cuidar", { method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ acao }) }).catch(() => {});
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
/* Histórico de detecções no servidor (o app tira a foto do evento). Só o celular registra. */
|
/* Histórico de detecções no servidor (o app tira a foto do evento). Só o celular registra. */
|
||||||
function registrarEvento(tipo, evento, nivel) {
|
function registrarEvento(tipo, evento, nivel) {
|
||||||
if (new URLSearchParams(location.search).get("aparelho") !== "1") return;
|
if (new URLSearchParams(location.search).get("aparelho") !== "1") return;
|
||||||
@@ -1105,8 +1131,10 @@ function laco(t) {
|
|||||||
animar("piscar");
|
animar("piscar");
|
||||||
proxPiscar = t + 1600 + Math.random() * 3500;
|
proxPiscar = t + 1600 + Math.random() * 3500;
|
||||||
} else if (AUTO && t >= proxGesto) {
|
} else if (AUTO && t >= proxGesto) {
|
||||||
animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]);
|
animar(gestoSolto());
|
||||||
proxGesto = t + 7000 + Math.random() * 8000;
|
// cansado e triste mexem menos; feliz, mais
|
||||||
|
const ritmo = { cansado: 1.8, triste: 1.5, feliz: 0.8 }[HUMOR] || 1;
|
||||||
|
proxGesto = t + (7000 + Math.random() * 8000) * ritmo;
|
||||||
}
|
}
|
||||||
} else if (t >= proxGesto) {
|
} else if (t >= proxGesto) {
|
||||||
proxGesto = t + 7000 + Math.random() * 8000;
|
proxGesto = t + 7000 + Math.random() * 8000;
|
||||||
|
|||||||
+1
-1
@@ -27,7 +27,7 @@ Set-Location $PSScriptRoot -ErrorAction Stop
|
|||||||
# chaves, vendor/ (modelos já instalados na placa) e o fonte do painel (só web/dist vai).
|
# chaves, vendor/ (modelos já instalados na placa) e o fonte do painel (só web/dist vai).
|
||||||
$arquivos = @(
|
$arquivos = @(
|
||||||
'kiosk-server.py', 'comum.py', 'eventos.py', 'controle_pc.py', 'sinalizacao.py', 'servos.py', 'braco.py',
|
'kiosk-server.py', 'comum.py', 'eventos.py', 'controle_pc.py', 'sinalizacao.py', 'servos.py', 'braco.py',
|
||||||
'cerebro.py', 'conversa.py', 'gemini.py', 'treinamento.py', 'figurinhas.py', 'api_config.py', 'historico.py',
|
'cerebro.py', 'conversa.py', 'gemini.py', 'treinamento.py', 'figurinhas.py', 'api_config.py', 'historico.py', 'vida.py',
|
||||||
'carinha.html', 'camera.html', 'reacoes-render.js', 'rastreador-worker.js', 'rosto-motor.js', 'voz-robo.js', 'version.json',
|
'carinha.html', 'camera.html', 'reacoes-render.js', 'rastreador-worker.js', 'rosto-motor.js', 'voz-robo.js', 'version.json',
|
||||||
'obter-rastreamento.py', 'android/app/assets/qualidade-auto.js'
|
'obter-rastreamento.py', 'android/app/assets/qualidade-auto.js'
|
||||||
)
|
)
|
||||||
|
|||||||
+31
-4
@@ -41,6 +41,7 @@ from conversa import Conversa
|
|||||||
from treinamento import Treinamento
|
from treinamento import Treinamento
|
||||||
from api_config import ConfigAPI
|
from api_config import ConfigAPI
|
||||||
from historico import Historico
|
from historico import Historico
|
||||||
|
from vida import Vida
|
||||||
|
|
||||||
TRAVA = threading.Lock()
|
TRAVA = threading.Lock()
|
||||||
|
|
||||||
@@ -70,7 +71,7 @@ ESTATICOS = {"/carinha.html", "/camera.html", "/reacoes-render.js", "/rastreador
|
|||||||
|
|
||||||
# Rotas que não exigem token: só o necessário para a página do rosto abrir e o app se achar.
|
# Rotas que não exigem token: só o necessário para a página do rosto abrir e o app se achar.
|
||||||
PUBLICAS_GET = {"/app/health", "/faces", "/gestos", "/config", "/ia/modelo", "/app.apk"}
|
PUBLICAS_GET = {"/app/health", "/faces", "/gestos", "/config", "/ia/modelo", "/app.apk"}
|
||||||
PREFIXOS_API = ("/eventos", "/estado", "/fila", "/rtc/", "/conversa/", "/treinamento/", "/historico/",
|
PREFIXOS_API = ("/eventos", "/estado", "/fila", "/rtc/", "/conversa/", "/treinamento/", "/historico/", "/vida/",
|
||||||
"/app/", "/braco/", "/servos/", "/ia/", "/api/", "/gesto/", "/face/", "/diag/")
|
"/app/", "/braco/", "/servos/", "/ia/", "/api/", "/gesto/", "/face/", "/diag/")
|
||||||
|
|
||||||
|
|
||||||
@@ -237,7 +238,7 @@ class Handler(SimpleHTTPRequestHandler):
|
|||||||
|
|
||||||
def modulos(self):
|
def modulos(self):
|
||||||
s = self.server
|
s = self.server
|
||||||
return [getattr(s, nome) for nome in ("api_config", "historico", "treinamento", "conversa", "cerebro",
|
return [getattr(s, nome) for nome in ("api_config", "historico", "vida", "treinamento", "conversa", "cerebro",
|
||||||
"braco", "servos", "controle") if hasattr(s, nome)]
|
"braco", "servos", "controle") if hasattr(s, nome)]
|
||||||
|
|
||||||
def estado(self):
|
def estado(self):
|
||||||
@@ -262,6 +263,8 @@ class Handler(SimpleHTTPRequestHandler):
|
|||||||
dados["api"] = s.api_config.status()
|
dados["api"] = s.api_config.status()
|
||||||
if hasattr(s, "historico"):
|
if hasattr(s, "historico"):
|
||||||
dados["pir"] = dict(s.historico.pir, presenca=dict(s.historico.presenca))
|
dados["pir"] = dict(s.historico.pir, presenca=dict(s.historico.presenca))
|
||||||
|
if hasattr(s, "vida"):
|
||||||
|
dados["vida"] = s.vida.status()
|
||||||
return dados
|
return dados
|
||||||
|
|
||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
@@ -454,7 +457,7 @@ class Servidor(ThreadingHTTPServer):
|
|||||||
|
|
||||||
|
|
||||||
def ligar_eventos(servidor):
|
def ligar_eventos(servidor):
|
||||||
for nome in ("controle", "servos", "cerebro", "conversa", "treinamento", "historico"):
|
for nome in ("controle", "servos", "cerebro", "conversa", "treinamento", "historico", "vida"):
|
||||||
if hasattr(servidor, nome):
|
if hasattr(servidor, nome):
|
||||||
getattr(servidor, nome).publicar = EVENTOS.publicar
|
getattr(servidor, nome).publicar = EVENTOS.publicar
|
||||||
|
|
||||||
@@ -479,10 +482,34 @@ def main():
|
|||||||
servidor.cerebro.chave = servidor.token
|
servidor.cerebro.chave = servidor.token
|
||||||
servidor.conversa = Conversa(Path.cwd() / 'memoria.db', servidor.cerebro)
|
servidor.conversa = Conversa(Path.cwd() / 'memoria.db', servidor.cerebro)
|
||||||
servidor.treinamento = Treinamento(Path.cwd() / 'treinamento.db')
|
servidor.treinamento = Treinamento(Path.cwd() / 'treinamento.db')
|
||||||
servidor.conversa.memoria.on_registrar = servidor.treinamento.registrar
|
|
||||||
servidor.historico = Historico(Path.cwd())
|
servidor.historico = Historico(Path.cwd())
|
||||||
|
servidor.vida = vida = Vida(Path.cwd() / 'vida.json')
|
||||||
|
|
||||||
|
def registrar_conversa(papel, texto):
|
||||||
|
servidor.treinamento.registrar(papel, texto)
|
||||||
|
if papel == 'user':
|
||||||
|
vida.estimulo('conversa') # alguém conversou com ele: companhia
|
||||||
|
servidor.conversa.memoria.on_registrar = registrar_conversa
|
||||||
|
|
||||||
|
def celular_dormindo():
|
||||||
|
resumo = servidor.controle.resumo()
|
||||||
|
return bool(resumo['estado'].get('dormindo')) if resumo['online'] else None
|
||||||
|
|
||||||
|
def alguem_perto():
|
||||||
|
h = servidor.historico
|
||||||
|
recente = max(h.pir.get('ultimo') or 0, h.ultimo.get('movimento', 0))
|
||||||
|
return bool(h.pir.get('ativo')) or time.time() - recente < 120
|
||||||
|
|
||||||
|
vida.enfileirar = enfileirar
|
||||||
|
vida.treinamento = servidor.treinamento
|
||||||
|
vida.celular_dormindo = celular_dormindo
|
||||||
|
vida.celular_online = lambda: servidor.controle.resumo()['online']
|
||||||
|
vida.alguem_perto = alguem_perto
|
||||||
|
# E-Stop parado, ou modo presença com ninguém em casa (dormindo em silêncio): não faz nada
|
||||||
|
vida.bloqueado = lambda: ESTOP["ativo"] or servidor.historico.presenca.get("estado") == "ausente"
|
||||||
|
|
||||||
def presenca(tipo):
|
def presenca(tipo):
|
||||||
|
vida.estimulo(tipo)
|
||||||
# PIR viu alguém: se o rosto estiver dormindo (e talvez com a tela apagada), acorda
|
# PIR viu alguém: se o rosto estiver dormindo (e talvez com a tela apagada), acorda
|
||||||
if tipo == "pir":
|
if tipo == "pir":
|
||||||
with servidor.controle.lock:
|
with servidor.controle.lock:
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
"""Modo vida (bichinho virtual): necessidades, sono, atos, figurinhas com limite e rotas HTTP."""
|
||||||
|
import http.client
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
from vida import Vida # noqa: E402
|
||||||
|
|
||||||
|
spec = importlib.util.spec_from_file_location('kiosk_vida', ROOT / 'kiosk-server.py')
|
||||||
|
kiosk = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(kiosk)
|
||||||
|
|
||||||
|
DIA = time.mktime((2026, 10, 6, 15, 0, 0, 0, 0, -1)) # 15h: longe da noite
|
||||||
|
|
||||||
|
|
||||||
|
class TreinoFalso:
|
||||||
|
def __init__(self):
|
||||||
|
self.pedidos, self.tocadas = [], []
|
||||||
|
|
||||||
|
def gerar_figurinha(self, assunto):
|
||||||
|
self.pedidos.append(assunto)
|
||||||
|
|
||||||
|
def status(self):
|
||||||
|
return dict(reacoes=[dict(id=7, receita=dict(nome='Dança'), feedback=0, ativa=True)])
|
||||||
|
|
||||||
|
def testar(self, ident):
|
||||||
|
self.tocadas.append(ident)
|
||||||
|
|
||||||
|
|
||||||
|
class VidaTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.v = Vida(Path(self.tmp.name) / 'vida.json', iniciar=False, rng=random.Random(1))
|
||||||
|
self.ordens = []
|
||||||
|
self.v.enfileirar = self.ordens.append
|
||||||
|
self.v.ultimo_tick = DIA
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.tmp.cleanup()
|
||||||
|
|
||||||
|
def test_necessidades_caem_e_sono_recupera(self):
|
||||||
|
self.v.necessidades.update(energia=50, social=50, diversao=50)
|
||||||
|
self.v.passar_tempo(DIA + 600) # 10 min acordado
|
||||||
|
self.assertLess(self.v.necessidades['energia'], 50)
|
||||||
|
self.assertLess(self.v.necessidades['diversao'], 50)
|
||||||
|
self.v.dormindo_proprio = True
|
||||||
|
antes = self.v.necessidades['energia']
|
||||||
|
self.v.passar_tempo(DIA + 1200) # 10 min dormindo
|
||||||
|
self.assertGreater(self.v.necessidades['energia'], antes)
|
||||||
|
|
||||||
|
def test_pausa_longa_nao_zera(self):
|
||||||
|
self.v.passar_tempo(DIA + 86400) # placa desligada um dia
|
||||||
|
self.assertGreater(self.v.necessidades['energia'], 60)
|
||||||
|
|
||||||
|
def test_cansado_vai_dormir_e_acorda_descansado(self):
|
||||||
|
self.v.necessidades['energia'] = 10
|
||||||
|
self.assertEqual(self.v.agir(DIA), 'dormir')
|
||||||
|
self.assertIn('dormir', self.ordens)
|
||||||
|
self.assertTrue(self.v.dormindo())
|
||||||
|
self.assertIsNone(self.v.agir(DIA + 60)) # ainda sem energia: continua dormindo
|
||||||
|
self.v.necessidades['energia'] = 100
|
||||||
|
self.assertEqual(self.v.agir(DIA + 120), 'acordar')
|
||||||
|
|
||||||
|
def test_noite_dorme_sem_ninguem(self):
|
||||||
|
noite = time.mktime((2026, 10, 6, 23, 30, 0, 0, 0, -1))
|
||||||
|
self.assertEqual(self.v.agir(noite), 'dormir')
|
||||||
|
self.v.dormindo_proprio = False
|
||||||
|
self.v.alguem_perto = lambda: True
|
||||||
|
self.assertNotEqual(self.v.agir(noite), 'dormir')
|
||||||
|
|
||||||
|
def test_humor_e_gestos_de_carente_com_gente_perto(self):
|
||||||
|
self.v.necessidades.update(energia=80, social=10)
|
||||||
|
self.assertEqual(self.v.humor(), 'carente')
|
||||||
|
self.v.alguem_perto = lambda: True
|
||||||
|
gesto = self.v.agir(DIA)
|
||||||
|
self.assertIn(gesto, ('acenar', 'espiar', 'coracao', 'surpreso'))
|
||||||
|
self.assertEqual(self.v.diario[0]['tipo'], 'acao')
|
||||||
|
|
||||||
|
def test_cuidados_sobem_necessidades_e_mandam_gesto(self):
|
||||||
|
self.v.necessidades['social'] = 20
|
||||||
|
st = self.v.estimulo('carinho', DIA)
|
||||||
|
self.assertEqual(st['necessidades']['social'], 35)
|
||||||
|
self.assertEqual(self.ordens[-1], 'coracao')
|
||||||
|
self.v.estimulo('ninar', DIA)
|
||||||
|
self.assertTrue(self.v.dormindo())
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.v.estimulo('voar', DIA)
|
||||||
|
|
||||||
|
def test_figurinha_com_limite(self):
|
||||||
|
treino = TreinoFalso()
|
||||||
|
self.v.treinamento = treino
|
||||||
|
self.v.pode_gemini = lambda: True
|
||||||
|
self.assertEqual(self.v.motivo_sem_emoji(DIA), '')
|
||||||
|
self.assertTrue(self.v.criar_figurinha(DIA, 'feliz'))
|
||||||
|
self.assertIn('à tarde', treino.pedidos[0])
|
||||||
|
self.assertEqual(self.v.motivo_sem_emoji(DIA + 60), 'descansando a criatividade')
|
||||||
|
self.v.config['max_emojis_dia'] = 1
|
||||||
|
self.assertEqual(self.v.motivo_sem_emoji(DIA + 4 * 3600), 'limite do dia')
|
||||||
|
self.v.pode_gemini = lambda: False
|
||||||
|
self.assertEqual(self.v.motivo_sem_emoji(DIA + 4 * 3600), 'sem chave Gemini')
|
||||||
|
|
||||||
|
def test_tick_manda_humor_e_respeita_bloqueio(self):
|
||||||
|
self.v.proximo_ato = DIA + 999
|
||||||
|
self.v.tick(DIA + 5)
|
||||||
|
self.assertTrue(any(o.startswith('humor ') for o in self.ordens))
|
||||||
|
self.ordens.clear()
|
||||||
|
self.v.bloqueado = lambda: True
|
||||||
|
self.v.proximo_ato = DIA
|
||||||
|
self.assertIsNone(self.v.tick(DIA + 70))
|
||||||
|
self.assertEqual(self.ordens, [])
|
||||||
|
|
||||||
|
def test_desligado_nao_age_e_config_persiste(self):
|
||||||
|
self.v.configurar({'ligado': False, 'ritmo': 'calmo'})
|
||||||
|
self.assertIn('humor normal', self.ordens)
|
||||||
|
self.v.proximo_ato = DIA
|
||||||
|
self.assertIsNone(self.v.tick(DIA + 10))
|
||||||
|
outra = Vida(Path(self.tmp.name) / 'vida.json', iniciar=False)
|
||||||
|
self.assertFalse(outra.config['ligado'])
|
||||||
|
self.assertEqual(outra.config['ritmo'], 'calmo')
|
||||||
|
for ruim in ({'ritmo': 'turbo'}, {'max_emojis_dia': 50}, {'ligado': 1}, {'x': True}):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.v.configurar(ruim)
|
||||||
|
|
||||||
|
|
||||||
|
class VidaHttpTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.server = kiosk.Servidor(('127.0.0.1', 0), kiosk.Handler)
|
||||||
|
self.server.token = 'token-vida'
|
||||||
|
self.server.vida = Vida(Path(self.tmp.name) / 'vida.json', iniciar=False)
|
||||||
|
threading.Thread(target=self.server.serve_forever, daemon=True).start()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.server.shutdown(); self.server.server_close(); self.tmp.cleanup()
|
||||||
|
|
||||||
|
def pedir(self, metodo, rota, corpo=None, token='token-vida'):
|
||||||
|
c = http.client.HTTPConnection('127.0.0.1', self.server.server_port, timeout=5)
|
||||||
|
h = {'Content-Type': 'application/json'}
|
||||||
|
if token:
|
||||||
|
h['Authorization'] = 'Bearer ' + token
|
||||||
|
c.request(metodo, rota, body=json.dumps(corpo) if corpo is not None else None, headers=h)
|
||||||
|
r = c.getresponse(); dados = r.read(); c.close()
|
||||||
|
return r.status, json.loads(dados)
|
||||||
|
|
||||||
|
def test_rotas(self):
|
||||||
|
self.assertEqual(self.pedir('GET', '/vida/status', token=None)[0], 401)
|
||||||
|
self.assertEqual(self.pedir('POST', '/vida/cuidar', {'acao': 'carinho'}, token=None)[0], 401)
|
||||||
|
codigo, st = self.pedir('GET', '/vida/status')
|
||||||
|
self.assertEqual(codigo, 200)
|
||||||
|
self.assertIn(st['humor'], ('feliz', 'tranquilo', 'carente', 'entediado', 'triste', 'cansado', 'dormindo'))
|
||||||
|
codigo, st = self.pedir('POST', '/vida/cuidar', {'acao': 'brincar'})
|
||||||
|
self.assertEqual((codigo, st['diario'][0]['texto']), (200, 'Brincou'))
|
||||||
|
self.assertEqual(self.pedir('POST', '/vida/cuidar', {'acao': 'voar'})[0], 400)
|
||||||
|
self.assertFalse(self.pedir('POST', '/vida/config', {'ligado': False})[1]['config']['ligado'])
|
||||||
|
self.assertIn('vida', self.pedir('GET', '/estado')[1])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,421 @@
|
|||||||
|
"""Modo vida: o Carinha como um bichinho virtual (estilo Tamagotchi).
|
||||||
|
|
||||||
|
Quatro necessidades (0 a 100, 100 = satisfeito) caem com o tempo e sobem com o que acontece em volta
|
||||||
|
(presença, conversa, carinho). Delas sai o humor, e do humor saem os atos: gestos, cochilos, chamar
|
||||||
|
atenção quando tem gente perto, tocar uma reação da biblioteca e, de vez em quando, desenhar uma
|
||||||
|
figurinha nova com o Gemini. O celular recebe o humor ("humor feliz") e escolhe os gestos soltos dele
|
||||||
|
a partir disso, então o rosto parece ter vida mesmo entre um ato e outro.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from comum import gravar_atomico, ler_json
|
||||||
|
|
||||||
|
NECESSIDADES = ('energia', 'alegria', 'social', 'diversao')
|
||||||
|
|
||||||
|
# Queda por minuto acordado; dormindo, as outras caem bem mais devagar e a energia volta.
|
||||||
|
QUEDA = dict(energia=0.5, alegria=0.0, social=0.8, diversao=1.0)
|
||||||
|
RECUPERA_SONO = 2.5 # energia por minuto dormindo (25 -> 100 em ~30 min)
|
||||||
|
SONO_LENTO = 0.25
|
||||||
|
|
||||||
|
RITMOS = {'calmo': (60, 150), 'normal': (25, 75), 'agitado': (12, 35)} # segundos entre atos
|
||||||
|
|
||||||
|
PADRAO = dict(ligado=True, criar_emojis=True, max_emojis_dia=3, ritmo='normal', noite=True)
|
||||||
|
NOITE = (23, 7) # dorme das 23h às 7h, se ninguém estiver por perto
|
||||||
|
INTERVALO_EMOJI = 90 * 60 # no mínimo 90 min entre duas figurinhas criadas sozinho
|
||||||
|
|
||||||
|
# O que cada estímulo faz com as necessidades (e o gesto que sai na hora, se houver).
|
||||||
|
ESTIMULOS = {
|
||||||
|
'pir': (dict(social=4, diversao=2), None),
|
||||||
|
'movimento': (dict(diversao=3, social=1), None),
|
||||||
|
'som': (dict(diversao=2), None),
|
||||||
|
'conversa': (dict(social=12, alegria=6, diversao=4), None),
|
||||||
|
'carinho': (dict(social=15, alegria=10), 'coracao'),
|
||||||
|
'cutucar': (dict(social=5, alegria=2, diversao=4), None),
|
||||||
|
'brincar': (dict(diversao=25, alegria=8, energia=-5), 'alegre'),
|
||||||
|
'alimentar': (dict(energia=30, alegria=4), 'feliz'),
|
||||||
|
}
|
||||||
|
CUIDADOS = {'carinho': 'Recebeu carinho', 'cutucar': 'Levou uma cutucada', 'brincar': 'Brincou',
|
||||||
|
'alimentar': 'Ganhou energia', 'ninar': 'Foi posto para dormir', 'acordar': 'Foi acordado'}
|
||||||
|
|
||||||
|
HUMORES = {
|
||||||
|
'dormindo': ('😴', 'Dormindo'), 'cansado': ('🥱', 'Cansado'), 'carente': ('🥺', 'Carente'),
|
||||||
|
'entediado': ('😐', 'Entediado'), 'triste': ('😢', 'Triste'), 'feliz': ('😄', 'Feliz'),
|
||||||
|
'tranquilo': ('🙂', 'Tranquilo'),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Gestos de cada humor: (gesto, peso, o que vai no diário)
|
||||||
|
ATOS = {
|
||||||
|
'feliz': [('alegre', 3, 'Saltitou de alegria'), ('musica', 2, 'Cantarolou'), ('feliz', 3, 'Sorriu à toa'),
|
||||||
|
('piscadinha', 2, 'Deu uma piscadinha'), ('coracao', 1, 'Ficou apaixonado pelo dia')],
|
||||||
|
'tranquilo': [('olhar_esq', 2, 'Olhou em volta'), ('olhar_dir', 2, 'Olhou em volta'), ('piscar2', 2, 'Piscou devagar'),
|
||||||
|
('feliz', 1, 'Sorriu'), ('pensar', 1, 'Ficou pensativo')],
|
||||||
|
'entediado': [('pensar', 3, 'Ficou pensando na vida'), ('olhar_esq', 2, 'Procurou algo para fazer'),
|
||||||
|
('olhar_dir', 2, 'Procurou algo para fazer'), ('espiar', 2, 'Espiou por trás das mãos'),
|
||||||
|
('musica', 1, 'Cantou para passar o tempo')],
|
||||||
|
'carente': [('triste', 2, 'Sentiu falta de alguém'), ('olhar_baixo', 2, 'Ficou cabisbaixo'),
|
||||||
|
('esconder', 1, 'Escondeu o rosto')],
|
||||||
|
'carente_perto': [('acenar', 3, 'Acenou chamando atenção'), ('espiar', 2, 'Espiou quem está aí'),
|
||||||
|
('coracao', 2, 'Pediu carinho'), ('surpreso', 1, 'Fez cara de "oi!"')],
|
||||||
|
'triste': [('triste', 3, 'Ficou tristinho'), ('olhar_baixo', 2, 'Olhou para baixo'), ('pensar', 1, 'Ficou pensativo')],
|
||||||
|
'cansado': [('piscar2', 3, 'Bocejou'), ('olhar_baixo', 2, 'Cochilou de olho aberto')],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Assuntos das figurinhas que ele inventa sozinho, por humor e por hora do dia.
|
||||||
|
TEMAS = {
|
||||||
|
'feliz': ['um sol sorridente', 'um balão colorido', 'um arco-íris', 'um sorvete de casquinha', 'confete de festa'],
|
||||||
|
'tranquilo': ['uma folha de árvore', 'uma flor', 'um gatinho dormindo', 'uma xícara de chá', 'um vaso com planta'],
|
||||||
|
'entediado': ['um foguete', 'um planeta com anel', 'uma pipa', 'um dado', 'um controle de videogame', 'um pião'],
|
||||||
|
'carente': ['um coração com curativo', 'um ursinho de pelúcia', 'uma carta de amor', 'dois corações juntos'],
|
||||||
|
'triste': ['uma nuvem de chuva', 'um guarda-chuva', 'uma gota d\'água'],
|
||||||
|
'cansado': ['uma lua com touca de dormir', 'uma xícara de café', 'um travesseiro', 'uma bateria fraca'],
|
||||||
|
}
|
||||||
|
PERIODOS = [(5, 12, 'de manhã', ['um sol nascendo', 'um pãozinho', 'uma xícara de café']),
|
||||||
|
(12, 18, 'à tarde', ['um picolé', 'um avião de papel', 'uma bola']),
|
||||||
|
(18, 24, 'à noite', ['uma lua', 'estrelas', 'uma coruja']),
|
||||||
|
(0, 5, 'de madrugada', ['uma lua', 'uma vela', 'uma coruja'])]
|
||||||
|
|
||||||
|
MAX_DIARIO = 60
|
||||||
|
|
||||||
|
|
||||||
|
def _limitar(v):
|
||||||
|
return max(0.0, min(100.0, v))
|
||||||
|
|
||||||
|
|
||||||
|
class Vida:
|
||||||
|
def __init__(self, caminho, iniciar=True, rng=None):
|
||||||
|
self.caminho = Path(caminho)
|
||||||
|
self.lock = threading.RLock()
|
||||||
|
self.rng = rng or random.Random()
|
||||||
|
self.publicar = lambda tipo, dados: None
|
||||||
|
self.enfileirar = lambda ordem: None
|
||||||
|
# Ligações com o resto do servidor (o main preenche; nos testes ficam neutras).
|
||||||
|
self.treinamento = None
|
||||||
|
self.celular_dormindo = lambda: None # True/False com o celular online, None sem ele
|
||||||
|
self.celular_online = lambda: False
|
||||||
|
self.alguem_perto = lambda: False # PIR/movimento recente
|
||||||
|
self.bloqueado = lambda: False # E-Stop ou modo presença com ninguém em casa
|
||||||
|
self.pode_gemini = lambda: bool(os.environ.get('GEMINI_API_KEY'))
|
||||||
|
|
||||||
|
agora = time.time()
|
||||||
|
self.config = dict(PADRAO)
|
||||||
|
self.necessidades = dict(energia=80.0, alegria=70.0, social=60.0, diversao=60.0)
|
||||||
|
self.nascimento = agora
|
||||||
|
self.diario = []
|
||||||
|
self.emojis = dict(dia='', total=0, ultimo=0.0)
|
||||||
|
self.dormindo_proprio = False
|
||||||
|
try:
|
||||||
|
salvo = json.loads(self.caminho.read_text(encoding='utf-8'))
|
||||||
|
self.config.update(self.validar(salvo.get('config', {})))
|
||||||
|
for k in NECESSIDADES:
|
||||||
|
if isinstance(salvo.get('necessidades', {}).get(k), (int, float)):
|
||||||
|
self.necessidades[k] = _limitar(float(salvo['necessidades'][k]))
|
||||||
|
self.nascimento = float(salvo.get('nascimento', agora))
|
||||||
|
self.diario = [d for d in salvo.get('diario', []) if isinstance(d, dict)][:MAX_DIARIO]
|
||||||
|
if isinstance(salvo.get('emojis'), dict):
|
||||||
|
self.emojis.update({k: salvo['emojis'][k] for k in ('dia', 'total', 'ultimo') if k in salvo['emojis']})
|
||||||
|
except (OSError, ValueError, TypeError, AttributeError):
|
||||||
|
pass
|
||||||
|
self.ultimo_tick = agora
|
||||||
|
self.proximo_ato = agora + 10
|
||||||
|
self.humor_enviado = (None, 0.0)
|
||||||
|
self.publicado_em = 0.0
|
||||||
|
self.salvo_em = agora
|
||||||
|
if iniciar:
|
||||||
|
threading.Thread(target=self.loop, daemon=True).start()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ config
|
||||||
|
@staticmethod
|
||||||
|
def validar(dados):
|
||||||
|
if not isinstance(dados, dict):
|
||||||
|
raise ValueError('Esperava um objeto')
|
||||||
|
limpo = {}
|
||||||
|
for chave, valor in dados.items():
|
||||||
|
if chave not in PADRAO:
|
||||||
|
raise ValueError('Ajuste desconhecido: ' + chave)
|
||||||
|
if chave == 'ritmo':
|
||||||
|
if valor not in RITMOS:
|
||||||
|
raise ValueError('Ritmo: calmo, normal ou agitado')
|
||||||
|
elif chave == 'max_emojis_dia':
|
||||||
|
if isinstance(valor, bool) or not isinstance(valor, int) or not 0 <= valor <= 10:
|
||||||
|
raise ValueError('Figurinhas por dia: de 0 a 10')
|
||||||
|
elif not isinstance(valor, bool):
|
||||||
|
raise ValueError(chave + ' precisa ser verdadeiro ou falso')
|
||||||
|
limpo[chave] = valor
|
||||||
|
return limpo
|
||||||
|
|
||||||
|
def configurar(self, dados):
|
||||||
|
limpo = self.validar(dados)
|
||||||
|
with self.lock:
|
||||||
|
antes = self.config['ligado']
|
||||||
|
self.config.update(limpo)
|
||||||
|
if 'ligado' in limpo and limpo['ligado'] != antes:
|
||||||
|
self.anotar('vida', 'Modo autônomo ligado' if limpo['ligado'] else 'Modo autônomo desligado')
|
||||||
|
if not limpo['ligado']:
|
||||||
|
self.enfileirar('humor normal') # o celular volta aos gestos soltos de sempre
|
||||||
|
self.humor_enviado = (None, 0.0)
|
||||||
|
self.proximo_ato = time.time() + 5
|
||||||
|
self.salvar()
|
||||||
|
self.avisar()
|
||||||
|
return self.status()
|
||||||
|
|
||||||
|
def salvar(self):
|
||||||
|
with self.lock:
|
||||||
|
dados = dict(config=self.config, necessidades=self.necessidades, nascimento=self.nascimento,
|
||||||
|
diario=self.diario, emojis=self.emojis)
|
||||||
|
self.salvo_em = time.time()
|
||||||
|
try:
|
||||||
|
gravar_atomico(self.caminho, dados)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ estado
|
||||||
|
def dormindo(self):
|
||||||
|
cel = self.celular_dormindo()
|
||||||
|
return self.dormindo_proprio if cel is None else bool(cel)
|
||||||
|
|
||||||
|
def humor(self):
|
||||||
|
if self.dormindo():
|
||||||
|
return 'dormindo'
|
||||||
|
n = self.necessidades
|
||||||
|
if n['energia'] < 25:
|
||||||
|
return 'cansado'
|
||||||
|
if n['social'] < 25:
|
||||||
|
return 'carente'
|
||||||
|
if n['diversao'] < 25:
|
||||||
|
return 'entediado'
|
||||||
|
if n['alegria'] < 30:
|
||||||
|
return 'triste'
|
||||||
|
if n['alegria'] >= 65 and n['social'] >= 40 and n['diversao'] >= 40:
|
||||||
|
return 'feliz'
|
||||||
|
return 'tranquilo'
|
||||||
|
|
||||||
|
def anotar(self, tipo, texto, agora=None):
|
||||||
|
with self.lock:
|
||||||
|
self.diario.insert(0, dict(horario=agora or time.time(), tipo=tipo, texto=texto))
|
||||||
|
del self.diario[MAX_DIARIO:]
|
||||||
|
|
||||||
|
def emojis_hoje(self, agora=None):
|
||||||
|
dia = time.strftime('%Y-%m-%d', time.localtime(agora or time.time()))
|
||||||
|
return self.emojis['total'] if self.emojis.get('dia') == dia else 0
|
||||||
|
|
||||||
|
def status(self, agora=None):
|
||||||
|
agora = agora or time.time()
|
||||||
|
h = self.humor()
|
||||||
|
with self.lock:
|
||||||
|
return dict(config=dict(self.config), necessidades={k: round(v) for k, v in self.necessidades.items()},
|
||||||
|
humor=h, emoji=HUMORES[h][0], nome_humor=HUMORES[h][1], dormindo=self.dormindo(),
|
||||||
|
nascimento=self.nascimento, idade_dias=int((agora - self.nascimento) // 86400),
|
||||||
|
emojis_hoje=self.emojis_hoje(agora), ultimo_emoji=self.emojis.get('ultimo') or None,
|
||||||
|
pode_criar=self.motivo_sem_emoji(agora) or '', diario=self.diario[:30])
|
||||||
|
|
||||||
|
def avisar(self):
|
||||||
|
self.publicado_em = time.time()
|
||||||
|
self.publicar('vida', self.status())
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ tempo passando
|
||||||
|
def passar_tempo(self, agora):
|
||||||
|
minutos = max(0.0, min(10.0, (agora - self.ultimo_tick) / 60)) # pausa longa (placa desligada) não zera tudo
|
||||||
|
self.ultimo_tick = agora
|
||||||
|
dormindo = self.dormindo()
|
||||||
|
with self.lock:
|
||||||
|
n = self.necessidades
|
||||||
|
for k, q in QUEDA.items():
|
||||||
|
n[k] = _limitar(n[k] - q * minutos * (SONO_LENTO if dormindo else 1))
|
||||||
|
if dormindo:
|
||||||
|
n['energia'] = _limitar(n['energia'] + RECUPERA_SONO * minutos)
|
||||||
|
# alegria acompanha devagar a média de companhia e diversão
|
||||||
|
alvo = (n['social'] + n['diversao']) / 2 + 10
|
||||||
|
n['alegria'] = _limitar(n['alegria'] + (alvo - n['alegria']) * min(1.0, 0.05 * minutos))
|
||||||
|
|
||||||
|
def estimulo(self, tipo, agora=None):
|
||||||
|
"""Algo aconteceu em volta (sensor, conversa, toque, botão do painel)."""
|
||||||
|
if tipo not in ESTIMULOS and tipo not in ('ninar', 'acordar'):
|
||||||
|
raise ValueError('Cuidado desconhecido')
|
||||||
|
agora = agora or time.time()
|
||||||
|
if not self.config['ligado']:
|
||||||
|
return self.status(agora)
|
||||||
|
gesto = None
|
||||||
|
if tipo == 'ninar':
|
||||||
|
self.dormir(agora, 'Foi posto para dormir')
|
||||||
|
elif tipo == 'acordar':
|
||||||
|
self.acordar(agora, 'Foi acordado')
|
||||||
|
else:
|
||||||
|
efeito, gesto = ESTIMULOS[tipo]
|
||||||
|
with self.lock:
|
||||||
|
for k, v in efeito.items():
|
||||||
|
self.necessidades[k] = _limitar(self.necessidades[k] + v)
|
||||||
|
if tipo in CUIDADOS:
|
||||||
|
self.anotar('cuidado', CUIDADOS[tipo], agora)
|
||||||
|
if gesto and not self.bloqueado():
|
||||||
|
if self.dormindo():
|
||||||
|
self.acordar(agora, None)
|
||||||
|
self.enfileirar(gesto)
|
||||||
|
if tipo in CUIDADOS:
|
||||||
|
self.avisar()
|
||||||
|
return self.status(agora)
|
||||||
|
|
||||||
|
def dormir(self, agora, motivo):
|
||||||
|
self.dormindo_proprio = True
|
||||||
|
self.enfileirar('dormir')
|
||||||
|
if motivo:
|
||||||
|
self.anotar('sono', motivo, agora)
|
||||||
|
|
||||||
|
def acordar(self, agora, motivo):
|
||||||
|
self.dormindo_proprio = False
|
||||||
|
self.enfileirar('acordar')
|
||||||
|
if motivo:
|
||||||
|
self.anotar('sono', motivo, agora)
|
||||||
|
|
||||||
|
def noite(self, agora):
|
||||||
|
hora = time.localtime(agora).tm_hour
|
||||||
|
ini, fim = NOITE
|
||||||
|
return hora >= ini or hora < fim
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ decidir o que fazer
|
||||||
|
def escolher(self, opcoes):
|
||||||
|
total = sum(p for _, p, _ in opcoes)
|
||||||
|
x = self.rng.uniform(0, total)
|
||||||
|
for gesto, peso, texto in opcoes:
|
||||||
|
x -= peso
|
||||||
|
if x <= 0:
|
||||||
|
return gesto, texto
|
||||||
|
return opcoes[-1][0], opcoes[-1][2]
|
||||||
|
|
||||||
|
def motivo_sem_emoji(self, agora):
|
||||||
|
"""'' quando pode criar uma figurinha agora; senão, o porquê (aparece no painel)."""
|
||||||
|
if not self.config['criar_emojis']:
|
||||||
|
return 'desligado'
|
||||||
|
if self.treinamento is None:
|
||||||
|
return 'biblioteca indisponível'
|
||||||
|
if not self.pode_gemini():
|
||||||
|
return 'sem chave Gemini'
|
||||||
|
if self.emojis_hoje(agora) >= self.config['max_emojis_dia']:
|
||||||
|
return 'limite do dia'
|
||||||
|
if agora - float(self.emojis.get('ultimo') or 0) < INTERVALO_EMOJI:
|
||||||
|
return 'descansando a criatividade'
|
||||||
|
return ''
|
||||||
|
|
||||||
|
def criar_figurinha(self, agora, humor):
|
||||||
|
h = time.localtime(agora).tm_hour
|
||||||
|
periodo, extras = next((p, e) for ini, fim, p, e in PERIODOS if ini <= h < fim)
|
||||||
|
tema = self.rng.choice(TEMAS.get(humor, TEMAS['tranquilo']) + extras)
|
||||||
|
assunto = (f'Ideia que o próprio Carinha teve sozinho, {periodo}, se sentindo {HUMORES[humor][1].lower()}: '
|
||||||
|
f'{tema}. Figurinha fofa e simples.')
|
||||||
|
try:
|
||||||
|
self.treinamento.gerar_figurinha(assunto)
|
||||||
|
except ValueError as e:
|
||||||
|
self.anotar('emoji', 'Quis desenhar ' + tema + ', mas não deu: ' + str(e)[:80], agora)
|
||||||
|
return False
|
||||||
|
dia = time.strftime('%Y-%m-%d', time.localtime(agora))
|
||||||
|
with self.lock:
|
||||||
|
total = self.emojis['total'] + 1 if self.emojis.get('dia') == dia else 1
|
||||||
|
self.emojis.update(dia=dia, total=total, ultimo=agora)
|
||||||
|
self.necessidades['diversao'] = _limitar(self.necessidades['diversao'] + 30)
|
||||||
|
self.necessidades['alegria'] = _limitar(self.necessidades['alegria'] + 10)
|
||||||
|
self.anotar('emoji', 'Teve uma ideia e desenhou ' + tema, agora)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def tocar_reacao(self, agora):
|
||||||
|
try:
|
||||||
|
reacoes = [r for r in self.treinamento.status()['reacoes'] if r.get('ativa', True) and r['feedback'] >= 0]
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
if not reacoes:
|
||||||
|
return False
|
||||||
|
r = self.rng.choice(reacoes[:40])
|
||||||
|
try:
|
||||||
|
self.treinamento.testar(r['id'])
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
self.anotar('acao', 'Lembrou da reação "' + r['receita'].get('nome', '?') + '"', agora)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def agir(self, agora):
|
||||||
|
"""Um ato: o que um bichinho com essas necessidades faria agora."""
|
||||||
|
n = self.necessidades
|
||||||
|
perto = self.alguem_perto()
|
||||||
|
if self.dormindo():
|
||||||
|
# acorda descansado de dia, ou sozinho quando a energia enche e tem alguém por perto
|
||||||
|
if n['energia'] >= 95 and (not self.noite(agora) or perto):
|
||||||
|
self.acordar(agora, 'Acordou descansado')
|
||||||
|
return 'acordar'
|
||||||
|
return None
|
||||||
|
if self.config['noite'] and self.noite(agora) and not perto:
|
||||||
|
self.dormir(agora, 'Ficou tarde, foi dormir')
|
||||||
|
return 'dormir'
|
||||||
|
if n['energia'] < 20:
|
||||||
|
self.dormir(agora, 'Ficou cansado e foi tirar um cochilo')
|
||||||
|
return 'dormir'
|
||||||
|
humor = self.humor()
|
||||||
|
# habilidade: inventar uma figurinha nova (raro, com limite por dia e intervalo)
|
||||||
|
if not self.motivo_sem_emoji(agora) and self.celular_online() and self.rng.random() < 0.08:
|
||||||
|
if self.criar_figurinha(agora, humor):
|
||||||
|
return 'figurinha'
|
||||||
|
# às vezes lembra de uma reação da biblioteca
|
||||||
|
if self.treinamento is not None and humor in ('feliz', 'tranquilo', 'entediado') and self.rng.random() < 0.15:
|
||||||
|
if self.tocar_reacao(agora):
|
||||||
|
with self.lock:
|
||||||
|
n['diversao'] = _limitar(n['diversao'] + 8)
|
||||||
|
return 'reacao'
|
||||||
|
lista = ATOS['carente_perto'] if humor == 'carente' and perto else ATOS.get(humor, ATOS['tranquilo'])
|
||||||
|
gesto, texto = self.escolher(lista)
|
||||||
|
self.enfileirar(gesto)
|
||||||
|
if humor == 'entediado':
|
||||||
|
with self.lock:
|
||||||
|
n['diversao'] = _limitar(n['diversao'] + 3) # fazer algo distrai um pouquinho
|
||||||
|
self.anotar('acao', texto, agora)
|
||||||
|
return gesto
|
||||||
|
|
||||||
|
def tick(self, agora=None):
|
||||||
|
agora = agora or time.time()
|
||||||
|
self.passar_tempo(agora)
|
||||||
|
if not self.config['ligado']:
|
||||||
|
return None
|
||||||
|
ato = None
|
||||||
|
humor = self.humor()
|
||||||
|
if not self.bloqueado():
|
||||||
|
# o celular escolhe os gestos soltos pelo humor; reenvia de tempos em tempos (pode ter reiniciado)
|
||||||
|
if humor != self.humor_enviado[0] or agora - self.humor_enviado[1] > 60:
|
||||||
|
self.enfileirar('humor ' + humor)
|
||||||
|
self.humor_enviado = (humor, agora)
|
||||||
|
if agora >= self.proximo_ato:
|
||||||
|
ato = self.agir(agora)
|
||||||
|
lo, hi = RITMOS[self.config['ritmo']]
|
||||||
|
self.proximo_ato = agora + self.rng.uniform(lo, hi)
|
||||||
|
if ato or agora - self.publicado_em > 30 or humor != self.humor():
|
||||||
|
self.avisar()
|
||||||
|
if agora - self.salvo_em > 60:
|
||||||
|
self.salvar()
|
||||||
|
return ato
|
||||||
|
|
||||||
|
def loop(self):
|
||||||
|
while True:
|
||||||
|
time.sleep(5)
|
||||||
|
try:
|
||||||
|
self.tick()
|
||||||
|
except Exception as e: # nunca derruba a thread
|
||||||
|
print('vida: erro no tick:', e, flush=True)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ HTTP
|
||||||
|
def rotas(self, h, metodo):
|
||||||
|
rota = h.path.split('?', 1)[0]
|
||||||
|
if not rota.startswith('/vida/'):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
if metodo == 'GET' and rota == '/vida/status':
|
||||||
|
resultado = self.status()
|
||||||
|
elif metodo == 'POST' and rota == '/vida/config':
|
||||||
|
resultado = self.configurar(ler_json(h, 2048))
|
||||||
|
elif metodo == 'POST' and rota == '/vida/cuidar':
|
||||||
|
resultado = self.estimulo(ler_json(h, 2048).get('acao'))
|
||||||
|
else:
|
||||||
|
raise ValueError('Rota inválida')
|
||||||
|
h.enviar(json.dumps(resultado, ensure_ascii=False))
|
||||||
|
except (ValueError, TypeError) as e:
|
||||||
|
h.enviar(json.dumps(dict(erro=str(e)), ensure_ascii=False), codigo=400)
|
||||||
|
return True
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Gauge, Moon, Sparkles } from '@lucide/svelte';
|
||||||
|
import { estado } from '../estado.svelte';
|
||||||
|
import { configurarVida, cuidar, CUIDADOS, NECESSIDADES } from '../vida.svelte';
|
||||||
|
import Grupo from './Grupo.svelte';
|
||||||
|
import Interruptor from './Interruptor.svelte';
|
||||||
|
import Linha from './Linha.svelte';
|
||||||
|
|
||||||
|
// Cartão do bichinho: humor, necessidades, botões de cuidado e o diário do que ele fez sozinho.
|
||||||
|
const v = $derived(estado.vida);
|
||||||
|
let diarioAberto = $state(false);
|
||||||
|
|
||||||
|
const RITMOS = [
|
||||||
|
{ valor: 'calmo', nome: 'Calmo' },
|
||||||
|
{ valor: 'normal', nome: 'Normal' },
|
||||||
|
{ valor: 'agitado', nome: 'Agitado' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const MOTIVOS: Record<string, string> = {
|
||||||
|
desligado: '',
|
||||||
|
'sem chave Gemini': 'precisa da chave Gemini (Ajustes)',
|
||||||
|
'limite do dia': 'limite de hoje atingido',
|
||||||
|
'descansando a criatividade': 'próxima ideia em breve',
|
||||||
|
'biblioteca indisponível': 'biblioteca indisponível',
|
||||||
|
};
|
||||||
|
|
||||||
|
function hora(s: number) {
|
||||||
|
return new Date(s * 1000).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
function idade(d: number) {
|
||||||
|
return d === 0 ? 'nasceu hoje' : d === 1 ? '1 dia de vida' : `${d} dias de vida`;
|
||||||
|
}
|
||||||
|
const detalheFigurinhas = $derived(
|
||||||
|
v ? [`${v.emojis_hoje} de ${v.config.max_emojis_dia} hoje`, MOTIVOS[v.pode_criar] ?? v.pode_criar].filter(Boolean).join(' · ') : '',
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Grupo
|
||||||
|
titulo="Vida"
|
||||||
|
rodape={v?.config.ligado
|
||||||
|
? 'Ele decide sozinho o que fazer pelo humor. Tocar ou deslizar o dedo na tela do celular também conta como carinho.'
|
||||||
|
: 'Ligado, ele tem necessidades, humor, cochilos e inventa figurinhas, como um bichinho virtual.'}
|
||||||
|
>
|
||||||
|
{#if !v}
|
||||||
|
<p class="vazio">Carregando…</p>
|
||||||
|
{:else}
|
||||||
|
<div class="bicho" class:dormindo={v.dormindo}>
|
||||||
|
<span class="humor" aria-hidden="true">{v.config.ligado ? v.emoji : '🤖'}</span>
|
||||||
|
<div class="quem">
|
||||||
|
<strong>{v.config.ligado ? v.nome_humor : 'Modo autônomo desligado'}</strong>
|
||||||
|
<span>{idade(v.idade_dias)}{v.config.ligado && v.diario[0] ? ' · ' + v.diario[0].texto : ''}</span>
|
||||||
|
</div>
|
||||||
|
<Interruptor rotulo="Modo autônomo" checked={v.config.ligado} onchange={(l) => configurarVida({ ligado: l })} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if v.config.ligado}
|
||||||
|
<div class="barras">
|
||||||
|
{#each NECESSIDADES as n (n.chave)}
|
||||||
|
{@const valor = v.necessidades[n.chave]}
|
||||||
|
<div class="barra" class:baixa={valor < 25}>
|
||||||
|
<span class="nome"><span aria-hidden="true">{n.emoji}</span> {n.nome}</span>
|
||||||
|
<span
|
||||||
|
class="trilho"
|
||||||
|
role="meter"
|
||||||
|
aria-label={n.nome}
|
||||||
|
aria-valuemin="0"
|
||||||
|
aria-valuemax="100"
|
||||||
|
aria-valuenow={valor}
|
||||||
|
>
|
||||||
|
<span class="nivel" style="--v: {valor}%; --c: {n.cor}"></span>
|
||||||
|
</span>
|
||||||
|
<span class="num mono">{valor}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cuidados">
|
||||||
|
{#each CUIDADOS as c (c.acao)}
|
||||||
|
<button class="btn pequeno" onclick={() => cuidar(c.acao)}><span aria-hidden="true">{c.emoji}</span> {c.nome}</button>
|
||||||
|
{/each}
|
||||||
|
{#if v.dormindo}
|
||||||
|
<button class="btn pequeno" onclick={() => cuidar('acordar')}><span aria-hidden="true">☀️</span> Acordar</button>
|
||||||
|
{:else}
|
||||||
|
<button class="btn pequeno" onclick={() => cuidar('ninar')}><span aria-hidden="true">🌙</span> Ninar</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Linha icone={Sparkles} cor="#af52de" rotulo="Inventar figurinhas sozinho" detalhe={detalheFigurinhas}>
|
||||||
|
{#snippet acessorio()}
|
||||||
|
<Interruptor rotulo="Inventar figurinhas sozinho" checked={v.config.criar_emojis} onchange={(l) => configurarVida({ criar_emojis: l })} />
|
||||||
|
{/snippet}
|
||||||
|
</Linha>
|
||||||
|
<Linha icone={Gauge} cor="#ff9500" rotulo="Ritmo">
|
||||||
|
{#snippet acessorio()}
|
||||||
|
<span class="segmentos" role="radiogroup" aria-label="Ritmo">
|
||||||
|
{#each RITMOS as r (r.valor)}
|
||||||
|
<button
|
||||||
|
role="radio"
|
||||||
|
aria-checked={v.config.ritmo === r.valor}
|
||||||
|
class:ativo={v.config.ritmo === r.valor}
|
||||||
|
onclick={() => configurarVida({ ritmo: r.valor })}>{r.nome}</button
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</span>
|
||||||
|
{/snippet}
|
||||||
|
</Linha>
|
||||||
|
<Linha icone={Moon} cor="#5e5ce6" rotulo="Dormir à noite" detalhe="Das 23h às 7h, se não tiver ninguém por perto">
|
||||||
|
{#snippet acessorio()}
|
||||||
|
<Interruptor rotulo="Dormir à noite" checked={v.config.noite} onchange={(l) => configurarVida({ noite: l })} />
|
||||||
|
{/snippet}
|
||||||
|
</Linha>
|
||||||
|
|
||||||
|
{#if v.diario.length}
|
||||||
|
<ol class="diario">
|
||||||
|
{#each v.diario.slice(0, diarioAberto ? 30 : 5) as d (d.horario + d.texto)}
|
||||||
|
<li class={d.tipo}><time class="mono">{hora(d.horario)}</time> <span>{d.texto}</span></li>
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
{#if v.diario.length > 5}
|
||||||
|
<button class="btn fantasma pequeno mais" onclick={() => (diarioAberto = !diarioAberto)}>
|
||||||
|
{diarioAberto ? 'Mostrar menos' : 'Ver o diário inteiro'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</Grupo>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.vazio {
|
||||||
|
padding: var(--e-3) var(--e-4);
|
||||||
|
color: var(--texto-3);
|
||||||
|
font-size: var(--t-sm);
|
||||||
|
}
|
||||||
|
.bicho {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--e-3);
|
||||||
|
padding: var(--e-3) var(--e-4);
|
||||||
|
}
|
||||||
|
.humor {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
flex: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--superficie-2);
|
||||||
|
font-size: 30px;
|
||||||
|
line-height: 1;
|
||||||
|
animation: respirar 3.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.dormindo .humor {
|
||||||
|
animation-duration: 6s;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
@keyframes respirar {
|
||||||
|
50% {
|
||||||
|
transform: scale(1.07);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.quem {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.quem strong {
|
||||||
|
font-size: var(--t-md);
|
||||||
|
}
|
||||||
|
.quem span {
|
||||||
|
color: var(--texto-3);
|
||||||
|
font-size: var(--t-xs);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.barras {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--e-2);
|
||||||
|
padding: 0 var(--e-4) var(--e-3);
|
||||||
|
}
|
||||||
|
.barra {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 7.5em 1fr 2.2em;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--e-3);
|
||||||
|
font-size: var(--t-sm);
|
||||||
|
}
|
||||||
|
.nome {
|
||||||
|
color: var(--texto-2);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.trilho {
|
||||||
|
height: 8px;
|
||||||
|
border-radius: var(--raio-pill);
|
||||||
|
background: var(--superficie-3);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.nivel {
|
||||||
|
display: block;
|
||||||
|
width: var(--v);
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: var(--c);
|
||||||
|
transition: width 600ms ease;
|
||||||
|
}
|
||||||
|
.num {
|
||||||
|
text-align: right;
|
||||||
|
color: var(--texto-3);
|
||||||
|
font-size: var(--t-xs);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.baixa .num,
|
||||||
|
.baixa .nome {
|
||||||
|
color: var(--perigo);
|
||||||
|
}
|
||||||
|
.cuidados {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: var(--e-2);
|
||||||
|
padding: 0 var(--e-4) var(--e-3);
|
||||||
|
}
|
||||||
|
.cuidados .btn {
|
||||||
|
gap: 4px;
|
||||||
|
padding: 0 var(--e-1);
|
||||||
|
}
|
||||||
|
.segmentos {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 2px;
|
||||||
|
border-radius: var(--raio-sm);
|
||||||
|
background: var(--superficie-3);
|
||||||
|
}
|
||||||
|
.segmentos button {
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 0 var(--e-2);
|
||||||
|
border: 0;
|
||||||
|
border-radius: calc(var(--raio-sm) - 2px);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--texto-2);
|
||||||
|
font: 500 var(--t-xs) var(--fonte);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.segmentos button.ativo {
|
||||||
|
background: var(--superficie);
|
||||||
|
color: var(--texto);
|
||||||
|
box-shadow: var(--sombra);
|
||||||
|
}
|
||||||
|
.diario {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--e-3) var(--e-4);
|
||||||
|
border-top: 1px solid var(--borda);
|
||||||
|
list-style: none;
|
||||||
|
font-size: var(--t-sm);
|
||||||
|
}
|
||||||
|
.diario li {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--e-2);
|
||||||
|
color: var(--texto-2);
|
||||||
|
}
|
||||||
|
.diario time {
|
||||||
|
color: var(--texto-3);
|
||||||
|
font-size: var(--t-xs);
|
||||||
|
padding-top: 2px;
|
||||||
|
}
|
||||||
|
.diario li.emoji {
|
||||||
|
color: #af52de;
|
||||||
|
}
|
||||||
|
.diario li.cuidado {
|
||||||
|
color: var(--texto);
|
||||||
|
}
|
||||||
|
.mais {
|
||||||
|
justify-self: start;
|
||||||
|
margin: 0 var(--e-3) var(--e-2);
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.humor {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
import { ErroApi, pedir, quandoNegado, urlEventos } from './api';
|
import { ErroApi, pedir, quandoNegado, urlEventos } from './api';
|
||||||
import { avisos } from './avisos.svelte';
|
import { avisos } from './avisos.svelte';
|
||||||
import type { AppConfig, Conversa, EstadoServidor, IA, Motores, Reacao, Telemetria } from './tipos';
|
import type { AppConfig, Conversa, EstadoServidor, IA, Motores, Reacao, Telemetria, Vida } from './tipos';
|
||||||
|
|
||||||
export type Conexao = 'conectando' | 'aberta' | 'caiu' | 'token';
|
export type Conexao = 'conectando' | 'aberta' | 'caiu' | 'token';
|
||||||
|
|
||||||
@@ -33,6 +33,7 @@ class Estado {
|
|||||||
reacao = $state<Reacao | null>(null);
|
reacao = $state<Reacao | null>(null);
|
||||||
treinamento = $state({ ocupado: false, erro: '' });
|
treinamento = $state({ ocupado: false, erro: '' });
|
||||||
api = $state<EstadoServidor['api'] | null>(null);
|
api = $state<EstadoServidor['api'] | null>(null);
|
||||||
|
vida = $state<Vida | null>(null);
|
||||||
feed = $state<ItemFeed[]>([]);
|
feed = $state<ItemFeed[]>([]);
|
||||||
/** Sensor PIR da placa: último disparo (s, epoch) e se está ligado. */
|
/** Sensor PIR da placa: último disparo (s, epoch) e se está ligado. */
|
||||||
pir = $state<{ disponivel: boolean; ultimo: number | null; ativo?: boolean; presenca?: { estado: 'presente' | 'ausente'; desde: number; ultimo: number } } | null>(null);
|
pir = $state<{ disponivel: boolean; ultimo: number | null; ativo?: boolean; presenca?: { estado: 'presente' | 'ausente'; desde: number; ultimo: number } } | null>(null);
|
||||||
@@ -108,12 +109,14 @@ function aplicarFoto(f: EstadoServidor) {
|
|||||||
estado.reacao = f.reacao ?? null;
|
estado.reacao = f.reacao ?? null;
|
||||||
if (f.treinamento) estado.treinamento = f.treinamento;
|
if (f.treinamento) estado.treinamento = f.treinamento;
|
||||||
if (f.api) estado.api = f.api;
|
if (f.api) estado.api = f.api;
|
||||||
|
if (f.vida) estado.vida = f.vida;
|
||||||
if (f.pir) estado.pir = { disponivel: f.pir.disponivel, ultimo: f.pir.ultimo, ativo: (f.pir as any).ativo, presenca: (f.pir as any).presenca };
|
if (f.pir) estado.pir = { disponivel: f.pir.disponivel, ultimo: f.pir.ultimo, ativo: (f.pir as any).ativo, presenca: (f.pir as any).presenca };
|
||||||
}
|
}
|
||||||
|
|
||||||
const tratadores: Record<string, (d: any) => void> = {
|
const tratadores: Record<string, (d: any) => void> = {
|
||||||
expressao: (d: { ordem: string }) => {
|
expressao: (d: { ordem: string }) => {
|
||||||
if (d.ordem !== 'calma') noFeed('expressao', 'Expressão: ' + d.ordem);
|
// 'humor x' é o modo vida avisando o celular; aparece no cartão Vida, não no feed
|
||||||
|
if (d.ordem !== 'calma' && !d.ordem.startsWith('humor ')) noFeed('expressao', 'Expressão: ' + d.ordem);
|
||||||
},
|
},
|
||||||
telemetria: (d: Telemetria) => {
|
telemetria: (d: Telemetria) => {
|
||||||
estado.app = d;
|
estado.app = d;
|
||||||
@@ -142,6 +145,11 @@ const tratadores: Record<string, (d: any) => void> = {
|
|||||||
if (d?.receita?.nome) noFeed('reacao', 'Reação: ' + d.receita.nome);
|
if (d?.receita?.nome) noFeed('reacao', 'Reação: ' + d.receita.nome);
|
||||||
},
|
},
|
||||||
treinamento: (d) => (estado.treinamento = d),
|
treinamento: (d) => (estado.treinamento = d),
|
||||||
|
vida: (d: Vida) => {
|
||||||
|
const novo = d.diario[0];
|
||||||
|
if (novo && novo.horario !== estado.vida?.diario[0]?.horario && novo.tipo !== 'acao') noFeed('vida', d.emoji + ' ' + novo.texto);
|
||||||
|
estado.vida = d;
|
||||||
|
},
|
||||||
pir: (d: { ultimo: number; ativo?: boolean; presenca?: any }) => (estado.pir = { disponivel: true, ultimo: d.ultimo, ativo: d.ativo, presenca: d.presenca }),
|
pir: (d: { ultimo: number; ativo?: boolean; presenca?: any }) => (estado.pir = { disponivel: true, ultimo: d.ultimo, ativo: d.ativo, presenca: d.presenca }),
|
||||||
historico: (d) => {
|
historico: (d) => {
|
||||||
const n = d?.novo;
|
const n = d?.novo;
|
||||||
@@ -169,7 +177,7 @@ function abrirEventos() {
|
|||||||
if (estado.conexao !== 'token') estado.conexao = 'caiu';
|
if (estado.conexao !== 'token') estado.conexao = 'caiu';
|
||||||
};
|
};
|
||||||
for (const tipo of ['expressao', 'telemetria', 'app_config', 'aparencia', 'motores', 'estop', 'conversa', 'ia',
|
for (const tipo of ['expressao', 'telemetria', 'app_config', 'aparencia', 'motores', 'estop', 'conversa', 'ia',
|
||||||
'reacao', 'treinamento', 'historico', 'pir']) {
|
'reacao', 'treinamento', 'historico', 'pir', 'vida']) {
|
||||||
fonte.addEventListener(tipo, (e) => {
|
fonte.addEventListener(tipo, (e) => {
|
||||||
const dados = JSON.parse((e as MessageEvent).data);
|
const dados = JSON.parse((e as MessageEvent).data);
|
||||||
tratadores[tipo]?.(dados);
|
tratadores[tipo]?.(dados);
|
||||||
|
|||||||
@@ -82,4 +82,22 @@ export interface EstadoServidor {
|
|||||||
treinamento?: { ocupado: boolean; erro: string };
|
treinamento?: { ocupado: boolean; erro: string };
|
||||||
api?: { provedor: string; modelo: string; configurada: boolean; ultimo_teste: unknown };
|
api?: { provedor: string; modelo: string; configurada: boolean; ultimo_teste: unknown };
|
||||||
pir?: { disponivel: boolean; erro: string; linha: string; ultimo: number | null };
|
pir?: { disponivel: boolean; erro: string; linha: string; ultimo: number | null };
|
||||||
|
vida?: Vida;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Modo vida (/vida/status e SSE 'vida'): necessidades de 0 a 100, 100 = satisfeito. */
|
||||||
|
export interface Vida {
|
||||||
|
config: { ligado: boolean; criar_emojis: boolean; max_emojis_dia: number; ritmo: 'calmo' | 'normal' | 'agitado'; noite: boolean };
|
||||||
|
necessidades: { energia: number; alegria: number; social: number; diversao: number };
|
||||||
|
humor: string;
|
||||||
|
emoji: string;
|
||||||
|
nome_humor: string;
|
||||||
|
dormindo: boolean;
|
||||||
|
nascimento: number;
|
||||||
|
idade_dias: number;
|
||||||
|
emojis_hoje: number;
|
||||||
|
ultimo_emoji: number | null;
|
||||||
|
/** '' quando pode inventar figurinha agora; senão, o motivo. */
|
||||||
|
pode_criar: string;
|
||||||
|
diario: { horario: number; tipo: 'acao' | 'sono' | 'emoji' | 'cuidado' | 'vida'; texto: string }[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/** Modo vida: ajustes e cuidados. O estado chega pelo SSE 'vida' em estado.vida. */
|
||||||
|
import { postar } from './api';
|
||||||
|
import { avisos } from './avisos.svelte';
|
||||||
|
import { estado } from './estado.svelte';
|
||||||
|
import type { Vida } from './tipos';
|
||||||
|
|
||||||
|
export const NECESSIDADES: { chave: keyof Vida['necessidades']; nome: string; emoji: string; cor: string }[] = [
|
||||||
|
{ chave: 'energia', nome: 'Energia', emoji: '⚡', cor: '#ffcc00' },
|
||||||
|
{ chave: 'alegria', nome: 'Alegria', emoji: '😊', cor: '#ff2d55' },
|
||||||
|
{ chave: 'social', nome: 'Companhia', emoji: '💬', cor: '#0a84ff' },
|
||||||
|
{ chave: 'diversao', nome: 'Diversão', emoji: '🎈', cor: '#34c759' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const CUIDADOS = [
|
||||||
|
{ acao: 'carinho', nome: 'Carinho', emoji: '❤️' },
|
||||||
|
{ acao: 'brincar', nome: 'Brincar', emoji: '🎈' },
|
||||||
|
{ acao: 'alimentar', nome: 'Energia', emoji: '⚡' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export async function configurarVida(parcial: Partial<Vida['config']>) {
|
||||||
|
const antes = estado.vida;
|
||||||
|
if (antes) estado.vida = { ...antes, config: { ...antes.config, ...parcial } };
|
||||||
|
try {
|
||||||
|
estado.vida = await postar<Vida>('/vida/config', parcial);
|
||||||
|
} catch (e) {
|
||||||
|
estado.vida = antes;
|
||||||
|
avisos.erro(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cuidar(acao: string) {
|
||||||
|
try {
|
||||||
|
estado.vida = await postar<Vida>('/vida/cuidar', { acao });
|
||||||
|
} catch (e) {
|
||||||
|
avisos.erro(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
import Linha from '../lib/componentes/Linha.svelte';
|
import Linha from '../lib/componentes/Linha.svelte';
|
||||||
import PresencaPir from '../lib/componentes/PresencaPir.svelte';
|
import PresencaPir from '../lib/componentes/PresencaPir.svelte';
|
||||||
import RostoPrevia from '../lib/componentes/RostoPrevia.svelte';
|
import RostoPrevia from '../lib/componentes/RostoPrevia.svelte';
|
||||||
|
import VidaCartao from '../lib/componentes/VidaCartao.svelte';
|
||||||
import { estado } from '../lib/estado.svelte';
|
import { estado } from '../lib/estado.svelte';
|
||||||
import { Rastreamento } from '../lib/rastreamento.svelte';
|
import { Rastreamento } from '../lib/rastreamento.svelte';
|
||||||
import { VideoAoVivo } from '../lib/video.svelte';
|
import { VideoAoVivo } from '../lib/video.svelte';
|
||||||
@@ -122,6 +123,8 @@ const pirAtivo = $derived(
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="lista">
|
<div class="lista">
|
||||||
|
<VidaCartao />
|
||||||
|
|
||||||
<Grupo titulo="Celular">
|
<Grupo titulo="Celular">
|
||||||
<Celular />
|
<Celular />
|
||||||
<Linha icone={RefreshCw} cor="#0a84ff" rotulo="Recarregar tela do celular" onclick={recarregarCelular} disabled={!estado.celularOnline} />
|
<Linha icone={RefreshCw} cor="#0a84ff" rotulo="Recarregar tela do celular" onclick={recarregarCelular} disabled={!estado.celularOnline} />
|
||||||
|
|||||||
Reference in New Issue
Block a user