fpx · social
Grupos (tope 8, liderazgo heredable) y trade P2P con escrow determinista anti-scam (candados + doble confirmación).
El ZIP incluye: addons/fpx/social + sus tests.
Qué es
PartyState: membresía de grupo pura (tope 8, orden de entrada estable como desempate, el líder hereda al más antiguo al irse, transferencia de liderazgo, motivos tipados). TradeSession: escrow P2P determinista anti-scam — cambiar tu oferta exige tu candado abierto y revienta candado+confirmación del otro (anti bait-and-switch); confirmar exige ambos candados; doble confirmación sella (committed); TTL por tick() inyectado (sin relojes). La materialización del canje es del consumidor (InventoryOps.trade_apply en inventory).
Instalación
- Descarga
fpx-social-v0.1.0.zip(botón de arriba) y extráelo en la raíz de tu proyecto Godot — deja la estructura tal cual:
tu-juego/
├── project.godot
├── addons/
│ └── fpx/
│ └── social/
└── tests/ ← opcional: bórralo si no quieres los tests- Abre el proyecto (o corre
--importheadless). Sin plugins ni autoloads que activar: las clases quedan disponibles en cualquier script. - Opcional — verifica con la suite (necesita GdUnit4, ver la guía):
-a tests/social.
Ejemplo de uso
Usa las fixtures incluidas en el componente para probar en segundos; en tu juego, sustitúyelas por tus datos.
# Grupo: tope 8, orden de entrada estable, motivos tipados (&"" = OK).
var party := PartyState.new(1)
party.add(2)
party.add(3)
var reason := party.add(2) # &"already_member" — nunca duplica
party.remove(1) # se va el líder → hereda el más antiguo
assert(party.leader == 2)
party.transfer_leadership(3)
# Trade P2P con escrow determinista (anti bait-and-switch): cambiar tu oferta
# exige tu candado abierto y REVIENTA el candado y la confirmación del otro.
var trade := TradeSession.new(11, 22) # Ana ↔ Bob
trade.set_gold(11, 50)
trade.set_item(22, &"pocion", 2)
trade.lock(11)
trade.lock(22)
trade.confirm(11)
trade.confirm(22) # doble confirmación → sella (committed)
if trade.state == TradeSession.COMMITTED:
# La materialización es del CONSUMIDOR (inventory · InventoryOps.trade_apply):
var offer_a := {"gold": 50, "items": []}
var offer_b := {"gold": 0, "items": [{"item": pocion, "count": 2}]}
var applied := InventoryOps.trade_apply(inv_ana, 100, offer_a, inv_bob, 0, offer_b)
# el oro lo mueve el llamador cuando applied["reason"] == OK
# TTL por tick() inyectado: sin relojes dentro del componente.
trade.tick(120.0) # → cancelled por inactividad (IDLE_TIMEOUT_S)
# Cada lado ve la sesión desde su perspectiva (para pintar la UI):
var view := trade.view(11) # {mine: {...}, theirs: {...}, state, ...}Demo en vivo
El componente REAL corriendo en tu navegador — Godot 4.7 exportado a Web sobre las fixtures del kit. La primera carga baja el motor (~10 MB comprimido) y se comparte entre todas las demos.
API pública
| Clase | Qué da |
|---|---|
PartyState | new(leader_id) · add/remove(id) -> motivo · transfer_leadership · size/is_full/has_member · MAX_SIZE = 8 |
TradeSession | new(a, b) · set_gold/set_item(who, …) · lock/unlock/confirm(who) · cancel(reason) · tick(s) · view(who) · state (negotiating/committed/cancelled) |
Firmas completas (del código)
PartyStateextends RefCountedparty_state.gd
Membresía de un GRUPO (M5, plan MMORPG: arenas ≤8 y mazmorras por party). Núcleo puro (A5): solo ids y reglas — las invitaciones, el enrutado y los datos vivos (nombre/HP) son del servicio y del zone server. El orden de members es el de ENTRADA (estable, sirve de desempate para heredar el liderazgo); el líder puede no ser members[0] tras una transferencia.
const OK := &"" # mismo contrato que CharacterProgress.OK / CombatActionconst MAX_SIZE := 8var leader := 0var members: Array[int] = []PartyState.new(leader_id: int)Constructor.
func size() -> intfunc has_member(char_id: int) -> boolfunc is_full() -> boolfunc add(char_id: int) -> StringNamefunc remove(char_id: int) -> StringNameSalida (voluntaria o kick). Si se va el LÍDER, hereda el más antiguo.
func transfer_leadership(to_id: int) -> StringNameTradeSessionextends RefCountedtrade_session.gd
Escrow P2P (M5, plan MMORPG §11 E4): máquina de estados DETERMINISTA de la negociación entre dos personajes. Núcleo puro (A5): sin señales, sin relojes (el TTL avanza con tick() del llamador) y sin tocar inventarios — el CANJE lo materializa el servicio con ProgressMutations.trade_apply cuando esta sesión llega a COMMITTED. Seguridades anti-scam (referencia Dofus/WoW): - Cambiar TU oferta exige tu candado ABIERTO, y cualquier cambio revienta el candado Y la confirmación DEL OTRO (nadie confirma contra una oferta vieja). - Confirmar exige AMBOS candados echados; el doble confirm sella (COMMITTED). - Cancelar puede cualquiera, en cualquier momento; la inactividad expira.
const OK := &"" # mismo contrato que CharacterProgress.OK / CombatActionconst NEGOTIATING := &"negotiating"const COMMITTED := &"committed"const CANCELLED := &"cancelled"const IDLE_TIMEOUT_S := 120.0Inactividad máxima antes de expirar la negociación (segundos de tick()).
var a_id := 0var b_id := 0var state: StringName = NEGOTIATINGvar cancel_reason: StringName = &""Motivo del cierre cuando state == CANCELLED (quién/por qué lo decide el caller).
TradeSession.new(first_id: int, second_id: int)Constructor.
func has_side(who: int) -> boolfunc other(who: int) -> intfunc offer_of(who: int) -> DictionaryOferta de un lado (copia profunda: el llamador no puede mutar el escrow).
func is_locked(who: int) -> boolfunc is_confirmed(who: int) -> boolfunc is_empty() -> boolfunc set_gold(who: int, amount: int) -> StringNameFija el ORO ofrecido por un lado. Solo con la negociación viva y TU candado abierto; el cambio revienta candado+confirmación del otro (oferta nueva).
func set_item(who: int, item_id: StringName, count: int) -> StringNameFija la cantidad ABSOLUTA de un ítem en la oferta (0 = quitarlo). El stock real lo valida el servicio contra el inventario (aquí solo la negociación).
func lock(who: int) -> StringNameEcha el candado: tu oferta queda congelada hasta que la cambies (unlock).
func unlock(who: int) -> StringNameAbre TU candado (para retocar la oferta); anula las confirmaciones.
func confirm(who: int) -> StringNameConfirmación final: exige AMBOS candados. El doble confirm sella la sesión (COMMITTED) — a partir de ahí solo el servicio materializa el canje.
func cancel(reason: StringName) -> voidCierre unilateral (o del sistema: timeout/desconexión). Idempotente.
func tick(seconds: float) -> voidAvanza el reloj de INACTIVIDAD (lo alimenta el servicio; determinista).
func view(who: int) -> DictionaryVista para la UI/protocolo DESDE la perspectiva de un lado.
Qué configura el juego
Quién puede invitar/echar (servicio), qué hace al sellar (committed → InventoryOps.trade_apply + mover oro), el TTL.
Fixture mínima
testing/social_fixtures.gd — SocialFixtures.full_party() (8, líder 1), locked_trade() (Ana 50 oro ↔ Bob 2 pociones, ambos candados).
social_fixtures.gdcódigo completo
# res://addons/fpx/social/testing/social_fixtures.gd
class_name SocialFixtures
extends RefCounted
## Fixture mínima del componente `social`: un grupo lleno (8, líder 1) y una
## sesión de trade con ambas ofertas puestas y candadas — el estado justo antes
## del doble confirm. Ids de personaje estables: son contrato del fixture.
const LEADER_ID := 1
const ANA := 11
const BOB := 22
## Grupo a tope: líder 1 + miembros 2..8.
static func full_party() -> PartyState:
var party := PartyState.new(LEADER_ID)
for id: int in range(2, PartyState.MAX_SIZE + 1):
party.add(id)
return party
## Trade Ana↔Bob: Ana ofrece 50 de oro, Bob 2 pociones; ambos candados echados.
static func locked_trade() -> TradeSession:
var s := TradeSession.new(ANA, BOB)
s.set_gold(ANA, 50)
s.set_item(BOB, &"pocion", 2)
s.lock(ANA)
s.lock(BOB)
return sCómo se prueba
tests/social/ — party_state_test, trade_session_test (máquina de estados completa), social_fixtures_test.
Ejemplar oro (Stremus)
scripts/core/social/{party_state,trade_session}.gd (main@0be95b4); M5 escrow E4.
Deuda declarada
- Los servicios de servidor del ejemplar (
PartyService/TradeService, invitaciones, kick, timeouts) llegan conserver_kernel(FK-8+).