Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions calc-sidecar/src/backends/rocalc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1456,6 +1456,30 @@ export function createShim(): ShimSession {
],
happy_break: [{ driver: "skill_slot", rocalcId: 341, level: "buffLevel" }],
kihop: [{ driver: "skill_slot", rocalcId: 342, level: "buffLevel" }],
// High Wizard (m_JobBuff[25] = [537,45,58,274,276]). Pure-data, skill_slot only.
// energy_coat (58) is already bound (inherited Mage). soul_drain + mystical_amplification
// are HW-only (trans). increase_sp_recovery (45) is a Mage-tree SP-regen passive,
// wired-but-inert (moves no scored field; distinct from soul_drain, which raises
// maxSp). rocalc ids diverge from Aegis ids, so the bindings carry the rocalc id.
// Excluded: 537 (carry-weight). Quagmire is in rocalc's A8 ailment bank, deferred.
// See high_wizard_buffs.test.ts.
soul_drain: [{ driver: "skill_slot", rocalcId: 274, level: "buffLevel" }],
mystical_amplification: [
{ driver: "skill_slot", rocalcId: 276, level: "buffLevel" },
],
increase_sp_recovery: [
{ driver: "skill_slot", rocalcId: 45, level: "buffLevel" },
],
// Super Novice No-Death Bonus (rocalc id 309 in m_JobBuff[20]): +10 to ALL
// stats, flat. A class-innate buff (no learnable skill anchor) -- the Go
// resolver gates it via ClassInnateBuffs(super_novice) and drives it at a
// fixed level 1. See super_novice_buffs.test.ts. The other Super-Novice bank
// entries are excluded: 253 Fury (no anchor, out of scope), 310 Undying
// (-1 penalty), 385 SN-Spirit (inert + external SL_SUPERNOVICE), 196 Steel
// Body (MO_STEELBODY not in the SN tree), 537/59 (carry-weight).
no_death_bonus: [
{ driver: "skill_slot", rocalcId: 309, level: "buffLevel" },
],
};

// A_Weapon_element option values (empirically probed; value 0 = "(unchanged)"
Expand Down
116 changes: 116 additions & 0 deletions calc-sidecar/test/backends/rocalc/high_wizard_buffs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { createShim } from "../../../src/shim.ts";

// High Wizard self-buffs ride the rocalc job-buff bank (m_JobBuff[25]); all use the
// skill_slot driver. soul_drain is scored -- it raises maxSp (readDerivedStats().maxSp),
// ~+2%/level. mystical_amplification raises derived MATK (readDerivedStats().matk),
// not gate-scored in the auto-attack sim (the dragonology precedent). increase_sp_recovery
// is wired-but-inert (SP-regen rate; moves no scored field, including maxSp). Reset is
// verified via maxSp (the field soul_drain moves). Buffs apply before the enemy.

const ROD = 1601; // Rod (W_ROD)

function hwShim() {
const s = createShim();
s.setClass("high_wizard");
s.setLevel({ base: 99, job: 70 });
s.setStats({ str: 1, agi: 1, vit: 50, int: 99, dex: 90, luk: 1 });
s.equip("weapon", { id: ROD });
return s;
}

const ENEMY = {
hp: 50000,
atk_min: 1500,
atk_max: 2000,
def: 20,
mdef: 10,
race: "RC_Brute",
element: "Ele_Neutral",
element_lv: 1,
size: "Size_Medium",
level: 90,
};

type Buff = { name: string; level: number };

function read(buffs: Buff[]): { maxSp: number; matkMax: number; ave: number } {
const s = hwShim();
if (buffs.length) s.setBuffs(buffs);
s.setEnemyInline(ENEMY);
const d = s.readDerivedStats();
const ave = s.readCombatResults().damage.ave;
const maxSp = d.maxSp;
const matkMax = d.matk.max;
assert.ok(
maxSp != null && matkMax != null && ave != null,
"maxSp, matk.max, and ave must be numeric",
);
return {
maxSp: maxSp as number,
matkMax: matkMax as number,
ave: ave as number,
};
}

test("soul_drain raises maxSp at lv10", () => {
const base = read([]);
const got = read([{ name: "soul_drain", level: 10 }]);
assert.ok(
got.maxSp > base.maxSp,
`soul_drain lv10 should raise maxSp (${base.maxSp} -> ${got.maxSp})`,
);
});

test("soul_drain scales with level (lv1 < lv10)", () => {
const lo = read([{ name: "soul_drain", level: 1 }]);
const hi = read([{ name: "soul_drain", level: 10 }]);
assert.ok(
hi.maxSp > lo.maxSp,
`soul_drain lv10 should exceed lv1 (${lo.maxSp} -> ${hi.maxSp})`,
);
});

test("mystical_amplification raises derived MATK at lv10", () => {
const base = read([]);
const got = read([{ name: "mystical_amplification", level: 10 }]);
assert.ok(
got.matkMax > base.matkMax,
`mystical_amplification lv10 should raise matk.max (${base.matkMax} -> ${got.matkMax})`,
);
});

test("increase_sp_recovery is wired but inert", () => {
const base = read([]);
const got = read([{ name: "increase_sp_recovery", level: 10 }]);
assert.equal(
got.maxSp,
base.maxSp,
"increase_sp_recovery must not move maxSp",
);
assert.equal(
got.matkMax,
base.matkMax,
"increase_sp_recovery must not move matk",
);
assert.equal(
got.ave,
base.ave,
"increase_sp_recovery must not move damage.ave",
);
});

test("reset() clears soul_drain (no maxSp leak)", () => {
const baseMaxSp = read([]).maxSp;
const s = hwShim();
s.setBuffs([{ name: "soul_drain", level: 10 }]);
s.readDerivedStats();
s.reset();
s.setClass("high_wizard");
s.setLevel({ base: 99, job: 70 });
s.setStats({ str: 1, agi: 1, vit: 50, int: 99, dex: 90, luk: 1 });
s.equip("weapon", { id: ROD });
const after = s.readDerivedStats().maxSp;
assert.equal(after, baseMaxSp, "reset must clear soul_drain (no maxSp leak)");
});
100 changes: 100 additions & 0 deletions calc-sidecar/test/backends/rocalc/super_novice_buffs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { createShim } from "../../../src/shim.ts";

// Super Novice: the No-Death Bonus (rocalc id 309 in m_JobBuff[20]): +10 to ALL
// stats, flat. A class-innate buff (no learnable skill anchor) -- the Go
// resolver gates it via ClassInnateBuffs(super_novice) and drives it at a
// fixed level 1. See super_novice_buffs.test.ts. The other Super-Novice bank
// entries are excluded: 253 Fury (no anchor, out of scope), 310 Undying
// (-1 penalty), 385 SN-Spirit (inert + external SL_SUPERNOVICE), 196 Steel
// Body (MO_STEELBODY not in the SN tree), 537/59 (carry-weight).

const DAGGER = 1207;

function snShim() {
const s = createShim();
s.setClass("super_novice");
s.setLevel({ base: 99, job: 70 });
s.setStats({ str: 50, agi: 50, vit: 50, int: 50, dex: 50, luk: 50 });
s.equip("weapon", { id: DAGGER });
return s;
}

const ENEMY = {
hp: 50000,
atk_min: 1500,
atk_max: 2000,
def: 20,
mdef: 10,
race: "RC_Brute",
element: "Ele_Neutral",
element_lv: 1,
size: "Size_Medium",
level: 90,
};

type Buff = { name: string; level: number };

function read(buffs: Buff[]) {
const s = snShim();
if (buffs.length) s.setBuffs(buffs);
s.setEnemyInline(ENEMY);
const d = s.readDerivedStats();
return {
maxHp: d.maxHp as number,
atkBase: (d.atk as { base: number }).base,
defSoft: (d.def as { soft: number }).soft,
hit: d.hit as number,
};
}

test("no_death_bonus raises all stats (maxHp, atk, def)", () => {
const base = read([]);
const got = read([{ name: "no_death_bonus", level: 1 }]);
assert.ok(got.maxHp > base.maxHp, `maxHp ${base.maxHp} -> ${got.maxHp}`);
assert.ok(
got.atkBase > base.atkBase,
`atk.base ${base.atkBase} -> ${got.atkBase}`,
);
assert.ok(
got.defSoft > base.defSoft,
`def.soft ${base.defSoft} -> ${got.defSoft}`,
);
});

test("inherited owls_eye drives on super_novice (skill_slot)", () => {
const base = read([]);
const got = read([{ name: "owls_eye", level: 10 }]);
assert.ok(
got.hit > base.hit,
`owls_eye should raise hit via DEX (${base.hit} -> ${got.hit})`,
);
});

test("inherited blessing drives on super_novice (support_slot)", () => {
const base = read([]);
const got = read([{ name: "blessing", level: 10 }]);
// Blessing raises STR/INT/DEX -> hit (DEX) and atk (STR) rise.
assert.ok(
got.hit > base.hit || got.atkBase > base.atkBase,
`blessing should raise a stat (hit ${base.hit}->${got.hit}, atk ${base.atkBase}->${got.atkBase})`,
);
});

test("reset() clears no_death_bonus (no maxHp leak)", () => {
const baseMaxHp = read([]).maxHp;
const s = snShim();
s.setBuffs([{ name: "no_death_bonus", level: 1 }]);
s.readDerivedStats();
s.reset();
s.setClass("super_novice");
s.setLevel({ base: 99, job: 70 });
s.setStats({ str: 50, agi: 50, vit: 50, int: 50, dex: 50, luk: 50 });
s.equip("weapon", { id: DAGGER });
assert.equal(
s.readDerivedStats().maxHp,
baseMaxHp,
"reset must clear no_death_bonus",
);
});
75 changes: 66 additions & 9 deletions cmd/build-catalog/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const (
immunityOverlayPath = "internal/catalog/data/item_immunities.yaml"
mobThreatsPath = "internal/catalog/data/mob_threats.yaml"
skillBuffsPath = "internal/catalog/data/skill_buffs.yaml"
innateBuffsPath = "internal/catalog/data/class_innate_buffs.yaml"
)

type catalogFile struct {
Expand All @@ -71,6 +72,10 @@ type catalogFile struct {
// Hercules's original job name (e.g. "Assassin_Cross"); runtime
// converts to shim-style keys at lookup time.
JobMaxLevels []data.JobMaxLevels `json:"job_max_levels,omitempty"`
// ClassInnateBuffs carries class-innate self-buffs (mechanics like the Super
// Novice No-Death Bonus) that are not anchored to a learnable skill. Keyed by
// the shim-style class name. See internal/catalog/data/class_innate_buffs.yaml.
ClassInnateBuffs map[string][]data.SelfBuff `json:"class_innate_buffs,omitempty"`
}

func main() {
Expand Down Expand Up @@ -157,6 +162,14 @@ func main() {
}
slog.Default().Info("merged skill buffs overlay", slog.Int("applied", appliedBuffs))

innateBuffsAbs := resolvePath(repoRoot, innateBuffsPath)
innateBuffs, err := loadInnateBuffs(innateBuffsAbs)
if err != nil {
slog.Default().Error("load class-innate buffs", slog.String("error", err.Error()))
os.Exit(1)
}
slog.Default().Info("class-innate buffs loaded", slog.Int("classes", len(innateBuffs)))

// Compute mob threats programmatically by joining rAthena's
// mob_skill_db.txt against each skill's StatusChange field in our
// catalog (already populated from Hercules's skill_db.conf above).
Expand Down Expand Up @@ -234,15 +247,16 @@ func main() {
}

out := catalogFile{
Version: 2,
Mode: "pre-renewal",
Source: sourceLabel(source),
GeneratedAt: time.Now().UTC(),
Items: items,
Mobs: mobs,
Skills: skills,
Classes: classes,
JobMaxLevels: jobMaxLevels,
Version: 2,
Mode: "pre-renewal",
Source: sourceLabel(source),
GeneratedAt: time.Now().UTC(),
Items: items,
Mobs: mobs,
Skills: skills,
Classes: classes,
JobMaxLevels: jobMaxLevels,
ClassInnateBuffs: innateBuffs,
}

if err := os.MkdirAll(filepath.Dir(outAbs), 0o755); err != nil {
Expand Down Expand Up @@ -451,6 +465,49 @@ func loadSkillBuffs(path string) (map[string]*data.SelfBuff, error) {
return out, nil
}

// innateBuffsFile mirrors internal/catalog/data/class_innate_buffs.yaml: a
// top-level `classes:` map of class name -> list of self-buffs that are class
// mechanics, not skills. No aegis_name (no anchor skill).
type innateBuffsFile struct {
Classes map[string][]data.SelfBuff `yaml:"classes"`
}

// loadInnateBuffs reads and validates the class-innate overlay.
func loadInnateBuffs(path string) (map[string][]data.SelfBuff, error) {
src, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read: %w", err)
}
var f innateBuffsFile
if err := yaml.Unmarshal(src, &f); err != nil {
return nil, fmt.Errorf("parse: %w", err)
}
out := make(map[string][]data.SelfBuff, len(f.Classes))
for class, buffs := range f.Classes {
seen := make(map[string]bool, len(buffs))
for i, b := range buffs {
if b.Name == "" {
return nil, fmt.Errorf("class %q innate buff %d: name is required", class, i)
}
if b.Kind != data.BuffKindStatBuff && b.Kind != data.BuffKindStatus {
return nil, fmt.Errorf("class %q innate buff %q: kind must be stat_buff or status, got %q", class, b.Name, b.Kind)
}
if b.Persistence != data.PersistencePermanent && b.Persistence != data.PersistenceTransient {
return nil, fmt.Errorf("class %q innate buff %q: persistence must be permanent or transient, got %q", class, b.Name, b.Persistence)
}
if b.Endow != nil {
return nil, fmt.Errorf("class %q innate buff %q: innate buffs cannot be weapon endows", class, b.Name)
}
if seen[b.Name] {
return nil, fmt.Errorf("class %q: duplicate innate buff name %q", class, b.Name)
}
seen[b.Name] = true
}
out[class] = buffs
}
return out, nil
}

// applySkillBuffs mutates skills in place, setting SelfBuff on each record
// present in the overlay. Errors if any overlay aegis_name is missing from
// the catalog (typo, or skill not in this mode).
Expand Down
Loading
Loading