Files
carinha-control-panel/vida.py
T
julioandClaude Opus 5.5 d7a17b5683 feat(rosto): atividades (momentos dele) e seis gestos novos
Atividades: a tela se divide, o rosto vai para a esquerda e encolhe, e
à direita um bonequinho do Carinha (silhueta na cor dos olhos) faz algo:
ler (vira a página), cantar (microfone e notas), jogar bola
(embaixadinha), videogame (TV com joguinho), pintar (cavalete que ganha
pinceladas) e malhar (halteres e suor). Os olhos grandes acompanham:
leem as linhas, seguem a bola, olham a TV. Camada própria atrás dos
olhos, vale para todos os rostos; em pé (retrato) a cena vai para baixo.

- Comando {tipo: "atividade", valor: nome|parar}; toque na tela, dormir
  e E-Stop encerram; o celular informa a atividade na telemetria.
- Modo vida escolhe pelo humor (feliz canta e joga bola, tranquilo lê
  e pinta, entediado joga videogame), nunca de madrugada, e não
  interrompe uma atividade em andamento.
- Gestos novos: rir, comemorar, tímido, confuso, bocejar e tonto; o
  humor e o modo vida também os usam.
- Painel: favoritos vão até 20 e o bravo volta junto com os novos;
  botões de atividade no Ao vivo, no Ctrl+K e tópico no guia.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:12:14 -03:00

471 lines
23 KiB
Python

"""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
# O ritmo também é a sensibilidade: o celular recebe "ritmo x" e ajusta limiares e quantos estímulos
# precisa para reagir/acordar. Aqui, quantos disparos do PIR em 2 min acordam o rosto.
PIR_ACORDAR = {'calmo': 3, 'normal': 1, 'agitado': 1}
PADRAO = dict(ligado=True, criar_emojis=True, max_emojis_dia=3, ritmo='normal', noite=True)
NOITE = (23, 7) # horário padrão da madrugada (o servidor usa o dos ajustes do celular)
PEDIU_ATENCAO = 10 * 60 # de madrugada, só fica acordado se alguém interagiu (carinho, conversa) há pouco
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'),
('rir', 2, 'Deu risada sozinho'), ('comemorar', 1, 'Comemorou à toa')],
'tranquilo': [('olhar_esq', 2, 'Olhou em volta'), ('olhar_dir', 2, 'Olhou em volta'), ('piscar2', 2, 'Piscou devagar'),
('feliz', 1, 'Sorriu'), ('pensar', 1, 'Ficou pensativo'), ('timido', 1, 'Ficou tímido')],
'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'), ('bocejar', 2, 'Bocejou de tédio'),
('confuso', 1, 'Ficou confuso com alguma coisa')],
'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!"'), ('timido', 1, 'Ficou tímido')],
'triste': [('triste', 3, 'Ficou tristinho'), ('olhar_baixo', 2, 'Olhou para baixo'), ('pensar', 1, 'Ficou pensativo')],
'cansado': [('bocejar', 3, 'Bocejou'), ('piscar2', 1, 'Piscou devagar'), ('olhar_baixo', 2, 'Cochilou de olho aberto'),
('tonto', 1, 'Ficou meio tonto de sono')],
}
# Atividades (momentos dele): o bonequinho faz algo na tela. Por humor: (atividade, peso, diário, efeito)
ATIVIDADES = {
'feliz': [('cantar', 3, 'Foi cantar'), ('bola', 2, 'Foi jogar bola'), ('malhar', 1, 'Foi malhar')],
'tranquilo': [('ler', 3, 'Foi ler um livro'), ('pintar', 2, 'Foi pintar um quadro'), ('cantar', 1, 'Foi cantar')],
'entediado': [('videogame', 3, 'Foi jogar videogame'), ('bola', 2, 'Foi jogar bola'),
('pintar', 1, 'Foi pintar um quadro'), ('ler', 1, 'Foi ler um livro')],
}
GASTO_ATIVIDADE = {'malhar': 8, 'bola': 5, 'cantar': 3} # energia gasta
# 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.celular_atividade = lambda: None # atividade que o celular está fazendo agora
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'))
self.madrugada = self.noite_padrao # o main troca pelo horário dos ajustes
self.interagiu_em = 0.0
self.pir_eventos = []
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.enfileirar('ritmo normal') # e à sensibilidade dos Ajustes
self.proximo_ato = time.time() + 5
if self.config['ligado'] and 'ritmo' in limpo:
self.enfileirar('ritmo ' + self.config['ritmo'])
self.humor_enviado = (None, 0.0)
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 tipo in CUIDADOS or tipo == 'conversa':
self.interagiu_em = agora # alguém quis ele acordado (sensor não conta)
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 pir_acorda(self, agora=None):
"""Disparo do PIR com o rosto dormindo: acorda? No ritmo calmo precisa de vários em 2 min."""
agora = agora or time.time()
self.pir_eventos = [t for t in self.pir_eventos if agora - t < 120] + [agora]
precisa = PIR_ACORDAR[self.config['ritmo']] if self.config['ligado'] else 1
if len(self.pir_eventos) >= precisa:
self.pir_eventos = []
return True
return False
@staticmethod
def noite_padrao(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():
# de madrugada nunca acorda sozinho (nem com gente se mexendo na cama); de dia, quando descansou
if n['energia'] >= 95 and not self.madrugada(agora):
self.acordar(agora, 'Acordou descansado')
return 'acordar'
return None
if self.config['noite'] and self.madrugada(agora) and agora - self.interagiu_em > PEDIU_ATENCAO:
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()
if self.celular_atividade():
return None # ocupado com uma atividade: deixa terminar
# momentos dele: ler, cantar, jogar bola, videogame... (de dia, com o celular online)
if humor in ATIVIDADES and self.celular_online() and not self.madrugada(agora) and self.rng.random() < 0.18:
nome, texto = self.escolher(ATIVIDADES[humor])
self.enfileirar('atividade ' + nome)
with self.lock:
n['diversao'] = _limitar(n['diversao'] + 18)
n['alegria'] = _limitar(n['alegria'] + 5)
n['energia'] = _limitar(n['energia'] - GASTO_ATIVIDADE.get(nome, 2))
self.anotar('atividade', texto, agora)
return 'atividade ' + nome
# 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.enfileirar('ritmo ' + self.config['ritmo'])
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