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
- 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:
tu-juego/
├── project.godot
├── addons/
│ └── fpx/
│ ├── progression/
│ ├── stats/
│ ├── items/
│ ├── inventory/
│ ├── combat/
│ ├── quests/
│ ├── grid/
│ └── loot/
└── 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/progression.
Ejemplo de uso
Usa las fixtures incluidas en el componente para probar en segundos; en tu juego, sustitúyelas por tus datos.
# 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.
API pública
| Clase | Qué da |
|---|---|
CharacterProgress | make(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 |
SkillTreeState | unlocked_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 |
MilestoneTrack | despertar_pct, despertar_hitos · set_despertar(pct) · award_despertar_hito(hito) -> bool |
RewardFlow | apply_battle_result(progress, quests, result, …) · apply_quest_results · split_party_result · has_per_player_rolls |
XpCurve | xp_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 / DespertarHitosCatalog | hitos 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 # §8const SKILL_POINTS_PER_LEVEL: int = 1 # §8const ATTRIBUTE_BASE_CAP: int = 300 # §2.1var active_class: ClassDefinition = nullvar 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 jobvar lifetime_levels: int = 0 # niveles GANADOS acumulados entre jobs (§8)var attribute_points: int = 0 # sin gastar; el reembolso §9 usa lifetime_levelsvar 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 = 0Puntos 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 = 0var 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_DIRDirectorio 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) -> CharacterProgressfunc 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) -> StringNameValida 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) -> boolEjecuta 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 # §9func spend_attribute_point(attr: StringName) -> StringNameAsigna +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) -> intCoste del siguiente punto para la UI (-1 = cap 300 alcanzado).
func attribute_reset_cost() -> intCoste 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() -> intfunc reset_attributes() -> voidReset 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() -> voidReset 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() -> boolfunc constellation_rank(skill_id: StringName) -> intfunc constellation_points_total() -> intPuntos de la constelación (§3.1): 1 por nivel — el nivel ES el presupuesto.
func line_reset_cost() -> intCoste 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) -> voidFija 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) -> boolOtorga un hito de Despertar de forma IDEMPOTENTE (§3.2, SK-13).
func choose_class_line(line_id: StringName) -> boolElige 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) -> voidVuelca 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() -> voidReset §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() -> voidRe-deriva known_skills + skill_ranks (P-24). No toca la hotbar.
func grant_innate_skill(skill: SkillDefinition) -> voidOtorga 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) -> intfunc can_buy(tree: SkillTreeDefinition, node: SkillNodeDefinition) -> StringNameValida la compra SIN efectos secundarios: OK (&"") o motivo tipado.
func buy(tree: SkillTreeDefinition, node: SkillNodeDefinition) -> StringNameCompra/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() -> GearBonusAgregado de TODAS las pasivas compradas como GearBonus (§3 paso 1/4).
func mechanic_bonus(key: StringName) -> floatSuma 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) -> floatMecánica de notable que ESCALA por rango (SK-01, P-25).
func set_bonus() -> GearBonusAgregado 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() -> GearBonusGear TOTAL del personaje: pasivas del árbol + equipo + conjuntos (MOT-3).
const HOTBAR_SLOTS: int = 36Grid 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 = 36var 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) -> Dictionaryfunc object_entry(index: int) -> Dictionaryfunc hotbar_assign(index: int, entry_type: StringName, id: StringName) -> voidAsigna 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) -> voidAsigna un ítem a la barra de objetos (misma regla de MOVER sin duplicar).
func hotbar_swap(a: int, b: int) -> voidfunc object_swap(a: int, b: int) -> voidfunc hotbar_clear(index: int) -> voidfunc object_clear(index: int) -> voidfunc hotbar_auto_assign(entry_type: StringName, id: StringName) -> voidPrimer 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) -> voidfunc can_equip(item: ItemDefinition) -> StringNameRequisitos 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) -> DerivedStatsDerivados 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 = 0var despertar_hitos: Array[StringName] = []Ids de hitos ya otorgados (idempotencia).
func set_despertar(pct: int) -> voidFija 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) -> boolOtorga 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 = 300Cap duro del atributo base: en él (o por encima) el coste es -1 (imposible).
@export var attribute_floor: int = 1Valor base mínimo de un atributo (§2 del ejemplar: 1).
static func active() -> ProgressionProfilestatic func set_active(profile: ProgressionProfile) -> voidstatic func reset_active() -> voidfunc point_cost(current_base: int) -> intCoste en puntos de subir el atributo desde current_base (+1); -1 = cap.
func total_cost(from_base: int, to_base: int) -> intPuntos totales para llevar un atributo de from_base a to_base (-1 si cruza el cap).
func can_raise(current_base: int) -> boolRewardFlowextends 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) -> DictionaryAplica 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]) -> DictionaryAplica 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) -> DictionaryReparto 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 = 0var line_resets_done: int = 0func has_class_line() -> boolfunc constellation_rank(skill_id: StringName) -> intfunc choose_class_line(line_id: StringName) -> boolElige 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) -> voidVuelca los rangos de la constelación desde {skill_id: rango} y re-deriva.
func reset_constellation() -> voidReset §3.4: devuelve TODOS los puntos de la constelación; no toca la línea.
func refresh_known_skills() -> voidRe-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) -> boolOtorga 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) -> intfunc can_buy(tree: SkillTreeDefinition, node: SkillNodeDefinition, level: int, skill_points: int) -> StringNameValida 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) -> boolAplica 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() -> voidReset §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() -> GearBonusPasivas 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) -> floatSuma 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) -> floatMecá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 = 200const COEF: float = 75.0const EXP: float = 1.85const REF_KILLS_PER_HOUR: float = 120.0const REF_XP_PER_MOB_PER_LEVEL: float = 15.0 # §8: nivel_mob × 15const REF_QUEST_FACTOR: float = 1.4 # combate + 40 % del leveleo por questsstatic func xp_for_level(level: int) -> intXP 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) -> intXP acumulada para ALCANZAR level desde el nivel 1 (total_xp_for(1) == 0).
static func level_at_total_xp(total_xp: int) -> intNivel 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) -> floatIngreso de XP/hora de referencia al nivel level (modelo del header).
static func estimate_hours_to(level: int) -> floatHoras 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
# 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 profileCó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) componeSkillTreeState(árboles, nodos, kit derivado, constelación, resets;can_buy/buypuros) yMilestoneTrack(Despertar idempotente); los campos históricos son propiedades-fachada por referencia y los métodos delegan — llamadores, tests yCharCodecintactos. - ~~FLAG-PROG-STARTER~~ cerrado (FW-9):
schemas/starter_profile_definition.gd— el loadout inicial como.tresdel juego (DEFAULT_PATH),build(level)con el algoritmo del ejemplar; en StremusStarterProfile.makedelega endata/progression/starter.tres. - FLAG-PROG-CONSTELLATION:
ConstellationCore(528 LOC, lee JSON dedocs/design) no viaja aún. - P-75 (carril MMO), P-76 (puente constelación↔motor);
ProgressionProfileaún no es leído porCharacterProgress(usaStatBlock.point_cost; ambos anclados por test).