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:
julio
2026-10-06 04:21:23 -03:00
co-authored by Claude Opus 5.5
parent 8d410dbf24
commit b456f80716
11 changed files with 1036 additions and 16 deletions
+167
View File
@@ -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()