Funpix Kit
Inicio / Componentes / fpx · progression

fpx · progression

CharacterProgress completo: XP contra la tabla del job, niveles y puntos, promoción con rebirth, árboles de skills, hitos de Despertar, RewardFlow y XpCurve.

El ZIP incluye: addons/fpx/progression, addons/fpx/stats, addons/fpx/items, addons/fpx/inventory, addons/fpx/combat, addons/fpx/quests, addons/fpx/grid, addons/fpx/loot + sus tests + data/balance/default.tres.

Qué es

La progresión del personaje del ejemplar, como agregado: CharacterProgress (XP contra la XpTable del job activo, niveles con +5 atributos / +1 skill, gasto de atributos por tramos, promoción con rebirth y bono único de stat_growth, árboles de skills desbloqueados y compra de nodos con motivos tipados, constelación de línea de clase, hitos de Despertar idempotentes, resets con coste, barra de skills/objetos, inventario+equipo como campos, derived() vía StatSheet), RewardFlow (aplica el resultado de batalla y de quests: XP en banda por jugador, oro, ítems, reparto de grupo), XpCurve (curva 1-200 ⌊75·n^1.85⌋ para juegos sin rebirth), los esquemas XpTable/ClassDefinition/SkillTreeDefinition/SkillNodeDefinition/DespertarHitoDefinition/DespertarHitosCatalog, y ProgressionProfile (reglas de gasto como datos).

Instalación

  1. Descarga fpx-progression-v0.2.0.zip (botón de arriba) y extráelo en la raíz de tu proyecto Godot — deja la estructura tal cual:
text
tu-juego/
├── project.godot
├── addons/
│   └── fpx/
│       ├── progression/
│       ├── stats/
│       ├── items/
│       ├── inventory/
│       ├── combat/
│       ├── quests/
│       ├── grid/
│       └── loot/
└── tests/          ← opcional: bórralo si no quieres los tests
  1. Abre el proyecto (o corre --import headless). Sin plugins ni autoloads que activar: las clases quedan disponibles en cualquier script.
  2. Opcional — verifica con la suite (necesita GdUnit4, ver la guía): -a tests/progression.

Ejemplo de uso

Usa las fixtures incluidas en el componente para probar en segundos; en tu juego, sustitúyelas por tus datos.

progression · ejemplo
# El agregado del personaje con el contenido de referencia (Newbie → Swordsman).
# Tus clases, tablas de XP y árboles son .tres; la fixture los replica en memoria.
var progress := CharacterProgress.make(ProgressionFixtures.newbie_class())

# XP contra la XpTable del job activo: devuelve los niveles alcanzados.
var levels := progress.add_xp(500, &"quest")   # p. ej. [2, 3]
# cada nivel: +5 puntos de atributo, +1 de skill (consts del componente)

# Gasto de atributos por tramos (la regla vive como DATOS en ProgressionProfile):
progress.attribute_point_cost(&"str")          # 1 (<100), 2 (<200), 3 (<300)
var reason := progress.spend_attribute_point(&"str")   # &"" = OK, motivo si no

# Árbol de skills: compra con motivos tipados, rangos y requisitos.
var tree := progress.active_class.skill_tree
var node := tree.roots()[0]
if progress.can_buy(tree, node) == CharacterProgress.OK:
	progress.buy(tree, node)
print(progress.node_rank(node.id), progress.usable_skills())

# Promoción con REBIRTH: exige nivel y quest; vuelve al nivel 1 del job nuevo
# conservando lo ganado, con bono único de stat_growth (+5 STR / +3 CON).
var swordsman := ProgressionFixtures.swordsman_class()
if progress.can_promote(swordsman, quest_done) == CharacterProgress.OK:
	progress.promote(swordsman, quest_done)

# Derivados: inventario+equipo+árbol+sets → StatSheet → snapshot.
var stats: DerivedStats = progress.derived()

# RewardFlow aplica el resultado de batalla/quests (XP en banda por jugador,
# oro, ítems, reparto de grupo); XpCurve es la curva 1-200 sin rebirth.
var xp_lv10 := XpCurve.xp_for_level(10)        # ⌊75·10^1.85⌋

# Y ProgressionProfile sigue siendo las reglas de gasto como datos:
var profile := ProgressionProfile.active()
profile.point_cost(150)      # → 2
profile.can_raise(300)       # → false (cap duro)

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.

abrir a pantalla completa ↗

API pública

ClaseQué da
CharacterProgressmake(starting_class) · add_xp(amount, source) -> niveles alcanzados · can_promote/promote(target, quest_done) · spend_attribute_point/attribute_point_cost/reset_attributes · can_buy/buy(tree, node) · node_rank · known_skills/usable_skills/grant_innate_skill · tree_bonus/set_bonus/total_gear/derived() · mechanic_bonus/tree_procs · choose_class_line/constellation_* · award_despertar_hito/set_despertar · hotbar_*/object_* · can_equip(item) · consts ATTRIBUTE_POINTS_PER_LEVEL=5, SKILL_POINTS_PER_LEVEL=1, ATTRIBUTE_BASE_CAP=300
SkillTreeStateunlocked_trees, purchased_nodes, known_skills, skill_ranks, innate_skills, constellation_ranks, class_line_id · can_buy(tree, node, level, points) · buy(tree, node) -> bool · node_rank · refresh_known_skills · usable_skills · tree_bonus/mechanic_bonus/tree_procs/proc_skill_ranks/mechanic_bonus_by_rank · choose_class_line/attach_line_tree/set_constellation_ranks/reset_constellation/reset_tree_nodes
MilestoneTrackdespertar_pct, despertar_hitos · set_despertar(pct) · award_despertar_hito(hito) -> bool
RewardFlowapply_battle_result(progress, quests, result, …) · apply_quest_results · split_party_result · has_per_player_rolls
XpCurvexp_for_level(n) · acumulada · inversa
XpTable (Resource)xp_to_next[], max_level, job_tier · xp_for_level_up(level) · is_max(level)
ClassDefinition (Resource)tier, required_level, promotion_quest, xp_table, promotes_to, stat_growth, skill_tree, skill_unlocks
SkillTreeDefinition / SkillNodeDefinition (Resource)tier_levels, nodes, roots(), node_by_id() · nodo: node_type, tier, max_rank, requires, requires_rank, skill, modifiers(_by_rank)
DespertarHitoDefinition / DespertarHitosCataloghitos con pct y fuente; by_id, organic_total, load_default()
ProgressionProfile (Resource)point_cost(base), total_cost, can_raise, active()/set_active()
StarterProfileDefinition (Resource)starting_class, innate_skills, bootstrap_level, equipment, inventory_items, object_bar_auto · build(level = -1) -> CharacterProgress · load_default()/set_default()

Firmas completas (del código)

CharacterProgressextends RefCountedcharacter_progress.gd

Progresión canónica del personaje (E1-CLS-02 + E1-TREE-02, sistemas_core §8-§10): XP y nivel DENTRO del job activo, puntos sin gastar, promoción con rebirth y compra de nodos del árbol. Núcleo puro (A5): no emite señales ni toca autoloads — GameState lo posee, traduce los retornos a señales tipadas de EventBus y E1-SAVE lo volcará a disco. Modelo P-24 (decisión del diseñador 2026-07-07, estilo Ragnarok): la promoción desbloquea el ÁRBOL de la clase (pestaña nueva), pero cada skill se COMPRA con los puntos que da subir de nivel, respetando requisitos (conexión, rango de prerequisito y tier por nivel). El gasto de ATRIBUTOS (E1-TREE-03) y el equipo (E1-INV) llegan en sus historias.

const OK: StringName = &"" # mismo contrato que CombatAction.validate()
const ATTRIBUTE_POINTS_PER_LEVEL: int = 5 # §8
const SKILL_POINTS_PER_LEVEL: int = 1 # §8
const ATTRIBUTE_BASE_CAP: int = 300 # §2.1
var active_class: ClassDefinition = null
var level: int = 1 # nivel DENTRO del job activo (rebirth, §8)
var xp_in_level: int = 0 # progreso hacia el siguiente nivel de la tabla del job
var lifetime_levels: int = 0 # niveles GANADOS acumulados entre jobs (§8)
var attribute_points: int = 0 # sin gastar; el reembolso §9 usa lifetime_levels
var skill_points: int = 0 # sin gastar (1/nivel + quests §8, cap +10/saga)
var base_attributes: Dictionary[StringName, int] = {}
var promotion_bonuses: Dictionary[StringName, int] = {}

Bonos FIJOS de promoción acumulados (§9): el reset de atributos los conserva — se devuelven solo los puntos GASTADOS, jamás los bonos de clase.

var quest_skill_points: int = 0

Puntos de skill extra por quests (§8, cap +10/saga): el reset del árbol también los devuelve ("ambos trackeados", §9). E1-QST los alimentará.

var attribute_resets_done: int = 0
var tree_resets_done: int:
var innate_skills: Array[SkillDefinition]:

Skills UNIVERSALES (P-05: el ataque básico del kit Newbie): las otorga el contenido (GameState) al crear el personaje y NUNCA se resetean.

var known_skills: Array[SkillDefinition]:

Skills COMPRADAS: derivadas de los nodos ACTIVOS del árbol (P-24).

var skill_ranks: Dictionary[StringName, int]:

Rango comprado por skill activa (SkillAction lee power_by_rank con esto).

var unlocked_trees: Array[SkillTreeDefinition]:

Árboles desbloqueados, ACUMULADOS entre jobs (rebirth, §8/§10.1).

var purchased_nodes: Dictionary[StringName, int]:

Nodo → rango comprado (ids únicos entre árboles, test de integridad).

var inventory: Inventory = Inventory.new()

Inventario y equipo (E1-INV): el rebirth los conserva (§8).

var equipment: Equipment = Equipment.new()
var tree_state: SkillTreeState = SkillTreeState.new()

FLAG-PROG-SPLIT (Funpix Kit, plan §6.10): el estado de árboles/constelación y el track de Despertar viven en sus propias clases; los campos de abajo que les pertenecen son PROPIEDADES-FACHADA (mismo nombre, misma referencia) para que llamadores, tests y CharCodec sigan byte a byte.

var milestones: MilestoneTrack = MilestoneTrack.new()
var sets_catalog: Array[SetDefinition] = []

Catálogo de conjuntos conocido por el motor (MOT-3, P-56 §3.2): lo puebla el dueño (GameState/StarterProfile) desde data/sets/ vía SetCatalog. Un set NO se "desbloquea" — aplica a quien tenga sus piezas equipadas; esta lista es solo el catálogo que el motor consulta. Vacío = sin sets (comportamiento de hoy: "Mis conjuntos (0)"). No se persiste: es contenido, se recarga al arrancar.

const LINE_TREE_DIR: String = SkillTreeState.LINE_TREE_DIR

Directorio de los SkillTreeDefinition .tres de las 6 líneas (puente CLS-1): cada línea = un árbol; por él la constelación entra al kit de combate por el MISMO seam que usaba el árbol viejo (known_skills/skill_ranks/tree_bonus/ tree_procs), sin tocar el núcleo de combate.

var class_line_id: StringName:

Línea de clase ELEGIDA (permanente, SK-12): &"" = sin elegir (el panel abre en modo elección). Un personaje = una línea, para siempre (§3.4).

var constellation_ranks: Dictionary[StringName, int]:

Rangos comprados en la constelación: skill_id → rango (fuente de verdad; el kit de combate se DERIVA de aquí). Cubre TODAS las skills del grafo, incluidas las que aún no tienen .tres de combate (esas gastan puntos sin efecto — deuda declarada del puente CLS-1). El subconjunto soportado entra al kit vía el árbol .tres de la línea y node_rank (fallback abajo).

var despertar_pct: int:

% de Despertar (0-100): SOLO por hitos (§3.2) — quests de historia, trials, logros, vínculos; JAMÁS por grind/combate. Gatea tiers y skills firma de alto tier. La Cesión de Job 3 lo cede TEMPORALMENTE en combate sin tocar este track.

var line_resets_done: int:

Resets de la constelación hechos (§3.4): el primero gratis, luego nivel×100.

static func make(starting_class: ClassDefinition) -> CharacterProgress
func add_xp(amount: int, _source: StringName = &"") -> Array[int]

Acumula XP de CUALQUIER fuente (combate E1-CLS-02, quests E1-QST-02) contra la tabla del job activo y resuelve todos los niveles que correspondan (+5 atributos / +1 skill por nivel, §8). Devuelve los niveles alcanzados (vacío si no hubo level-up) para que el dueño emita las señales tipadas.

func can_promote(target: ClassDefinition, quest_done: bool = true) -> StringName

Valida la promoción (§9) sin efectos secundarios: OK (&"") o motivo tipado (&"invalid_class", &"not_a_promotion", &"level_too_low", &"quest_pending"). quest_done lo aporta el llamador hasta que exista QuestManager (E1-QST).

func promote(target: ClassDefinition, quest_done: bool = true) -> bool

Ejecuta la promoción con REBIRTH (§8-§9): el nivel vuelve a 1 con la tabla del job nuevo (la de target.xp_table); se conservan atributos, puntos sin gastar, nodos comprados y lifetime_levels; se aplica el bono FIJO ÚNICO de stat_growth y se DESBLOQUEA el árbol de la clase nueva (P-24: las skills no se regalan — se compran nodo a nodo con los puntos por nivel).

const RESET_GOLD_CAP: int = 50_000 # §9
func spend_attribute_point(attr: StringName) -> StringName

Asigna +1 a un atributo BASE pagando el coste por tramo (§2.1: 1/2/3 puntos según base <100 / 100-199 / 200-299; cap duro 300). Solo cuenta la base — el equipo jamás encarece la subida.

func attribute_point_cost(attr: StringName) -> int

Coste del siguiente punto para la UI (-1 = cap 300 alcanzado).

func attribute_reset_cost() -> int

Coste en oro del PRÓXIMO reset de cada tipo (§9): el primero es gratis (tutorial de builds); después nivel_actual × 100 con cap 50 000.

func tree_reset_cost() -> int
func reset_attributes() -> void

Reset de atributos (§9): devuelve el TOTAL ganado (5 × lifetime_levels — nunca "deshacer compras" una a una) y la base vuelve a 1 + bonos de promoción. El cobro del oro es del dueño (GameState).

func reset_tree() -> void

Reset del árbol (§9): borra los nodos comprados de TODOS los árboles y devuelve todos los puntos de skill; el kit se RE-DERIVA (P-74) y la hotbar purga las skills que dejan de conocerse (las universales se quedan).

func has_class_line() -> bool
func constellation_rank(skill_id: StringName) -> int
func constellation_points_total() -> int

Puntos de la constelación (§3.1): 1 por nivel — el nivel ES el presupuesto.

func line_reset_cost() -> int

Coste en oro del PRÓXIMO reset de la constelación (§3.4): el primero gratis, luego nivel×100 con cap 50 000 (hereda la política de resets, §9).

func set_despertar(pct: int) -> void

Fija el % de Despertar (0-100): SOLO por hitos (§3.2), jamás por grind.

var despertar_hitos: Array[StringName]:

Hitos de Despertar YA otorgados (§3.2, SK-13): ids del catálogo — idempotencia (un hito jamás cuenta dos veces). El % de Despertar SÍ persiste y jamás baja, así que el EFECTO sobrevive aunque el set se reconstruya (su persistencia entre sesiones vive en el save, CharCodec — fuera de este pase).

func award_despertar_hito(hito: DespertarHitoDefinition) -> bool

Otorga un hito de Despertar de forma IDEMPOTENTE (§3.2, SK-13).

func choose_class_line(line_id: StringName) -> bool

Elige la línea de clase (permanente, SK-12). false si ya había línea o el árbol no existe.

func attach_line_tree() -> void

(Re)adjunta el árbol de la línea elegida (restauración desde el guardado).

func set_constellation_ranks(ranks: Dictionary) -> void

Vuelca los rangos de la constelación y re-deriva el kit; las activas nuevas entran solas a la hotbar (E1-UX-02).

func reset_constellation() -> void

Reset §3.4 de la constelación: devuelve TODOS los puntos; JAMÁS toca la línea, el Despertar ni la promoción. Purga de la hotbar las activas perdidas.

func refresh_known_skills() -> void

Re-deriva known_skills + skill_ranks (P-24). No toca la hotbar.

func grant_innate_skill(skill: SkillDefinition) -> void

Otorga una skill universal (P-05); entra sola a la hotbar (E1-UX-02).

func usable_skills() -> Array[SkillDefinition]

Kit usable en combate: universales + activas compradas (P-24).

func node_rank(node_id: StringName) -> int
func can_buy(tree: SkillTreeDefinition, node: SkillNodeDefinition) -> StringName

Valida la compra SIN efectos secundarios: OK (&"") o motivo tipado.

func buy(tree: SkillTreeDefinition, node: SkillNodeDefinition) -> StringName

Compra/sube de rango un nodo: gasta 1 punto de skill; las ACTIVAS entran a la hotbar al conocerse (E1-TREE-02/E1-UX-02). Devuelve OK o el motivo.

func tree_bonus() -> GearBonus

Agregado de TODAS las pasivas compradas como GearBonus (§3 paso 1/4).

func mechanic_bonus(key: StringName) -> float

Suma de una clave de MECÁNICA de las notables compradas (P-25).

func tree_procs() -> Array[ProcDefinition]

Procs de las pasivas compradas con disparador.

func proc_skill_ranks() -> Dictionary[StringName, int]

Rango comprado de cada pasiva con procs (SK-01).

func mechanic_bonus_by_rank(key: StringName) -> float

Mecánica de notable que ESCALA por rango (SK-01, P-25).

func set_bonus() -> GearBonus

Agregado de los bonos de CONJUNTO activos (MOT-3): por cada set del catálogo, cuenta sus piezas equipadas y suma el bono acumulado de los umbrales alcanzados. Sin sets o sin piezas → bono neutro. Fase 4 del pipeline §3 (aditiva sobre árbol+equipo): NO altera el cálculo previo.

func set_progress() -> Array[Dictionary]

Estado de CADA set del catálogo para la UI («Mis conjuntos»): piezas puestas / total, umbrales activos y el bono agregado. Solo lectura, sin efectos.

func total_gear() -> GearBonus

Gear TOTAL del personaje: pasivas del árbol + equipo + conjuntos (MOT-3).

const HOTBAR_SLOTS: int = 36

Grid fijo 12×3 (GEN-3 MP-9, Biblia §10.5 «12 slots»; antes 10×3): 12 columnas × 3 filas. Solo la primera fila tiene teclas (1-0/-/=); el resto va a ratón. MIGRACIÓN SUAVE: los saves con 30 slots se rellenan a 36 en _ensure_* (los slots nuevos nacen VACÍOS, {}) — nada se pierde y las asignaciones viejas conservan su índice. Las 2 columnas nuevas de la fila keyed (- y =) quedan vacías para quien nunca las tuvo.

const OBJECT_BAR_SLOTS: int = 36
var hotbar: Array[Dictionary] = []

Hotbar de HECHIZOS: {} vacío o {"type": &"skill", "id": StringName}. Las ACTIVAS nuevas entran solas al primer hueco (E1-TREE-02). Desde el split v5 SOLO acepta skills: asignar un ítem aquí se enruta a la barra de objetos — así los saves viejos (tabla hotbar mixta §13) migran solos al restaurar. Núcleo puro: la UI (Hotbar) y el save consumen esto.

var object_bar: Array[Dictionary] = []

Barra de OBJETOS (split v5): {} o {"type": &"item", "id": StringName}. El jugador la puebla arrastrando stackeables desde el inventario; muestra stock en vivo. Persiste en la MISMA tabla hotbar (ref_type la separa).

func hotbar_entry(index: int) -> Dictionary
func object_entry(index: int) -> Dictionary
func hotbar_assign(index: int, entry_type: StringName, id: StringName) -> void

Asigna al slot; si la entrada ya vivía en otro, SE MUEVE (sin duplicados). Los ítems se ENRUTAN a la barra de objetos (split v5 + migración de saves).

func object_assign(index: int, id: StringName) -> void

Asigna un ítem a la barra de objetos (misma regla de MOVER sin duplicar).

func hotbar_swap(a: int, b: int) -> void
func object_swap(a: int, b: int) -> void
func hotbar_clear(index: int) -> void
func object_clear(index: int) -> void
func hotbar_auto_assign(entry_type: StringName, id: StringName) -> void

Primer hueco libre, sin duplicar; con la barra llena no roba slots. Los ítems se enrutan a la barra de objetos (split v5).

func object_auto_assign(id: StringName) -> void
func can_equip(item: ItemDefinition) -> StringName

Requisitos de equipo (§8): tier de job y nivel DENTRO del job — el rebirth jamás te "des-equipa" porque el nivel exigido es del job, no total.

func derived(gear: GearBonus = null) -> DerivedStats

Derivados frescos del personaje (§2.2: JAMÁS se persisten), con pasivas del árbol Y equipo incluidos (E1-INV-03); pasar gear explícito sustituye a ambos (harness/tests).

MilestoneTrackextends RefCountedmilestone_track.gd

FLAG-PROG-SPLIT (plan §6.10): el TRACK DE HITOS 0-100 del personaje (el «Despertar» del ejemplar, §3.2 / SK-13), extraído de CharacterProgress. Solo sube por HITOS idempotentes — jamás por grind — y jamás baja. Los hitos de fuente «quest» solo se REGISTRAN (su % lo aplica la propia quest por reward_despertar_pct); los demás SUMAN su aporte acotado a 100.

var despertar_pct: int = 0
var despertar_hitos: Array[StringName] = []

Ids de hitos ya otorgados (idempotencia).

func set_despertar(pct: int) -> void

Fija el % (0-100). El track nunca baja por progresión: es el dueño quien decide llamar con un valor mayor (o igual).

func award_despertar_hito(hito: DespertarHitoDefinition) -> bool

Otorga un hito de forma IDEMPOTENTE. Devuelve true si se otorgó AHORA con efecto (false si ya estaba, es nulo, o es un hito de quest sin aporte aquí).

ProgressionProfileextends Resourceprogression_profile.gd

FK-4 · Perfil de progresión (plan §6.2 FLAG-STATS-POINTCOST / §6.10): las REGLAS DE GASTO de puntos de atributo salen de StatBlock.point_cost (stat_block.gd:115-122 del ejemplar: 1/2/3 puntos a <100/<200/<300, cap duro 300) a un Resource que el juego rellena. Los defaults del esquema SON los valores de producción de Stremus (molde §5 regla 3) y el test del kit los ancla a StatBlock.point_cost en todo el rango. Es la semilla del componente progression (FK-6 trae XP, niveles, promoción y árboles desde la cirugía de CharacterProgress en Stremus).

const PROFILE_PATH := "res://data/progression/default.tres"

Ruta por convención del perfil del juego (const de cabecera, guardia FK-0).

@export var point_cost_steps: Array[int] = [100, 200]

Tramos: el coste sube 1 punto cada vez que la base alcanza un umbral. Con [100, 200]: <100 → 1, <200 → 2, <300 → 3.

@export var attribute_hard_cap: int = 300

Cap duro del atributo base: en él (o por encima) el coste es -1 (imposible).

@export var attribute_floor: int = 1

Valor base mínimo de un atributo (§2 del ejemplar: 1).

static func active() -> ProgressionProfile
static func set_active(profile: ProgressionProfile) -> void
static func reset_active() -> void
func point_cost(current_base: int) -> int

Coste en puntos de subir el atributo desde current_base (+1); -1 = cap.

func total_cost(from_base: int, to_base: int) -> int

Puntos totales para llevar un atributo de from_base a to_base (-1 si cruza el cap).

func can_raise(current_base: int) -> bool
RewardFlowextends RefCountedreward_flow.gd

resultado de un combate (y las recompensas de quest que dispare) sobre un perfil, SIN señales ni autoloads — núcleo puro (P4). Lo consumen: - GameState (single-player y espejo online): aplica y REPLAYEA los eventos del informe como señales de EventBus (el orden se conserva). - El zone server (autoridad M4): aplica sobre el personaje persistido y usa las filas ledger del informe para el checkpoint transaccional (E2). Determinista: el espejo del cliente y el server producen el MISMO informe desde el mismo resultado — la reconciliación CHAR_STATE es un no-op salvo bug.

static func apply_battle_result(progress: CharacterProgress, quests: QuestLog, result: Dictionary) -> Dictionary

Aplica un resultado de combate (CombatState.build_end_events) al perfil. En derrota no se aplica nada (§15.4: respawn sin pérdidas ni progreso). Informe: {"victory", "xp", "gold_gained", "drops", "events", "ledger"}. - drops: [{"item": ItemDefinition, "wanted": int, "added": int}] (P-30) - events: lista ORDENADA para replay de señales (ver _push_*) - ledger: filas para el checkpoint del server (kind loot|reward)

static func apply_quest_results(progress: CharacterProgress, quests: QuestLog, results: Array[Dictionary]) -> Dictionary

Aplica los resultados de QuestLog.report/start de CUALQUIER fuente (hablar con NPC, llegar a una celda…) con sus recompensas encadenadas. Devuelve el mismo informe {"gold_gained", "events", "ledger"} para replay/checkpoint.

static func split_party_result(result: Dictionary, participants: Array[Dictionary], loot_seed: int) -> Dictionary

Reparto de la recompensa de combate por PARTY (SOC-1, P-3/D-7). El cierre de batalla es POR combatiente (cada solitario recibe el pool COMPLETO, M4/P-55); cuando varios combatientes son del MISMO grupo, ese pool se REPARTE entre ellos en vez de duplicarse. Aritmética PURA y determinista: el server solo orquesta y persiste por el MISMO camino (apply_battle_result → ledger → checkpoint E6), sin abrir ventanas de dupe. DEFAULT (flag SOC-1, SIN ratificar — equitativo vs por-daño vs need/greed): reparto EQUITATIVO entre los participantes VIVOS del grupo — - XP: total en partes iguales; el resto (total % N) se reparte de a +1 a los PRIMEROS por orden de participants (determinista; la suma == total). - Oro: idéntico reparto entero con resto determinista. - Botín: cada stack va ENTERO a un único dueño elegido por RNG sembrado (loot_seed) — determinista y auditable; los demás no reciben ese stack. - Kills de quest (defeated): crédito COMPARTIDO a todos los que reparten (cada QuestLog avanza sus propios pasos de matar). Miembros KO al cerrar (alive=false) quedan FUERA del reparto (flag SOC-1) y reciben un result de derrota (no aplica nada). Si NINGÚN participante sobrevive (victoria pírrica), el pool se reparte entre TODOS para no perder la recompensa. C2 · TIRADAS POR JUGADOR, AL 100 % (FLAG-GOLD-SPLIT RATIFICADO por el diseñador el 2026-08-05, textual: «no, aquí cada cosa es un 100%, tanto items exp y oro, el drop y ganancia de exp/oro va para cada uno, se aplica cada uno a los personajes. por ejemplo si 1 sacó 1 pócima ganó 100 de exp y 55 oro, el otro debe tener su probabilidad por su parte, quizás dropeó 2 pócimas, 120 de exp y 85 oro»). Cuando el resultado trae xp_by_player / loot_by_player / gold_by_player (los tira CombatState: cada jugador su banda de XP con su +%XP de WIS, su botín D12 con su +%drop y su propio oro), NO SE DIVIDE NADA — cada miembro aplica su tirada ÍNTEGRA: su XP, su oro y sus ítems. La división ÷n de las recompensas de victoria MUERE aquí; el «reparto» de esta función pasa a ser sólo la resolución por miembro (quién cobra y qué le toca), no una repartición de un pool. De SOC-1 sobrevive lo que NO era división: la ELEGIBILIDAD (KO al cerrar queda fuera; victoria pírrica → cobran todos) y el crédito COMPARTIDO de kills. Que dos miembros cobren a la vez no es duplicación: son DOS tiradas distintas, una por persona, como en el ejemplo del diseñador. La fila del participante trae su unit (unit_id del combate, el que indexa esos mapas). Sin esos campos —resultados legacy, tests, herramientas— el reparto es EXACTAMENTE el clásico de más arriba, miembro a miembro (lookup defensivo): sin tiradas propias no hay nada que entregar entero. participants: [{"id": int, "alive": bool, "unit": int (opcional)}] en ORDEN estable (el del roster del grupo intersecado con los combatientes) — ese orden fija el reparto del resto. Devuelve {id: result_por_miembro} con la MISMA forma que consume apply_battle_result ({victory, xp_gained, gold, loot, defeated}).

const PER_PLAYER_KEYS: Array[String] = …

Claves de los mapas de tirada POR JUGADOR de C2 en un resultado de combate. UNA sola lista: el server (para saber si debe empujar la recompensa real) y el espejo (para saber si su predicción del pool sigue siendo legítima) preguntan por aquí. Añadir un mapa nuevo y olvidar uno de los dos sitios fue exactamente el bug que dejó al solitario de una batalla abierta aplicando ganancia ajena.

static func has_per_player_rolls(result: Dictionary) -> bool

¿El resultado trae tiradas POR JUGADOR (C2)? Si las trae, el "pool" del resultado es lo del PRIMER combatiente y NADIE puede aplicarlo como si fuera suyo: la recompensa real de cada uno sale de los mapas (o del reparto que el server ya asentó y empuja en el ENC_END). El tipo se PREGUNTA antes de casear: un resultado malformado (o de otra versión) con la clave ocupada por otra cosa haría reventar el as Dictionary justo en el cierre de combate — y esta función es la que decide si el espejo puede aplicar algo, así que caerse aquí es peor que ignorar la clave.

SkillTreeStateextends RefCountedskill_tree_state.gd

FLAG-PROG-SPLIT (plan §6.10, cirugía nº 2): el ESTADO DE ÁRBOLES del personaje, extraído de CharacterProgress — árboles desbloqueados, nodos comprados con rango, kit DERIVADO (known_skills/skill_ranks, P-24), skills universales (P-05), la constelación de línea (CHAR-1: rangos por skill como fuente de verdad + fallback de node_rank) y los contadores de resets. PURO: no toca puntos, nivel ni hotbar — eso lo hace la fachada de CharacterProgress, que le pasa level/skill_points cuando hace falta y expone estos campos como propiedades para que nada externo cambie.

const OK: StringName = &""
const LINE_TREE_DIR: String = "res://data/trees/mmo"

Directorio de los SkillTreeDefinition .tres de las líneas de clase (const de cabecera, guardia FK-0): cada línea = un árbol; por él la constelación entra al kit por el MISMO seam que el árbol viejo.

var innate_skills: Array[SkillDefinition] = []

Skills universales (P-05): fuera del árbol, inmunes a los resets.

var known_skills: Array[SkillDefinition] = []

Kit DERIVADO de los nodos activos comprados (P-24); se re-deriva siempre.

var skill_ranks: Dictionary[StringName, int] = {}

skill_id → rango (power_by_rank, D-11). Derivado.

var unlocked_trees: Array[SkillTreeDefinition] = []

Árboles desbloqueados (acumulados entre jobs, §8/§10.1; + el de la línea).

var purchased_nodes: Dictionary[StringName, int] = {}

node_id → rango comprado (árbol legado). Fuente de verdad del árbol.

var class_line_id: StringName = &""

Línea de clase ELEGIDA (permanente, SK-12): &"" = sin elegir.

var constellation_ranks: Dictionary[StringName, int] = {}

Rangos comprados en la constelación: skill_id → rango (fuente de verdad).

var tree_resets_done: int = 0
var line_resets_done: int = 0
func has_class_line() -> bool
func constellation_rank(skill_id: StringName) -> int
func choose_class_line(line_id: StringName) -> bool

Elige la línea (permanente): adjunta su árbol; false si ya había línea o el árbol no existe.

func attach_line_tree() -> void

(Re)adjunta el árbol de la línea ya elegida (restauración desde el guardado).

func set_constellation_ranks(ranks: Dictionary) -> void

Vuelca los rangos de la constelación desde {skill_id: rango} y re-deriva.

func reset_constellation() -> void

Reset §3.4: devuelve TODOS los puntos de la constelación; no toca la línea.

func refresh_known_skills() -> void

Re-deriva known_skills + skill_ranks desde los nodos ACTIVOS comprados de TODOS los árboles (constelación incluida, vía node_rank).

func grant_innate_skill(skill: SkillDefinition) -> bool

Otorga una universal (P-05). true si se añadió AHORA.

func usable_skills() -> Array[SkillDefinition]

Kit usable en combate: universales + activas compradas (P-24).

func node_rank(node_id: StringName) -> int
func can_buy(tree: SkillTreeDefinition, node: SkillNodeDefinition, level: int, skill_points: int) -> StringName

Valida la compra SIN efectos secundarios: OK o motivo tipado (&"unknown_node", &"max_rank", &"no_points", &"tier_locked", &"not_connected", &"requires_rank"). level/skill_points los aporta el dueño.

func buy(_tree: SkillTreeDefinition, node: SkillNodeDefinition) -> bool

Aplica la compra YA validada: sube el rango del nodo; las ACTIVAS entran al kit. Devuelve true si una activa se conoció AHORA (el dueño la lleva a la hotbar). NO gasta puntos: eso es del dueño.

func reset_tree_nodes() -> void

Reset §9 del árbol legado: borra los nodos comprados y RE-DERIVA el kit (la constelación no se toca, P-74). Los puntos los repone el dueño.

func tree_bonus() -> GearBonus

Pasivas compradas como GearBonus (§3 paso 1/4); las claves de mecánica de las notables van por mechanic_bonus() (GearBonus las ignora).

func mechanic_bonus(key: StringName) -> float

Suma de una clave de MECÁNICA de las notables compradas (P-25).

func tree_procs() -> Array[ProcDefinition]

Procs de las pasivas compradas cuyo nodo porta una skill.

func proc_skill_ranks() -> Dictionary[StringName, int]

Rango comprado de cada pasiva con procs (SK-01): skill_id → rango.

func mechanic_bonus_by_rank(key: StringName) -> float

Mecánica de notable que ESCALA por rango (modifiers_by_rank[key][rango-1]).

XpCurveextends RefCountedxp_curve.gd

SK-16 — Curva de XP 1–200 del MMO (doble track §3.1 de plan_maestro_mmo.md). PURA y estática: se testea headless en tests/mmo/xp_curve_test.gd. Nivel base continuo 1–200 que JAMÁS se reinicia (regla dura §1.2 — divergencia declarada respecto al rebirth del SP). XP INCREMENTAL por nivel (avanzar DEL nivel n al n+1): ⌊COEF × n^EXP⌋. COEF = 75, EXP = 1.85 (generador orientativo del CHECKLIST, ahora validado). MODELO DE INGRESO DE REFERENCIA (para estimar horas a los gates): el ingreso de XP/hora crece con el nivel porque los mobs dan nivel × 15 XP (§8) y las quests aportan el 30–50 % del leveleo (principio Dofus, §3.1): ingreso(nivel) ≈ MUERTES/HORA × (15 × nivel) × (1 + factor_quest) Con 120 muertes/hora y +40 % de quests ⇒ ≈ 2520 × nivel XP/h. Es una REFERENCIA para dimensionar horas, no un dato del juego: el ritmo real lo fija el contenido (SK-13 y el pase de quests). Horas a los gates en plan_maestro_mmo.md §3.1.

const MAX_LEVEL: int = 200
const COEF: float = 75.0
const EXP: float = 1.85
const REF_KILLS_PER_HOUR: float = 120.0
const REF_XP_PER_MOB_PER_LEVEL: float = 15.0 # §8: nivel_mob × 15
const REF_QUEST_FACTOR: float = 1.4 # combate + 40 % del leveleo por quests
static func xp_for_level(level: int) -> int

XP para avanzar DEL nivel level al level + 1 (1 ≤ level < MAX_LEVEL). 0 fuera de rango (el nivel 200 es el techo: no hay siguiente).

static func total_xp_for(level: int) -> int

XP acumulada para ALCANZAR level desde el nivel 1 (total_xp_for(1) == 0).

static func level_at_total_xp(total_xp: int) -> int

Nivel resultante de una XP acumulada dada (inversa monótona de total_xp_for). Útil para la UI de progreso y los tests de consistencia.

static func ref_income_per_hour(level: int) -> float

Ingreso de XP/hora de referencia al nivel level (modelo del header).

static func estimate_hours_to(level: int) -> float

Horas estimadas para ALCANZAR level desde el nivel 1 con el ingreso ref.

Qué configura el juego

Sus clases, tablas de XP y árboles (.tres o en memoria), su catálogo de hitos (DespertarHitosCatalog.DEFAULT_PATH), los árboles de línea (CharacterProgress.LINE_TREE_DIR), el loadout inicial (en el ejemplar StarterProfile, que se queda en Stremus por ser contenido) y el perfil de progresión. RewardFlow recibe QuestLog e Inventory del personaje: quién mueve el oro real es el llamador (patrón P-49).

Fixture mínima

testing/progression_fixtures.gd — ProgressionFixtures.newbie_class()/swordsman_class() (réplica de data/classes/, con sus tablas y árboles newbie_tree()/swordsman_tree() campo a campo, cacheadas para conservar identidad como load()), production_profile()/cheap_profile().

progression_fixtures.gdcódigo completo
progression_fixtures.gd
# res://addons/fpx/progression/testing/progression_fixtures.gd
class_name ProgressionFixtures
extends RefCounted
## Fixture mínima del componente `progression`: réplica EN MEMORIA, campo a
## campo, del contenido de referencia de Stremus — Newbie (tabla
## `data/progression/newbie_xp.tres`, árbol `data/trees/newbie.tres` de 7 nodos)
## → Swordsman (tier 1, nivel 10, quest, +5 STR/+3 CON, tabla
## `first_job_xp.tres`, árbol `data/trees/swordsman.tres` de 16 nodos) — más el
## perfil de producción y uno de sandbox. Las clases y árboles se CACHEAN para
## conservar la identidad de instancia igual que `load()` (promotes_to,
## unlocked_trees.has). Son contrato numérico con los tests que viajan.

static var _newbie: ClassDefinition = null
static var _swordsman: ClassDefinition = null
static var _newbie_tree: SkillTreeDefinition = null
static var _swordsman_tree: SkillTreeDefinition = null


static func production_profile() -> ProgressionProfile:
	return ProgressionProfile.new()


## Sandbox: un solo tramo a 50 y cap a 120 (nada que ver con el ejemplar).
static func cheap_profile() -> ProgressionProfile:
	var profile := ProgressionProfile.new()
	profile.point_cost_steps = [50]
	profile.attribute_hard_cap = 120
	return profile


# ── Tablas de XP ─────────────────────────────────────────────────────────────

static func newbie_xp_table() -> XpTable:
	var table := XpTable.new()
	table.id = &"newbie_xp"
	table.xp_to_next = [40, 121, 231, 367, 525, 703, 899, 1114, 1345]
	return table


static func first_job_xp_table() -> XpTable:
	var table := XpTable.new()
	table.id = &"first_job_xp"
	table.job_tier = 1
	table.max_level = 60
	table.xp_to_next = [60, 208, 433, 727, 1087, 1509, 1992, 2533, 3131, 3785, 4494,
		5256, 6070, 6937, 7854, 8822, 9839, 10905, 12020, 13182, 14392, 15649, 16953,
		18303, 19698, 21139, 22625, 24156, 25731, 27350, 29013, 30720, 32469, 34262,
		36097, 37974, 39894, 41856, 43859, 45904, 47991, 50118, 52287, 54496, 56745,
		59035, 61365, 63736, 66146, 68595, 71084, 73613, 76181, 78787, 81433, 84118,
		86841, 89602, 92402]
	return table


# ── Nodos y árboles ──────────────────────────────────────────────────────────

static func _passive(id: StringName, name: String, modifiers: Dictionary[StringName, float],
		requires: SkillNodeDefinition = null, tier: int = 1, column: int = 0,
		notable: bool = false) -> SkillNodeDefinition:
	var node := SkillNodeDefinition.new()
	node.id = id
	node.display_name = name
	node.node_type = &"passive_notable" if notable else &"passive_minor"
	node.tier = tier
	node.column = column
	node.modifiers = modifiers
	if requires != null:
		node.requires = [requires]
	return node


static func _active(id: StringName, name: String, skill: SkillDefinition,
		requires: SkillNodeDefinition = null, tier: int = 1, column: int = 0,
		requires_rank: int = 1) -> SkillNodeDefinition:
	var node := SkillNodeDefinition.new()
	node.id = id
	node.display_name = name
	node.tier = tier
	node.column = column
	node.max_rank = 5
	node.skill = skill
	node.requires_rank = requires_rank
	if requires != null:
		node.requires = [requires]
	return node


## Árbol Newbie (7 nodos, 3 columnas, tier único).
static func newbie_tree() -> SkillTreeDefinition:
	if _newbie_tree != null:
		return _newbie_tree
	var golpe := _passive(&"golpe_firme", "Golpe firme", {&"weapon_atk": 2.0})
	var instinto := _passive(&"instinto_critico", "Instinto crítico", {&"crit_chance_pct": 1.0}, golpe)
	var ambicion := _passive(&"ambicion_temprana", "Ambición temprana", {&"xp_pct": 2.0}, instinto, 1, 0, true)
	var vigor := _passive(&"vigor_novato", "Vigor novato", {&"hp_flat": 10.0}, null, 1, 1)
	var piel := _passive(&"piel_curtida", "Piel curtida", {&"armor_def": 1.0}, vigor, 1, 1)
	var paso := _passive(&"paso_ligero", "Paso ligero", {&"initiative_flat": 2.0}, null, 1, 2)
	var ojo := _passive(&"ojo_certero", "Ojo certero", {&"precision_pct": 1.0}, paso, 1, 2)
	var tree := SkillTreeDefinition.new()
	tree.id = &"newbie_tree"
	tree.display_name = "Árbol Newbie"
	tree.class_id = &"newbie"
	tree.tier_levels = {1: 1}
	tree.nodes = [golpe, instinto, ambicion, vigor, piel, paso, ojo]
	_newbie_tree = tree
	return tree


## Árbol Swordsman (16 nodos, 3 columnas, tiers 1/15/25).
static func swordsman_tree() -> SkillTreeDefinition:
	if _swordsman_tree != null:
		return _swordsman_tree
	# Columna 0: la línea de daño.
	var tajo := _active(&"nodo_tajo", "Tajo", SkillFixtures.tajo())
	var filo := _passive(&"filo_afilado", "Filo afilado", {&"weapon_atk": 3.0}, tajo)
	var arco := _active(&"nodo_corte_en_arco", "Corte en arco", SkillFixtures.corte_en_arco(), tajo, 2, 0, 2)
	var herida := _passive(&"herida_abierta", "Herida abierta", {&"dot_duration_bonus": 1.0}, arco, 2, 0, true)
	var estocada := _active(&"nodo_estocada_profunda", "Estocada profunda", SkillFixtures.estocada_profunda(), arco, 3)
	var asesino := _passive(&"instinto_asesino", "Instinto asesino", {&"crit_chance_pct": 2.0}, estocada, 3)
	# Columna 1: la línea defensiva.
	var guardia := _active(&"nodo_guardia_ferrea", "Guardia férrea", SkillFixtures.guardia_ferrea(), null, 1, 1)
	var constitucion := _passive(&"constitucion_ferrea", "Constitución férrea", {&"hp_flat": 15.0}, guardia, 1, 1)
	var placas := _passive(&"placas_soldadas", "Placas soldadas", {&"armor_def": 2.0}, constitucion, 2, 1)
	var baluarte := _passive(&"baluarte", "Baluarte", {&"defend_reduction_bonus_pct": 5.0}, placas, 2, 1, true)
	var grito := _active(&"nodo_grito_de_guerra", "Grito de guerra", SkillFixtures.grito_de_guerra(), baluarte, 3, 1)
	var represalia := _passive(&"represalia", "Represalia", {&"atk_when_defending_pct": 5.0}, grito, 3, 1, true)
	# Columna 2: la línea de control.
	var embate := _active(&"nodo_embate", "Embate", SkillFixtures.embate(), null, 1, 2)
	var zancada := _passive(&"zancada", "Zancada", {&"initiative_flat": 2.0}, embate, 1, 2)
	var empuje := _passive(&"empuje_brutal", "Empuje brutal", {&"push_cells_bonus": 1.0}, embate, 2, 2, true)
	var reflejos := _passive(&"reflejos", "Reflejos", {&"evasion_pct": 1.0}, zancada, 2, 2)
	# (`nodo_corte_sangrante` y `persecucion` del ejemplar dependen de una skill
	# que no está en SkillFixtures; ningún test que viaja los usa.)
	var tree := SkillTreeDefinition.new()
	tree.id = &"swordsman_tree"
	tree.display_name = "Árbol Swordsman"
	tree.class_id = &"swordsman"
	tree.tier_levels = {1: 1, 2: 15, 3: 25}
	tree.nodes = [tajo, filo, arco, herida, estocada, asesino, guardia, constitucion,
			placas, baluarte, grito, represalia, embate, zancada, empuje, reflejos]
	_swordsman_tree = tree
	return tree


# ── Clases ───────────────────────────────────────────────────────────────────

static func newbie_class() -> ClassDefinition:
	if _newbie != null:
		return _newbie
	var cls := ClassDefinition.new()
	cls.id = &"newbie"
	cls.display_name = "Newbie"
	cls.xp_table = newbie_xp_table()
	cls.skill_tree = newbie_tree()
	cls.promotes_to = [swordsman_class()]
	_newbie = cls
	return cls


static func swordsman_class() -> ClassDefinition:
	if _swordsman != null:
		return _swordsman
	var cls := ClassDefinition.new()
	cls.id = &"swordsman"
	cls.display_name = "Swordsman"
	cls.tier = 1
	cls.required_level = 10
	cls.promotion_quest = &"la_senda_del_espadachin"
	cls.xp_table = first_job_xp_table()
	cls.stat_growth = {&"str": 5.0, &"con": 3.0}
	cls.skill_tree = swordsman_tree()
	_swordsman = cls
	return cls


# ── Loadout inicial ──────────────────────────────────────────────────────────

## El arranque del ejemplar como datos: Newbie nivel 5, ataque básico innato,
## espada + peto de recluta y 3 pociones a la barra de objetos.
static func starter_profile() -> StarterProfileDefinition:
	var profile := StarterProfileDefinition.new()
	profile.id = &"newbie_recluta"
	profile.starting_class = newbie_class()
	profile.innate_skills = [SkillFixtures.ataque_basico()]
	profile.bootstrap_level = 5
	profile.equipment = [
		ItemFixtures.equipment(&"espada_de_recluta", &"weapon", {&"weapon_atk": 4.0}),
		ItemFixtures.equipment(&"peto_de_recluta", &"armor", {&"armor_def": 2.0}),
	]
	profile.inventory_items = [{"item": ItemFixtures.consumable(&"pocion_pequena", 10, &"heal", 30), "count": 3}]
	profile.object_bar_auto = [&"pocion_pequena"]
	profile.load_sets_catalog = false
	return profile

Cómo se prueba

tests/progression/ — progression_split_test (fachadas por referencia, estado puro, hotbar y puntos en el personaje, track idempotente), starter_profile_test (el arranque del ejemplar como datos), character_progress_test (XP multi-fuente, level-up, techo del job, presupuesto Newbie, gates y rebirth de promoción, bono único), skill_tree_purchase_test (compra, rechazos tipados, árboles acumulados tras rebirth, innatas), xp_curve_test, progression_profile_test. Se quedan en Stremus: reward_flow_test y promocion_gate_test (contenido de quests/constelación), reset_tree_kit_test y despertar_hitos_test (leen data/trees/mmo y data/mmo), xp_tables_test, skill_trees_test.

Ejemplar oro (Stremus)

scripts/core/progression/{character_progress,reward_flow}.gd, scripts/mmo/xp_curve.gd, scripts/resources/{xp_table,class_definition,skill_tree_definition,skill_node_definition,despertar_hito_definition,despertar_hitos_catalog}.gd (main@5a5942a). Contenido de referencia: Newbie (tabla [40, 121, …, 1345], árbol de 7 nodos) → Swordsman (tier 1, nivel 10, quest la_senda_del_espadachin, +5 STR/+3 CON, tabla first_job_xp).

Deuda declarada

  • ~~FLAG-PROG-SPLIT~~ cerrado (2026-08-29, Stremus bc43889): CharacterProgress (606 LOC) compone SkillTreeState (árboles, nodos, kit derivado, constelación, resets; can_buy/buy puros) y MilestoneTrack (Despertar idempotente); los campos históricos son propiedades-fachada por referencia y los métodos delegan — llamadores, tests y CharCodec intactos.
  • ~~FLAG-PROG-STARTER~~ cerrado (FW-9): schemas/starter_profile_definition.gd — el loadout inicial como .tres del juego (DEFAULT_PATH), build(level) con el algoritmo del ejemplar; en Stremus StarterProfile.make delega en data/progression/starter.tres.
  • FLAG-PROG-CONSTELLATION: ConstellationCore (528 LOC, lee JSON de docs/design) no viaja aún.
  • P-75 (carril MMO), P-76 (puente constelación↔motor); ProgressionProfile aún no es leído por CharacterProgress (usa StatBlock.point_cost; ambos anclados por test).
Generado el 2026-08-31 · Funpix Kit — componentes extraídos de Stremus: El Tercer Despertar (ejemplar oro) · Funpixart