diff --git a/internal/characters/jahoda/aimed.go b/internal/characters/jahoda/aimed.go
new file mode 100644
index 0000000000..41d02b1f09
--- /dev/null
+++ b/internal/characters/jahoda/aimed.go
@@ -0,0 +1,97 @@
+package jahoda
+
+import (
+ "fmt"
+
+ "github.com/genshinsim/gcsim/internal/frames"
+ "github.com/genshinsim/gcsim/pkg/core/action"
+ "github.com/genshinsim/gcsim/pkg/core/attacks"
+ "github.com/genshinsim/gcsim/pkg/core/attributes"
+ "github.com/genshinsim/gcsim/pkg/core/combat"
+ "github.com/genshinsim/gcsim/pkg/core/info"
+)
+
+var aimedFrames [][]int
+
+var aimedHitmarks = []int{15, 85} // {Aim -> Dash, Aim -> Jump}
+
+func init() {
+ aimedFrames = make([][]int, 3)
+
+ // Aimed Shot
+ aimedFrames[0] = frames.InitAbilSlice(aimedHitmarks[0])
+
+ // Fully-Charged Aimed Shot
+ aimedFrames[1] = frames.InitAbilSlice(aimedHitmarks[1])
+}
+
+func (c *char) Aimed(p map[string]int) (action.Info, error) {
+ if c.StatusIsActive(shadowPursuitKey) {
+ c.Core.Tasks.Add(c.drainFlask(c.skillSrc), 0)
+ return action.Info{
+ Frames: frames.NewAbilFunc(skillCancelFrames),
+ AnimationLength: skillCancelFrames[action.InvalidAction],
+ CanQueueAfter: skillCancelFrames[action.ActionDash], // earliest cancel
+ State: action.SkillState,
+ }, nil
+ }
+
+ hold, ok := p["hold"]
+ if !ok {
+ hold = attacks.AimParamLv1
+ }
+
+ switch hold {
+ case attacks.AimParamPhys:
+ case attacks.AimParamLv1:
+ default:
+ return action.Info{}, fmt.Errorf("invalid hold param supplied, got %v", hold)
+ }
+ travel, ok := p["travel"]
+ if !ok {
+ travel = 10
+ }
+ weakspot := p["weakspot"]
+
+ ai := info.AttackInfo{
+ ActorIndex: c.Index(),
+ Abil: "Fully-Charged Aimed Shot",
+ AttackTag: attacks.AttackTagExtra,
+ ICDTag: attacks.ICDTagNone,
+ ICDGroup: attacks.ICDGroupDefault,
+ StrikeType: attacks.StrikeTypePierce,
+ Element: attributes.Anemo,
+ Durability: 25,
+ Mult: fullaim[c.TalentLvlAttack()],
+ HitWeakPoint: weakspot == 1,
+ HitlagHaltFrames: 0.12 * 60,
+ HitlagFactor: 0.01,
+ HitlagOnHeadshotOnly: true,
+ IsDeployable: true,
+ }
+ if hold < attacks.AimParamLv1 {
+ ai.Abil = "Aimed Shot"
+ ai.Element = attributes.Physical
+ ai.Mult = aim[c.TalentLvlAttack()]
+ }
+
+ c.Core.QueueAttack(
+ ai,
+ combat.NewBoxHit(
+ c.Core.Combat.Player(),
+ c.Core.Combat.PrimaryTarget(),
+ info.Point{Y: -0.5},
+ 0.1,
+ 1,
+ ),
+ aimedHitmarks[hold],
+ aimedHitmarks[hold]+travel,
+ )
+
+ return action.Info{
+ Frames: frames.NewAbilFunc(aimedFrames[hold]),
+ AnimationLength: aimedFrames[hold][action.InvalidAction],
+ CanQueueAfter: aimedHitmarks[hold],
+ State: action.AimState,
+ }, nil
+}
diff --git a/internal/characters/jahoda/asc.go b/internal/characters/jahoda/asc.go
new file mode 100644
index 0000000000..47b28f0c2a
--- /dev/null
+++ b/internal/characters/jahoda/asc.go
@@ -0,0 +1,117 @@
+package jahoda
+
+import (
+ "github.com/genshinsim/gcsim/pkg/core/attributes"
+ "github.com/genshinsim/gcsim/pkg/core/glog"
+ "github.com/genshinsim/gcsim/pkg/core/player/character"
+ "github.com/genshinsim/gcsim/pkg/modifier"
+)
+
+func (c *char) a1Init() {
+ eleCountMap := c.countElements()
+
+ priority := []attributes.Element{
+ attributes.Pyro,
+ attributes.Hydro,
+ attributes.Electro,
+ attributes.Cryo,
+ }
+
+ highestEleCount := 0
+
+ for _, ele := range priority {
+ if eleCountMap[ele] > highestEleCount {
+ highestEleCount = eleCountMap[ele]
+ c.a1HighestEle = ele
+ }
+ }
+
+ if highestEleCount == 0 {
+ c.a1HighestEle = attributes.NoElement
+ }
+
+ if c.Base.Cons >= 2 && c.Core.Player.GetMoonsignLevel() > 2 {
+ secondHighestEleCount := 0
+
+ for _, ele := range priority {
+ if ele == c.a1HighestEle {
+ continue
+ }
+
+ if eleCountMap[ele] > secondHighestEleCount {
+ secondHighestEleCount = eleCountMap[ele]
+ c.c2NextHighestEle = ele
+ }
+ }
+
+ if secondHighestEleCount == 0 {
+ c.c2NextHighestEle = attributes.NoElement
+ }
+ }
+}
+
+func (c *char) a1() {
+ if c.Base.Ascension < 1 {
+ return
+ }
+
+ c.applyA1Buff(c.a1HighestEle)
+
+ if c.Base.Cons >= 2 && c.Core.Player.GetMoonsignLevel() > 2 {
+ c.applyA1Buff(c.c2NextHighestEle)
+ }
+}
+
+func (c *char) countElements() map[attributes.Element]int {
+ count := map[attributes.Element]int{
+ attributes.Pyro: 0,
+ attributes.Hydro: 0,
+ attributes.Electro: 0,
+ attributes.Cryo: 0,
+ }
+
+ for _, ch := range c.Core.Player.Chars() {
+ if ch == nil {
+ continue
+ }
+
+ switch ch.Base.Element {
+ case attributes.Pyro,
+ attributes.Hydro,
+ attributes.Electro,
+ attributes.Cryo:
+ count[ch.Base.Element]++
+ }
+ }
+
+ return count
+}
+
+func (c *char) applyA1Buff(ele attributes.Element) {
+ switch ele {
+ case attributes.Pyro:
+ c.robotAi.FlatDmg *= 1.3
+ case attributes.Hydro:
+ c.robotHi.Src *= 1.2
+ case attributes.Electro:
+ c.robotCount += 1
+ case attributes.Cryo:
+ c.robotHitmarkInterval *= 0.9
+ }
+}
+
+func (c *char) a4() {
+ if c.Base.Ascension < 4 {
+ return
+ }
+
+ c.Core.Player.ActiveChar().AddStatMod(character.StatMod{
+ Base: modifier.NewBase("jahoda-a4", 6*60),
+ AffectedStat: attributes.EM,
+ Amount: func() ([]float64, bool) {
+ return c.a4Buff, true
+ },
+ })
+
+ c.Core.Log.NewEvent("jahoda a4 triggered", glog.LogCharacterEvent, c.Index()).Write("em snapshot", c.a4Buff[attributes.EM]).Write("expiry", c.Core.F+6*60)
+}
diff --git a/internal/characters/jahoda/attack.go b/internal/characters/jahoda/attack.go
new file mode 100644
index 0000000000..b75f2a282e
--- /dev/null
+++ b/internal/characters/jahoda/attack.go
@@ -0,0 +1,85 @@
+package jahoda
+
+import (
+ "fmt"
+
+ "github.com/genshinsim/gcsim/internal/frames"
+ "github.com/genshinsim/gcsim/pkg/core/action"
+ "github.com/genshinsim/gcsim/pkg/core/attacks"
+ "github.com/genshinsim/gcsim/pkg/core/attributes"
+ "github.com/genshinsim/gcsim/pkg/core/combat"
+ "github.com/genshinsim/gcsim/pkg/core/info"
+)
+
+var (
+ attackFrames [][]int
+ attackHitmarks = [][]int{{14}, {15, 29}, {40}}
+)
+
+const normalHitNum = 3
+
+func init() {
+ // NA cancels
+ attackFrames = make([][]int, normalHitNum)
+
+ attackFrames[0] = frames.InitNormalCancelSlice(attackHitmarks[0][0], 35) // N1 -> Walk
+ attackFrames[0][action.ActionAttack] = 30
+ attackFrames[0][action.ActionAim] = 30
+
+ attackFrames[1] = frames.InitNormalCancelSlice(attackHitmarks[1][1], 52) // N2 -> Walk
+ attackFrames[1][action.ActionAttack] = 48
+ attackFrames[1][action.ActionAim] = 47
+
+ attackFrames[2] = frames.InitNormalCancelSlice(attackHitmarks[2][0], 99) // N3 -> Walk
+ attackFrames[2][action.ActionAttack] = 88
+ attackFrames[2][action.ActionAim] = 89
+}
+
+func (c *char) Attack(p map[string]int) (action.Info, error) {
+ if c.StatusIsActive(shadowPursuitKey) {
+ c.Core.Tasks.Add(c.drainFlask(c.skillSrc), 0)
+ return action.Info{
+ Frames: frames.NewAbilFunc(skillCancelFrames),
+ AnimationLength: skillCancelFrames[action.InvalidAction],
+ CanQueueAfter: skillCancelFrames[action.ActionDash], // earliest cancel
+ State: action.SkillState,
+ }, nil
+ }
+
+ travel, ok := p["travel"]
+ if !ok {
+ travel = 10
+ }
+
+ for i, mult := range attack[c.NormalCounter] {
+ ai := info.AttackInfo{
+ ActorIndex: c.Index(),
+ Abil: fmt.Sprintf("Normal %v", c.NormalCounter),
+ AttackTag: attacks.AttackTagNormal,
+ ICDTag: attacks.ICDTagNone,
+ ICDGroup: attacks.ICDGroupDefault,
+ StrikeType: attacks.StrikeTypePierce,
+ Element: attributes.Physical,
+ Durability: 25,
+ Mult: mult[c.TalentLvlAttack()],
+ HitlagFactor: 0.01,
+ }
+
+ ap := combat.NewBoxHit(c.Core.Combat.Player(), c.Core.Combat.PrimaryTarget(), info.Point{Y: -0.5}, 0.1, 1)
+ c.Core.QueueAttack(
+ ai,
+ ap,
+ attackHitmarks[c.NormalCounter][i],
+ attackHitmarks[c.NormalCounter][i]+travel,
+ )
+ }
+
+ defer c.AdvanceNormalIndex()
+
+ return action.Info{
+ Frames: frames.NewAttackFunc(c.Character, attackFrames),
+ AnimationLength: attackFrames[c.NormalCounter][action.InvalidAction],
+ CanQueueAfter: attackHitmarks[c.NormalCounter][len(attackHitmarks[c.NormalCounter])-1],
+ State: action.NormalAttackState,
+ }, nil
+}
diff --git a/internal/characters/jahoda/burst.go b/internal/characters/jahoda/burst.go
new file mode 100644
index 0000000000..cb5c7b646d
--- /dev/null
+++ b/internal/characters/jahoda/burst.go
@@ -0,0 +1,262 @@
+package jahoda
+
+import (
+ "errors"
+ "sort"
+
+ "github.com/genshinsim/gcsim/internal/frames"
+ "github.com/genshinsim/gcsim/pkg/core/action"
+ "github.com/genshinsim/gcsim/pkg/core/attacks"
+ "github.com/genshinsim/gcsim/pkg/core/attributes"
+ "github.com/genshinsim/gcsim/pkg/core/combat"
+ "github.com/genshinsim/gcsim/pkg/core/glog"
+ "github.com/genshinsim/gcsim/pkg/core/info"
+)
+
+var burstFrames []int
+
+const (
+ burstDuration = 790
+ firstAbsorbtionDelay = 6
+ absorptionInterval = 41
+ firstrobotHitmark = 45
+ robotDelay = 94
+ firsHealTickDelay = 12
+ healInterval = 87
+ burstCD = 18 * 60
+ burstKey = "jahoda-burst-dot"
+)
+
+func init() {
+ burstFrames = frames.InitAbilSlice(48) // Q -> N1
+ burstFrames[action.ActionSkill] = 53 // Q -> Skill
+ burstFrames[action.ActionAim] = 55 // Q -> Aim
+ burstFrames[action.ActionDash] = 54 // Q -> D
+ burstFrames[action.ActionJump] = 54 // Q -> J
+ burstFrames[action.ActionWalk] = 55 // Q -> W
+ burstFrames[action.ActionSwap] = 36 // Q -> Swap
+}
+
+func (c *char) Burst(p map[string]int) (action.Info, error) {
+ if c.StatusIsActive(shadowPursuitKey) {
+ return action.Info{}, errors.New("burst called in skill state")
+ }
+
+ c.robotHitmarkInterval = 140
+ c.burstSrc = c.Core.F
+ src := c.burstSrc
+ c.burstAbsorbCheckLocation = combat.NewCircleHitOnTarget(c.Core.Combat.Player(), nil, 1.2) // Couldn't find anywhere in dm, assume top be the same as Sayu
+
+ c.AddStatus(burstKey, burstDuration, false)
+
+ // Initial hit damage
+ ai := info.AttackInfo{
+ ActorIndex: c.Index(),
+ Abil: "Hidden Aces: Seven Tools of the Hunter",
+ AttackTag: attacks.AttackTagElementalBurst,
+ ICDTag: attacks.ICDTagNone,
+ ICDGroup: attacks.ICDGroupDefault,
+ StrikeType: attacks.StrikeTypeDefault,
+ Element: attributes.Anemo,
+ Durability: 25,
+ Mult: burst[c.TalentLvlBurst()],
+ }
+
+ c.Core.QueueAttack(
+ ai,
+ combat.NewCircleHitOnTarget(c.Core.Combat.Player(), info.Point{Y: 1}, 5),
+ 0,
+ 0)
+
+ // Define Info
+ c.robotAi = info.AttackInfo{
+ ActorIndex: c.Index(),
+ Abil: "Purrsonal Coordinated Assistance Robot DMG",
+ AttackTag: attacks.AttackTagElementalBurst,
+ ICDTag: attacks.ICDTagElementalBurst,
+ ICDGroup: attacks.ICDGroupJahodaBurst, // special icd, 15s/4 hits
+ StrikeType: attacks.StrikeTypeDefault,
+ Element: attributes.NoElement,
+ Durability: 25,
+ FlatDmg: burstSkill[c.TalentLvlBurst()] * c.TotalAtk(),
+ }
+
+ heal := burstHealFlat[c.TalentLvlBurst()] + burstHealPP[c.TalentLvlBurst()]*c.TotalAtk()
+ c.robotHi = info.HealInfo{
+ Caller: c.Index(),
+ Target: c.Core.Player.Active(),
+ Message: "Purrsonal Coordinated Assistance Robot Healing",
+ Src: heal,
+ Bonus: c.Stat(attributes.Heal),
+ }
+
+ c.robotCount = 2
+
+ // Apply A1 buff
+ c.a1()
+
+ // Heal ticks
+ c.QueueCharTask(func() {
+ for i := 0; i < burstDuration-firsHealTickDelay; i += healInterval {
+ c.Core.Tasks.Add(func() {
+ if src != c.burstSrc {
+ return
+ }
+
+ c.Core.Player.Heal(c.robotHi)
+
+ if c.Core.Player.ActiveChar().CurrentHPRatio() > 0.7 {
+ c.a4()
+
+ low := c.lowestHPChar()
+ if low >= 0 {
+ healOffField := burstAdditionalHealFlat[c.TalentLvlBurst()] + burstAdditionalHealPP[c.TalentLvlBurst()]*c.TotalAtk()
+
+ c.Core.Player.Heal(info.HealInfo{
+ Caller: c.Index(),
+ Target: low,
+ Message: "Additional Healing",
+ Src: healOffField,
+ Bonus: c.Stat(attributes.Heal),
+ })
+ }
+
+ }
+ }, i)
+ }
+ }, firsHealTickDelay)
+
+ // Dmg ticks
+ if c.Core.Player.GetMoonsignLevel() >= 2 {
+ c.Core.Tasks.Add(c.absorbCheck(src, 0, burstDuration/absorptionInterval), firstAbsorbtionDelay+firstrobotHitmark)
+ }
+
+ c.SetCDWithDelay(action.ActionBurst, burstCD, 1)
+ c.ConsumeEnergy(13)
+
+ return action.Info{
+ Frames: frames.NewAbilFunc(burstFrames),
+ AnimationLength: burstFrames[action.InvalidAction],
+ CanQueueAfter: burstFrames[action.ActionSwap], // Earliest cancel
+ State: action.BurstState,
+ }, nil
+}
+
+func (c *char) lowestHPChar() int {
+ lowestIdx := -1
+ lowestPct := 2.0 // > 1
+
+ for i := 0; i < len(c.Core.Player.Chars()); i++ {
+ ch := c.Core.Player.Chars()[i]
+ if ch == nil {
+ continue
+ }
+
+ if ch.CurrentHP() <= 0 {
+ continue
+ }
+
+ if ch.CurrentHPRatio() < lowestPct {
+ lowestPct = ch.CurrentHPRatio()
+ lowestIdx = i
+ }
+ }
+
+ return lowestIdx
+}
+
+func (c *char) absorbCheck(src, count, maxcount int) func() {
+ return func() {
+ if src != c.burstSrc {
+ return
+ }
+
+ if count == maxcount {
+ return
+ }
+
+ c.robotAi.Element = c.Core.Combat.AbsorbCheck(c.Index(), c.burstAbsorbCheckLocation, attributes.Pyro, attributes.Hydro, attributes.Electro, attributes.Cryo)
+ if c.robotAi.Element != attributes.NoElement {
+ switch c.robotAi.Element {
+ case attributes.Pyro:
+ c.robotAi.ICDTag = attacks.ICDTagElementalBurstPyro
+ case attributes.Hydro:
+ c.robotAi.ICDTag = attacks.ICDTagElementalBurstHydro
+ case attributes.Electro:
+ c.robotAi.ICDTag = attacks.ICDTagElementalBurstElectro
+ case attributes.Cryo:
+ c.robotAi.ICDTag = attacks.ICDTagElementalBurstCryo
+ }
+
+ c.Core.Log.NewEventBuildMsg(glog.LogCharacterEvent, c.Index(),
+ "jahoda burst absorbed ", c.robotAi.Element.String(),
+ )
+
+ c.c4()
+
+ for i := 0; i < burstDuration-firstrobotHitmark; i += int(c.robotHitmarkInterval) {
+ c.Core.Tasks.Add(c.robotAtkTick(src), i)
+ }
+
+ return
+ }
+ c.Core.Tasks.Add(c.absorbCheck(src, count+1, maxcount), absorptionInterval)
+ }
+}
+
+func (c *char) robotAtkTick(src int) func() {
+ return func() {
+ if src != c.burstSrc {
+ return
+ }
+
+ // For each robot, trigger an instance of damage on 3 closest enemies
+ for i := 0; i < c.robotCount; i++ {
+ c.queueOn3Closest(c.Core.Combat.Player().Pos(), c.robotAi, robotDelay*i)
+ }
+ }
+}
+
+// Helper to sort 3 closest enemies and attack them simultaneously
+func (c *char) queueOn3Closest(origin info.Point, ai info.AttackInfo, hitDelay int) {
+ enemies := c.Core.Combat.Enemies()
+ type cand struct {
+ t info.Target
+ d float64
+ }
+ cands := make([]cand, 0, len(enemies))
+
+ // Compute distance
+ for _, e := range enemies {
+ if e == nil {
+ continue
+ }
+ if e.Type() != info.TargettableEnemy {
+ continue
+ }
+ if !e.IsAlive() {
+ continue
+ }
+
+ p := e.Pos()
+ dx := p.X - origin.X
+ dy := p.Y - origin.Y
+ d := dx*dx + dy*dy // squared distance is enough for sorting (no sqrt)
+
+ cands = append(cands, cand{t: e, d: d})
+ }
+
+ // sort
+ sort.Slice(cands, func(i, j int) bool { return cands[i].d < cands[j].d })
+
+ // queue on up to 3
+ n := 3
+ if len(cands) < n {
+ n = len(cands)
+ }
+ for i := 0; i < n; i++ {
+ t := cands[i].t
+ ap := combat.NewCircleHitOnTarget(t, nil, 1.2) // Couldn't find anywhere in dm
+ c.Core.QueueAttack(ai, ap, hitDelay, hitDelay)
+ }
+}
diff --git a/internal/characters/jahoda/config.yml b/internal/characters/jahoda/config.yml
new file mode 100644
index 0000000000..973e17693a
--- /dev/null
+++ b/internal/characters/jahoda/config.yml
@@ -0,0 +1,57 @@
+package_name: jahoda
+genshin_id: 10000124
+key: jahoda
+action_param_keys:
+ attack:
+ - param: "travel"
+ aim:
+ - param: "hold"
+ - param: "travel"
+ - param: "weakspot"
+ skill:
+ - param: "travel"
+icd_groups:
+ - group_name: ICDGroupJahodaBurst
+ reset_timer: 900
+ ele_app_sequence: [1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0]
+ damage_sequence: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
+ - group_name: ICDGroupJahodaCons
+ reset_timer: 900
+ ele_app_sequence: [1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0]
+ damage_sequence: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
+icd_tags:
+ - ICDTagJahodaCons
+skill_data_mapping:
+ attack:
+ attack_1:
+ - 0 # 1-Hit DMG|{param0:F1P}
+ attack_2:
+ - 1 # 2-Hit DMG|{param1:F1P}
+ - 1 # 2-Hit DMG|{param1:F1P}
+ attack_3:
+ - 2 # 3-Hit DMG|{param2:F1P}
+ aim:
+ - 3 # Aimed Shot|{param3:F1P}
+ fullaim:
+ - 4 # Fully-Charged Aimed Shot|{param4:P}
+ skill:
+ unfilledFlask:
+ - 1 # Unfilled Treasure Flask DMG|{param1:F1P}
+ filledFlask:
+ - 2 # Filled Treasure Flask DMG|{param2:F1P}
+ meowball:
+ - 4 # Meowball DMG|{param4:F1P}
+ burst:
+ burst:
+ - 0 # Skill DMG|{param0:F1P}
+ burstSkill:
+ - 1 # Purrsonal Coordinated Assistance Robot DMG|{param1:F1P}
+ burstHealPP:
+ - 3 # Purrsonal Coordinated Assistance Robot Healing|{param3:F1P} ATK+{param4:I}
+ burstHealFlat:
+ - 4 # Purrsonal Coordinated Assistance Robot Healing|{param3:F1P} ATK+{param4:I}
+ burstAdditionalHealPP:
+ - 5 # Lowest HP Character Additional Healing|{param5:F1P} ATK+{param6:I}
+ burstAdditionalHealFlat:
+ - 6 # Lowest HP Character Additional Healing|{param5:F1P} ATK+{param6:I}
+
diff --git a/internal/characters/jahoda/cons.go b/internal/characters/jahoda/cons.go
new file mode 100644
index 0000000000..de6e400b21
--- /dev/null
+++ b/internal/characters/jahoda/cons.go
@@ -0,0 +1,52 @@
+package jahoda
+
+import (
+ "github.com/genshinsim/gcsim/pkg/core/attributes"
+ "github.com/genshinsim/gcsim/pkg/core/glog"
+ "github.com/genshinsim/gcsim/pkg/core/player/character"
+ "github.com/genshinsim/gcsim/pkg/modifier"
+)
+
+const (
+ c4Key = "jahoda-c4-flat-energy"
+ c6Key = "jahoda-c6"
+)
+
+func (c *char) c4() {
+ if c.Base.Cons < 4 {
+ return
+ }
+
+ c.AddEnergy(c4Key, 4)
+}
+
+func (c *char) c6() {
+ if c.Base.Cons < 6 {
+ return
+ }
+
+ c.c6Buff = make([]float64, attributes.EndStatType)
+ c.c6Buff = make([]float64, attributes.EndStatType)
+
+ c.c6Buff[attributes.CR] = 0.05
+ c.c6Buff[attributes.CD] = 0.40
+
+ for _, char := range c.Core.Player.Chars() {
+ char.AddStatMod(character.StatMod{
+ Base: modifier.NewBase(c6Key, 20*60),
+ AffectedStat: attributes.CR,
+ Amount: func() ([]float64, bool) {
+ if char.Moonsign < 1 {
+ return nil, false
+ }
+ return c.c6Buff, true
+ },
+ })
+
+ c.Core.Log.NewEvent("jahoda c6 triggered", glog.LogCharacterEvent, c.Index()).
+ Write("cr", c.c6Buff[attributes.CR]).
+ Write("cd", c.c6Buff[attributes.CD]).
+ Write("expiry", c.Core.F+20*60)
+
+ }
+}
diff --git a/internal/characters/jahoda/dash.go b/internal/characters/jahoda/dash.go
new file mode 100644
index 0000000000..f2f9a03e48
--- /dev/null
+++ b/internal/characters/jahoda/dash.go
@@ -0,0 +1,21 @@
+package jahoda
+
+import (
+ "github.com/genshinsim/gcsim/internal/frames"
+ "github.com/genshinsim/gcsim/pkg/core/action"
+)
+
+func init() {
+}
+
+func (c *char) Dash(p map[string]int) (action.Info, error) {
+ if c.StatusIsActive(shadowPursuitKey) {
+ c.Core.Tasks.Add(c.drainFlask(c.skillSrc), 0)
+ return action.Info{
+ Frames: frames.NewAbilFunc(skillCancelFrames),
+ AnimationLength: skillCancelFrames[action.InvalidAction],
+ CanQueueAfter: skillCancelFrames[action.ActionDash], // earliest cancel
+ }, nil
+ }
+ return c.Character.Dash(p)
+}
diff --git a/internal/characters/jahoda/data_gen.textproto b/internal/characters/jahoda/data_gen.textproto
new file mode 100644
index 0000000000..e5517626b2
--- /dev/null
+++ b/internal/characters/jahoda/data_gen.textproto
@@ -0,0 +1,151 @@
+id: 10000124
+key: "jahoda"
+rarity: QUALITY_PURPLE
+body: BODY_GIRL
+region: ASSOC_TYPE_NODKRAI
+element: Wind
+weapon_class: WEAPON_BOW
+icon_name: "UI_AvatarIcon_Jahoda"
+stats: {
+ base_hp: 808.7586
+ base_atk: 18.6984
+ base_def: 48.64125
+ hp_curve: GROW_CURVE_HP_S4
+ atk_curve: GROW_CURVE_ATTACK_S4
+ def_cruve: GROW_CURVE_HP_S4
+ promo_data: {
+ max_level: 20
+ add_props: {
+ prop_type: FIGHT_PROP_BASE_HP
+ }
+ add_props: {
+ prop_type: FIGHT_PROP_BASE_DEFENSE
+ }
+ add_props: {
+ prop_type: FIGHT_PROP_BASE_ATTACK
+ }
+ add_props: {
+ prop_type: FIGHT_PROP_HEAL_ADD
+ }
+ }
+ promo_data: {
+ max_level: 40
+ add_props: {
+ prop_type: FIGHT_PROP_BASE_HP
+ value: 604.1841
+ }
+ add_props: {
+ prop_type: FIGHT_PROP_BASE_DEFENSE
+ value: 36.3375
+ }
+ add_props: {
+ prop_type: FIGHT_PROP_BASE_ATTACK
+ value: 13.96899
+ }
+ add_props: {
+ prop_type: FIGHT_PROP_HEAL_ADD
+ }
+ }
+ promo_data: {
+ max_level: 50
+ add_props: {
+ prop_type: FIGHT_PROP_BASE_HP
+ value: 1033.4728
+ }
+ add_props: {
+ prop_type: FIGHT_PROP_BASE_DEFENSE
+ value: 62.15625
+ }
+ add_props: {
+ prop_type: FIGHT_PROP_BASE_ATTACK
+ value: 23.894325
+ }
+ add_props: {
+ prop_type: FIGHT_PROP_HEAL_ADD
+ value: 0.0462
+ }
+ }
+ promo_data: {
+ max_level: 60
+ add_props: {
+ prop_type: FIGHT_PROP_BASE_HP
+ value: 1605.8577
+ }
+ add_props: {
+ prop_type: FIGHT_PROP_BASE_DEFENSE
+ value: 96.58125
+ }
+ add_props: {
+ prop_type: FIGHT_PROP_BASE_ATTACK
+ value: 37.128105
+ }
+ add_props: {
+ prop_type: FIGHT_PROP_HEAL_ADD
+ value: 0.0923
+ }
+ }
+ promo_data: {
+ max_level: 70
+ add_props: {
+ prop_type: FIGHT_PROP_BASE_HP
+ value: 2035.1465
+ }
+ add_props: {
+ prop_type: FIGHT_PROP_BASE_DEFENSE
+ value: 122.4
+ }
+ add_props: {
+ prop_type: FIGHT_PROP_BASE_ATTACK
+ value: 47.05344
+ }
+ add_props: {
+ prop_type: FIGHT_PROP_HEAL_ADD
+ value: 0.0923
+ }
+ }
+ promo_data: {
+ max_level: 80
+ add_props: {
+ prop_type: FIGHT_PROP_BASE_HP
+ value: 2464.435
+ }
+ add_props: {
+ prop_type: FIGHT_PROP_BASE_DEFENSE
+ value: 148.21875
+ }
+ add_props: {
+ prop_type: FIGHT_PROP_BASE_ATTACK
+ value: 56.978775
+ }
+ add_props: {
+ prop_type: FIGHT_PROP_HEAL_ADD
+ value: 0.1385
+ }
+ }
+ promo_data: {
+ max_level: 90
+ add_props: {
+ prop_type: FIGHT_PROP_BASE_HP
+ value: 2893.7239
+ }
+ add_props: {
+ prop_type: FIGHT_PROP_BASE_DEFENSE
+ value: 174.0375
+ }
+ add_props: {
+ prop_type: FIGHT_PROP_BASE_ATTACK
+ value: 66.90411
+ }
+ add_props: {
+ prop_type: FIGHT_PROP_HEAL_ADD
+ value: 0.1846
+ }
+ }
+}
+skill_details: {
+ skill: 11242
+ burst: 11245
+ attack: 11241
+ burst_energy_cost: 70
+}
+name_text_hash_map: 1438964522
diff --git a/internal/characters/jahoda/jahoda.go b/internal/characters/jahoda/jahoda.go
new file mode 100644
index 0000000000..e056f05b1a
--- /dev/null
+++ b/internal/characters/jahoda/jahoda.go
@@ -0,0 +1,79 @@
+package jahoda
+
+import (
+ tmpl "github.com/genshinsim/gcsim/internal/template/character"
+ "github.com/genshinsim/gcsim/pkg/core"
+ "github.com/genshinsim/gcsim/pkg/core/attributes"
+ "github.com/genshinsim/gcsim/pkg/core/info"
+ "github.com/genshinsim/gcsim/pkg/core/keys"
+ "github.com/genshinsim/gcsim/pkg/core/player/character"
+)
+
+func init() {
+ core.RegisterCharFunc(keys.Jahoda, NewChar)
+}
+
+type char struct {
+ *tmpl.Character
+ flaskAbsorbCheckLocation info.AttackPattern
+ flaskAbsorb attributes.Element
+ flaskGauge int
+ flaskGaugeMax int
+ pursuitDuration int
+ skillSrc int
+ skillTravel int
+ burstAbsorbCheckLocation info.AttackPattern
+ burstSrc int
+ a1HighestEle attributes.Element
+ robotAi info.AttackInfo
+ robotHi info.HealInfo
+ robotCount int
+ robotHitmarkInterval float64
+ c2NextHighestEle attributes.Element
+ a4Buff []float64
+ c6Buff []float64
+}
+
+func NewChar(s *core.Core, w *character.CharWrapper, _ info.CharacterProfile) error {
+ c := char{}
+ c.Character = tmpl.NewWithWrapper(s, w)
+
+ c.EnergyMax = 70
+ c.NormalHitNum = normalHitNum
+ c.BurstCon = 3
+ c.SkillCon = 5
+
+ c.Moonsign = 1
+ c.HasArkhe = false
+
+ c.flaskAbsorb = attributes.NoElement
+ c.flaskGauge = 0
+ c.flaskGaugeMax = 100
+
+ c.a1HighestEle = attributes.NoElement
+
+ c.c2NextHighestEle = attributes.NoElement
+
+ w.Character = &c
+
+ return nil
+}
+
+func (c *char) Init() error {
+ c.a1Init()
+
+ c.a4Buff = make([]float64, attributes.EndStatType)
+ c.a4Buff[attributes.EM] = 100
+
+ return nil
+}
+
+func (c *char) AnimationStartDelay(k info.AnimationDelayKey) int {
+ if k == info.AnimationXingqiuN0StartDelay {
+ return 13
+ }
+ if k == info.AnimationYelanN0StartDelay {
+ return 11
+ }
+ return c.Character.AnimationStartDelay(k)
+}
diff --git a/internal/characters/jahoda/jahoda_gen.go b/internal/characters/jahoda/jahoda_gen.go
new file mode 100644
index 0000000000..be38b38d41
--- /dev/null
+++ b/internal/characters/jahoda/jahoda_gen.go
@@ -0,0 +1,332 @@
+// Code generated by "pipeline"; DO NOT EDIT.
+package jahoda
+
+import (
+ _ "embed"
+
+ "fmt"
+ "slices"
+
+ "github.com/genshinsim/gcsim/pkg/core/action"
+ "github.com/genshinsim/gcsim/pkg/core/keys"
+ "github.com/genshinsim/gcsim/pkg/gcs/validation"
+ "github.com/genshinsim/gcsim/pkg/model"
+ "google.golang.org/protobuf/encoding/prototext"
+)
+
+//go:embed data_gen.textproto
+var pbData []byte
+var base *model.AvatarData
+var paramKeysValidation = map[action.Action][]string{
+ 1: {"travel"},
+ 3: {"travel"},
+ 7: {"hold", "travel", "weakspot"},
+}
+
+func init() {
+ base = &model.AvatarData{}
+ err := prototext.Unmarshal(pbData, base)
+ if err != nil {
+ panic(err)
+ }
+ validation.RegisterCharParamValidationFunc(keys.Jahoda, ValidateParamKeys)
+}
+
+func ValidateParamKeys(a action.Action, keys []string) error {
+ valid, ok := paramKeysValidation[a]
+ if !ok {
+ return nil
+ }
+ for _, v := range keys {
+ if !slices.Contains(valid, v) {
+ return fmt.Errorf("key %v is invalid for action %v", v, a.String())
+ }
+ }
+ return nil
+}
+
+func (x *char) Data() *model.AvatarData {
+ return base
+}
+
+var (
+ attack = [][][]float64{
+ {attack_1},
+ attack_2,
+ {attack_3},
+ }
+)
+
+var (
+ // attack: aim = [3]
+ aim = []float64{
+ 0.4386,
+ 0.4743,
+ 0.51,
+ 0.561,
+ 0.5967,
+ 0.6375,
+ 0.6936,
+ 0.7497,
+ 0.8058,
+ 0.867,
+ 0.9282,
+ 0.9894,
+ 1.0506,
+ 1.1118,
+ 1.173,
+ }
+ // attack: attack_1 = [0]
+ attack_1 = []float64{
+ 0.416739,
+ 0.450659,
+ 0.48458,
+ 0.533038,
+ 0.566959,
+ 0.605725,
+ 0.659029,
+ 0.712333,
+ 0.765636,
+ 0.823786,
+ 0.881936,
+ 0.940085,
+ 0.998235,
+ 1.056384,
+ 1.114534,
+ }
+ // attack: attack_2 = [1 1]
+ attack_2 = [][]float64{
+ {
+ 0.192313,
+ 0.207967,
+ 0.22362,
+ 0.245982,
+ 0.261635,
+ 0.279525,
+ 0.304123,
+ 0.328721,
+ 0.35332,
+ 0.380154,
+ 0.406988,
+ 0.433823,
+ 0.460657,
+ 0.487492,
+ 0.514326,
+ },
+ {
+ 0.192313,
+ 0.207967,
+ 0.22362,
+ 0.245982,
+ 0.261635,
+ 0.279525,
+ 0.304123,
+ 0.328721,
+ 0.35332,
+ 0.380154,
+ 0.406988,
+ 0.433823,
+ 0.460657,
+ 0.487492,
+ 0.514326,
+ },
+ }
+ // attack: attack_3 = [2]
+ attack_3 = []float64{
+ 0.511975,
+ 0.553648,
+ 0.59532,
+ 0.654852,
+ 0.696524,
+ 0.74415,
+ 0.809635,
+ 0.87512,
+ 0.940606,
+ 1.012044,
+ 1.083482,
+ 1.154921,
+ 1.226359,
+ 1.297798,
+ 1.369236,
+ }
+ // attack: fullaim = [4]
+ fullaim = []float64{
+ 1.24,
+ 1.333,
+ 1.426,
+ 1.55,
+ 1.643,
+ 1.736,
+ 1.86,
+ 1.984,
+ 2.108,
+ 2.232,
+ 2.356,
+ 2.48,
+ 2.635,
+ 2.79,
+ 2.945,
+ }
+ // skill: filledFlask = [2]
+ filledFlask = []float64{
+ 2.12,
+ 2.279,
+ 2.438,
+ 2.65,
+ 2.809,
+ 2.968,
+ 3.18,
+ 3.392,
+ 3.604,
+ 3.816,
+ 4.028,
+ 4.24,
+ 4.505,
+ 4.77,
+ 5.035,
+ }
+ // skill: meowball = [4]
+ meowball = []float64{
+ 1.28,
+ 1.376,
+ 1.472,
+ 1.6,
+ 1.696,
+ 1.792,
+ 1.92,
+ 2.048,
+ 2.176,
+ 2.304,
+ 2.432,
+ 2.56,
+ 2.72,
+ 2.88,
+ 3.04,
+ }
+ // skill: unfilledFlask = [1]
+ unfilledFlask = []float64{
+ 1.908,
+ 2.0511,
+ 2.1942,
+ 2.385,
+ 2.5281,
+ 2.6712,
+ 2.862,
+ 3.0528,
+ 3.2436,
+ 3.4344,
+ 3.6252,
+ 3.816,
+ 4.0545,
+ 4.293,
+ 4.5315,
+ }
+ // burst: burst = [0]
+ burst = []float64{
+ 2.072,
+ 2.2274,
+ 2.3828,
+ 2.59,
+ 2.7454,
+ 2.9008,
+ 3.108,
+ 3.3152,
+ 3.5224,
+ 3.7296,
+ 3.9368,
+ 4.144,
+ 4.403,
+ 4.662,
+ 4.921,
+ }
+ // burst: burstAdditionalHealFlat = [6]
+ burstAdditionalHealFlat = []float64{
+ 192.59721,
+ 211.8596,
+ 232.7272,
+ 255.2,
+ 279.27798,
+ 304.96118,
+ 332.2496,
+ 361.1432,
+ 391.642,
+ 423.74597,
+ 457.45517,
+ 492.76956,
+ 529.68915,
+ 568.214,
+ 608.34393,
+ }
+ // burst: burstAdditionalHealPP = [5]
+ burstAdditionalHealPP = []float64{
+ 0.3072,
+ 0.33024,
+ 0.35328,
+ 0.384,
+ 0.40704,
+ 0.43008,
+ 0.4608,
+ 0.49152,
+ 0.52224,
+ 0.55296,
+ 0.58368,
+ 0.6144,
+ 0.6528,
+ 0.6912,
+ 0.7296,
+ }
+ // burst: burstHealFlat = [4]
+ burstHealFlat = []float64{
+ 500.73764,
+ 550.81836,
+ 605.0725,
+ 663.5,
+ 726.1009,
+ 792.8752,
+ 863.8229,
+ 938.944,
+ 1018.23846,
+ 1101.7063,
+ 1189.3477,
+ 1281.1622,
+ 1377.1503,
+ 1477.3118,
+ 1581.6466,
+ }
+ // burst: burstHealPP = [3]
+ burstHealPP = []float64{
+ 0.79872,
+ 0.858624,
+ 0.918528,
+ 0.9984,
+ 1.058304,
+ 1.118208,
+ 1.19808,
+ 1.277952,
+ 1.357824,
+ 1.437696,
+ 1.517568,
+ 1.59744,
+ 1.69728,
+ 1.79712,
+ 1.89696,
+ }
+ // burst: burstSkill = [1]
+ burstSkill = []float64{
+ 0.172664,
+ 0.185614,
+ 0.198564,
+ 0.21583,
+ 0.22878,
+ 0.24173,
+ 0.258996,
+ 0.276262,
+ 0.293529,
+ 0.310795,
+ 0.328062,
+ 0.345328,
+ 0.366911,
+ 0.388494,
+ 0.410077,
+ }
+)
diff --git a/internal/characters/jahoda/jump.go b/internal/characters/jahoda/jump.go
new file mode 100644
index 0000000000..9dfbe14ad1
--- /dev/null
+++ b/internal/characters/jahoda/jump.go
@@ -0,0 +1,22 @@
+package jahoda
+
+import (
+ "github.com/genshinsim/gcsim/internal/frames"
+ "github.com/genshinsim/gcsim/pkg/core/action"
+)
+
+func init() {
+}
+
+func (c *char) Jump(p map[string]int) (action.Info, error) {
+ if c.StatusIsActive(shadowPursuitKey) {
+ c.Core.Tasks.Add(c.drainFlask(c.skillSrc), 0)
+ return action.Info{
+ Frames: frames.NewAbilFunc(skillCancelFrames),
+ AnimationLength: skillCancelFrames[action.InvalidAction],
+ CanQueueAfter: skillCancelFrames[action.ActionDash], // earliest cancel
+ State: action.SkillState,
+ }, nil
+ }
+ return c.Character.Jump(p)
+}
diff --git a/internal/characters/jahoda/skill.go b/internal/characters/jahoda/skill.go
new file mode 100644
index 0000000000..82cbf7cefc
--- /dev/null
+++ b/internal/characters/jahoda/skill.go
@@ -0,0 +1,300 @@
+package jahoda
+
+import (
+ "math"
+
+ "github.com/genshinsim/gcsim/internal/frames"
+ "github.com/genshinsim/gcsim/pkg/core/action"
+ "github.com/genshinsim/gcsim/pkg/core/attacks"
+ "github.com/genshinsim/gcsim/pkg/core/attributes"
+ "github.com/genshinsim/gcsim/pkg/core/combat"
+ "github.com/genshinsim/gcsim/pkg/core/glog"
+ "github.com/genshinsim/gcsim/pkg/core/info"
+)
+
+var (
+ skillFrames []int
+ skillCancelFrames []int
+)
+
+const (
+ skillWindup = 30
+ shadowPursuitMaxDuration = 334
+ firstFillFlaskDelay = 19
+ fillFlaskInterval = 29
+ drainFlask = 22
+ unfillHitmark = 4
+ fillHitmark = 2
+ firstMeowballFirstHitmark = 129
+ meowballHitmarkInterval = 116
+ skillCD = 15 * 60
+ c1BounceHitmark = 32
+ shadowPursuitKey = "jahoda-shadow-pursuit"
+ meowballKey = "jahoda-meowball"
+ meowballFlatEnergyKey = "jahoda-meowball-flat-energy"
+ meowballFlatEnergyICDKey = "jahoda-meowball-flat-energy-icd"
+ particleICDKey = "jahoda-particle-icd"
+)
+
+func init() {
+ skillFrames = frames.InitAbilSlice(12 + skillWindup) // E -> E
+
+ skillCancelFrames = frames.InitAbilSlice(43) // E -> E -> N1
+ skillCancelFrames[action.ActionAim] = 42 // E -> E -> Aim
+ skillCancelFrames[action.ActionBurst] = 42 // E -> E -> Q
+ skillCancelFrames[action.ActionDash] = 41 // E -> E -> D
+ skillCancelFrames[action.ActionJump] = 48 // E -> E -> J
+ skillCancelFrames[action.ActionWalk] = 44 // E -> E -> W
+ skillCancelFrames[action.ActionSwap] = 41 // E -> E -> Swap
+}
+
+func (c *char) Skill(p map[string]int) (action.Info, error) {
+ if c.StatusIsActive(shadowPursuitKey) {
+ c.Core.Tasks.Add(c.drainFlask(c.skillSrc), 0)
+ return action.Info{
+ Frames: frames.NewAbilFunc(skillCancelFrames),
+ AnimationLength: skillCancelFrames[action.InvalidAction],
+ CanQueueAfter: skillCancelFrames[action.ActionDash], // earliest cancel
+ State: action.SkillState,
+ }, nil
+ }
+
+ c.Core.Player.SwapCD = math.MaxInt16
+ travel, ok := p["travel"]
+ if !ok {
+ travel = 13
+ }
+
+ c.skillTravel = travel
+
+ c.skillSrc = c.Core.F
+ c.flaskAbsorb = attributes.NoElement
+ c.flaskGauge = 0
+ c.flaskGaugeMax = 100
+ c.flaskAbsorbCheckLocation = combat.NewCircleHitOnTarget(c.Core.Combat.Player(), nil, 4)
+
+ // Enter shadow pursuit
+ c.pursuitDuration = shadowPursuitMaxDuration
+ c.Core.Tasks.Add(func() {
+ c.AddStatus(shadowPursuitKey, shadowPursuitMaxDuration, false)
+ c.Core.Player.SwapCD = math.MaxInt16
+ c.Core.Tasks.Add(
+ c.fillFlask(c.skillSrc),
+ firstFillFlaskDelay,
+ )
+ }, skillWindup)
+
+ return action.Info{
+ Frames: frames.NewAbilFunc(skillFrames),
+ AnimationLength: skillFrames[action.InvalidAction],
+ CanQueueAfter: skillFrames[action.ActionSkill],
+ State: action.SkillState,
+ }, nil
+}
+
+func (c *char) ParticleCB(a info.AttackCB) {
+ if a.Target.Type() != info.TargettableEnemy {
+ return
+ }
+ if c.StatusIsActive(particleICDKey) {
+ return
+ }
+ c.AddStatus(particleICDKey, 0.5*60, false) // Couldn't find anywhere in dm, assume to be the same as Sayu
+ c.Core.QueueParticle(c.Base.Key.String(), 4, attributes.Anemo, c.ParticleDelay)
+}
+
+func (c *char) meowballEnergyCB(a info.AttackCB) {
+ if a.Target.Type() != info.TargettableEnemy {
+ return
+ }
+
+ if c.StatusIsActive(meowballFlatEnergyICDKey) {
+ return
+ }
+
+ c.AddEnergy(meowballFlatEnergyKey, 2)
+ c.AddStatus(meowballFlatEnergyICDKey, int(3.5*60), true)
+}
+
+func (c *char) fillFlask(src int) func() {
+ return func() {
+ if src != c.skillSrc {
+ return
+ }
+
+ c.pursuitDuration = c.Core.F - c.skillSrc
+
+ // If the flask is full OR the max duration of the state is reached, drain the flask
+ if c.flaskGauge >= c.flaskGaugeMax || !c.StatusIsActive(shadowPursuitKey) || c.Core.F >= shadowPursuitMaxDuration+c.skillSrc {
+ c.Core.Tasks.Add(c.drainFlask(c.skillSrc), 0)
+ return
+ }
+
+ // Check elemental aura in the area
+ objectElem := c.Core.Combat.AbsorbCheck(c.Index(), c.flaskAbsorbCheckLocation, attributes.Pyro, attributes.Hydro, attributes.Electro, attributes.Cryo)
+ if objectElem != attributes.NoElement {
+ if c.flaskAbsorb == attributes.NoElement {
+ // If there are an element to absorb AND the flask has not absorbed any elements, the flask absorb that element and increase its gauge
+ c.flaskAbsorb = objectElem
+ c.Core.Tasks.Add(c.changeFlaskGauge(c.flaskGaugeMax/4), 0)
+ } else if objectElem == c.flaskAbsorb {
+ // Else if there are an element to absorb AND if that element is the same as that the flask has absorbed the flask absorb that element
+ // and increase its gauge
+ c.Core.Tasks.Add(c.changeFlaskGauge(c.flaskGaugeMax/4), 0)
+ }
+ // Otherwise nothing happend since the flask will not change its elemental absorption mid way
+
+ c.Core.Log.NewEventBuildMsg(glog.LogCharacterEvent, c.Index(),
+ "jahoda flask absorbed ", c.flaskAbsorb.String(),
+ )
+ }
+ c.Core.Tasks.Add(c.fillFlask(c.skillSrc), fillFlaskInterval)
+ }
+}
+
+func (c *char) changeFlaskGauge(amount int) func() {
+ return func() {
+ prevFlaskGauge := c.flaskGauge
+ c.flaskGauge += amount
+ c.Core.Log.NewEvent("Flask Gauge Change", glog.LogCharacterEvent, c.Index()).
+ Write("previous flask gauge", prevFlaskGauge).
+ Write("current flask gauge", c.flaskGauge)
+ }
+}
+
+func (c *char) drainFlask(src int) func() {
+ return func() {
+ if src != c.skillSrc {
+ return
+ }
+
+ c.cancelPursuit() // Exit state
+
+ if c.flaskGauge >= c.flaskGaugeMax {
+ c.flaskGauge = c.flaskGaugeMax
+
+ // If the flask is full, do filled flask damage
+ ai := info.AttackInfo{
+ ActorIndex: c.Index(),
+ Abil: "Filled Treasure Flask",
+ AttackTag: attacks.AttackTagElementalArt,
+ ICDTag: attacks.ICDTagElementalArt,
+ ICDGroup: attacks.ICDGroupDefault,
+ StrikeType: attacks.StrikeTypeDefault,
+ Element: attributes.Anemo,
+ Durability: 25,
+ Mult: filledFlask[c.TalentLvlSkill()],
+ }
+
+ c.Core.QueueAttack(ai, combat.NewCircleHitOnTarget(c.Core.Combat.PrimaryTarget(), info.Point{Y: 2.5}, 5), 0, drainFlask+fillHitmark, c.ParticleCB)
+
+ // If in Ascendent Gleam, do meowball damage
+ if c.Core.Player.GetMoonsignLevel() >= 2 {
+ c.c6() // Apply buff from C6
+
+ ticks := c.flaskGaugeMax / 10
+
+ for i := range ticks {
+ c.Core.Tasks.Add(
+ c.meowballTick(c.skillSrc),
+ firstMeowballFirstHitmark+i*meowballHitmarkInterval,
+ )
+ c.Core.Tasks.Add(c.changeFlaskGauge(-10), firstMeowballFirstHitmark+i*meowballHitmarkInterval)
+ }
+
+ c.AddStatus(
+ meowballKey,
+ firstMeowballFirstHitmark+(ticks-1)*meowballHitmarkInterval,
+ false,
+ )
+ }
+
+ } else {
+ // If the flask is not full (early cancel or no furation expired), do unfill damage
+ ai := info.AttackInfo{
+ ActorIndex: c.Index(),
+ Abil: "Unfilled Treasure Flask",
+ AttackTag: attacks.AttackTagElementalArt,
+ ICDTag: attacks.ICDTagElementalArt,
+ ICDGroup: attacks.ICDGroupDefault,
+ StrikeType: attacks.StrikeTypeDefault,
+ Element: attributes.Anemo,
+ Durability: 25,
+ Mult: unfilledFlask[c.TalentLvlSkill()],
+ }
+
+ c.Core.QueueAttack(ai, combat.NewCircleHitOnTarget(c.Core.Combat.PrimaryTarget(), info.Point{Y: 2.5}, 5), 0, unfillHitmark, c.ParticleCB)
+ }
+ }
+}
+
+func (c *char) cancelPursuit() {
+ if !c.StatusIsActive(shadowPursuitKey) {
+ return
+ }
+ c.SetCD(action.ActionSkill, skillCD+skillWindup)
+ c.DeleteStatus(shadowPursuitKey)
+ c.Core.Player.SwapCD = skillCancelFrames[action.ActionSwap]
+ c.skillSrc = -1
+}
+
+func (c *char) meowballTick(src int) func() {
+ return func() {
+ if src != c.skillSrc {
+ return
+ }
+
+ if c.flaskGauge <= 0 {
+ return
+ }
+
+ if c.flaskGauge < 0 {
+ c.flaskGauge = 0
+ }
+
+ ai := info.AttackInfo{
+ ActorIndex: c.Index(),
+ Abil: "Meowball",
+ AttackTag: attacks.AttackTagElementalArt,
+ ICDTag: attacks.ICDTagNone,
+ ICDGroup: attacks.ICDGroupDefault,
+ StrikeType: attacks.StrikeTypeDefault,
+ Element: c.flaskAbsorb,
+ Durability: 25,
+ Mult: meowball[c.TalentLvlSkill()],
+ }
+
+ c.Core.QueueAttack(
+ ai,
+ combat.NewCircleHitOnTarget(c.Core.Combat.PrimaryTarget(), nil, 4),
+ 0,
+ c.skillTravel,
+ c.meowballEnergyCB,
+ )
+
+ // 50% hit twice
+ if c.Base.Cons >= 1 {
+ if c.Core.Rand.Float64() < 0.5 {
+ aiC1 := info.AttackInfo{
+ ActorIndex: c.Index(),
+ Abil: "Meowball (C1)",
+ AttackTag: attacks.AttackTagElementalArt,
+ ICDTag: attacks.ICDTagJahodaCons,
+ ICDGroup: attacks.ICDGroupJahodaCons,
+ StrikeType: attacks.StrikeTypeDefault,
+ Element: c.flaskAbsorb,
+ Durability: 25,
+ Mult: meowball[c.TalentLvlSkill()],
+ }
+
+ c.Core.QueueAttack(
+ aiC1,
+ combat.NewCircleHitOnTarget(c.Core.Combat.PrimaryTarget(), nil, 4),
+ 0,
+ c.skillTravel+c1BounceHitmark,
+ nil,
+ )
+ }
+ }
+ }
+}
diff --git a/internal/services/assets/avatars_gen.go b/internal/services/assets/avatars_gen.go
index 65ec479d6d..a17a506a37 100644
--- a/internal/services/assets/avatars_gen.go
+++ b/internal/services/assets/avatars_gen.go
@@ -41,6 +41,7 @@ var avatarMap = map[string]string{
"heizou": "UI_AvatarIcon_Heizo",
"hutao": "UI_AvatarIcon_Hutao",
"itto": "UI_AvatarIcon_Itto",
+ "jahoda": "UI_AvatarIcon_Jahoda",
"jean": "UI_AvatarIcon_Qin",
"kaeya": "UI_AvatarIcon_Kaeya",
"kaveh": "UI_AvatarIcon_Kaveh",
diff --git a/pkg/core/attacks/icd_groups_gen.go b/pkg/core/attacks/icd_groups_gen.go
index 56e9656c71..bce366e81c 100644
--- a/pkg/core/attacks/icd_groups_gen.go
+++ b/pkg/core/attacks/icd_groups_gen.go
@@ -27,6 +27,8 @@ const (
ICDGroupEscoffierSkill
ICDGroupFischl
ICDGroupFurinaSalonSolitaire
+ ICDGroupJahodaBurst
+ ICDGroupJahodaCons
ICDGroupKinichLoopShot
ICDGroupKinichScalespikerCannon
ICDGroupLanyanRingAttack
@@ -150,6 +152,14 @@ func init() {
ICDGroupEleApplicationSequence[ICDGroupFurinaSalonSolitaire] = []float64{1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0}
ICDGroupDamageSequence[ICDGroupFurinaSalonSolitaire] = []float64{1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1}
+ ICDGroupResetTimer[ICDGroupJahodaBurst] = 900
+ ICDGroupEleApplicationSequence[ICDGroupJahodaBurst] = []float64{1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0}
+ ICDGroupDamageSequence[ICDGroupJahodaBurst] = []float64{1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1}
+
+ ICDGroupResetTimer[ICDGroupJahodaCons] = 900
+ ICDGroupEleApplicationSequence[ICDGroupJahodaCons] = []float64{1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0}
+ ICDGroupDamageSequence[ICDGroupJahodaCons] = []float64{1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1}
+
ICDGroupResetTimer[ICDGroupKinichLoopShot] = 120
ICDGroupEleApplicationSequence[ICDGroupKinichLoopShot] = []float64{1, 0, 0, 0}
ICDGroupDamageSequence[ICDGroupKinichLoopShot] = []float64{1, 1, 1, 1, 1, 1, 1, 1}
diff --git a/pkg/core/attacks/icd_tags_gen.go b/pkg/core/attacks/icd_tags_gen.go
index 179b7a2196..d1dcfcb747 100644
--- a/pkg/core/attacks/icd_tags_gen.go
+++ b/pkg/core/attacks/icd_tags_gen.go
@@ -19,6 +19,7 @@ const (
ICDTagEmilieLumidouce
ICDTagFurinaChevalmarin
ICDTagFurinaUsher
+ ICDTagJahodaCons
ICDTagKinichLoopShot
ICDTagKinichScalespikerCannon
ICDTagKleeFireDamage
diff --git a/pkg/core/keys/keys_char_gen.go b/pkg/core/keys/keys_char_gen.go
index 34db090fc4..42c868136c 100644
--- a/pkg/core/keys/keys_char_gen.go
+++ b/pkg/core/keys/keys_char_gen.go
@@ -46,6 +46,7 @@ const (
Heizou
Hutao
Itto
+ Jahoda
Jean
Kaeya
Kaveh
@@ -264,6 +265,10 @@ func init() {
charPrettyName[Itto] = "Itto"
CharKeyToEle[Itto] = attributes.Geo
+ charNames[Jahoda] = "jahoda"
+ charPrettyName[Jahoda] = "Jahoda"
+ CharKeyToEle[Jahoda] = attributes.Anemo
+
charNames[Jean] = "jean"
charPrettyName[Jean] = "Jean"
CharKeyToEle[Jean] = attributes.Anemo
diff --git a/pkg/core/player/player.go b/pkg/core/player/player.go
index 01d6797a94..850f8a03cb 100644
--- a/pkg/core/player/player.go
+++ b/pkg/core/player/player.go
@@ -342,3 +342,11 @@ func (h *Handler) Airborne() AirborneSource {
const (
XianyunAirborneBuff = "xianyun-airborne-buff"
)
+
+func (h *Handler) GetMoonsignLevel() int {
+ count := 0
+ for _, c := range h.Chars() {
+ count += c.Moonsign
+ }
+ return count
+}
diff --git a/pkg/shortcut/characters.go b/pkg/shortcut/characters.go
index ceaf33645e..de785c660b 100644
--- a/pkg/shortcut/characters.go
+++ b/pkg/shortcut/characters.go
@@ -62,6 +62,7 @@ var CharNameToKey = map[string]keys.Char{
"hutao": keys.Hutao,
"tao": keys.Hutao,
"ht": keys.Hutao,
+ "jahoda": keys.Jahoda,
"jean": keys.Jean,
"kaedeharakazuha": keys.Kazuha,
"kazuha": keys.Kazuha,
diff --git a/pkg/simulation/imports_char_gen.go b/pkg/simulation/imports_char_gen.go
index c3d36ea340..4f04b772f0 100644
--- a/pkg/simulation/imports_char_gen.go
+++ b/pkg/simulation/imports_char_gen.go
@@ -41,6 +41,7 @@ import (
_ "github.com/genshinsim/gcsim/internal/characters/heizou"
_ "github.com/genshinsim/gcsim/internal/characters/hutao"
_ "github.com/genshinsim/gcsim/internal/characters/itto"
+ _ "github.com/genshinsim/gcsim/internal/characters/jahoda"
_ "github.com/genshinsim/gcsim/internal/characters/jean"
_ "github.com/genshinsim/gcsim/internal/characters/kaeya"
_ "github.com/genshinsim/gcsim/internal/characters/kaveh"
diff --git a/ui/packages/components/src/Editor/mode-gcsim.js b/ui/packages/components/src/Editor/mode-gcsim.js
index f100edc3ff..3cc3f4b65c 100644
--- a/ui/packages/components/src/Editor/mode-gcsim.js
+++ b/ui/packages/components/src/Editor/mode-gcsim.js
@@ -148,6 +148,7 @@ ace.define(
'ht',
'hutao',
'itto',
+ 'jahoda',
'jean',
'kabukimono',
'kaedeharakazuha',
diff --git a/ui/packages/db/src/Data/char_data.generated.json b/ui/packages/db/src/Data/char_data.generated.json
index b002a6625f..6c3a806d2b 100644
--- a/ui/packages/db/src/Data/char_data.generated.json
+++ b/ui/packages/db/src/Data/char_data.generated.json
@@ -771,6 +771,23 @@
},
"name_text_hash_map ": "3068316954"
},
+ "jahoda": {
+ "id": 10000124,
+ "key": "jahoda",
+ "rarity": "QUALITY_PURPLE",
+ "body": "BODY_GIRL",
+ "region": "ASSOC_TYPE_NODKRAI",
+ "element": "Wind",
+ "weapon_class": "WEAPON_BOW",
+ "icon_name": "UI_AvatarIcon_Jahoda",
+ "skill_details": {
+ "skill": 11242,
+ "burst": 11245,
+ "attack": 11241,
+ "burst_energy_cost": 70
+ },
+ "name_text_hash_map ": "1438964522"
+ },
"jean": {
"id": 10000003,
"key": "jean",
diff --git a/ui/packages/docs/docs/reference/characters/jahoda.md b/ui/packages/docs/docs/reference/characters/jahoda.md
new file mode 100644
index 0000000000..cc8a6d087c
--- /dev/null
+++ b/ui/packages/docs/docs/reference/characters/jahoda.md
@@ -0,0 +1,44 @@
+---
+title: Jahoda
+---
+
+import HitlagTable from "@site/src/components/Hitlag/HitlagTable";
+import FieldsTable from "@site/src/components/Fields/FieldsTable";
+import ParamsTable from "@site/src/components/Params/ParamsTable";
+import FramesTable from "@site/src/components/Frames/FramesTable";
+import IssuesTable from "@site/src/components/Issues/IssuesTable";
+import AoETable from "@site/src/components/AoE/AoETable";
+import NamesList from "@site/src/components/Names/NamesList";
+import ActionsTable from "@site/src/components/Actions/ActionsTable";
+
+## Frames
+
+
+
+## Hitlag Data
+
+
+
+## AoE Data
+
+
+
+## Known issues
+
+
+
+## Names
+
+
+
+## Legal Actions
+
+
+
+## Params
+
+
+
+## Fields
+
+
diff --git a/ui/packages/docs/src/components/Actions/character_data.json b/ui/packages/docs/src/components/Actions/character_data.json
index dd59a9cb0c..a24ff070b8 100644
--- a/ui/packages/docs/src/components/Actions/character_data.json
+++ b/ui/packages/docs/src/components/Actions/character_data.json
@@ -1355,6 +1355,40 @@
"legal": true
}
],
+ "jahoda": [
+ {
+ "ability": "attack",
+ "legal": true
+ },
+ {
+ "ability": "aim",
+ "legal": true
+ },
+ {
+ "ability": "skill",
+ "legal": true
+ },
+ {
+ "ability": "burst",
+ "notes": "Burst in Shadow Pursuit state is not legal."
+ },
+ {
+ "ability": "dash",
+ "legal": true
+ },
+ {
+ "ability": "jump",
+ "legal": true
+ },
+ {
+ "ability": "walk",
+ "legal": true
+ },
+ {
+ "ability": "swap",
+ "notes": "Swapping in Shadow Pursuit state is not legal."
+ }
+ ],
"jean": [
{
"ability": "attack",
diff --git a/ui/packages/docs/src/components/AoE/character_data.json b/ui/packages/docs/src/components/AoE/character_data.json
index 082345319f..8e0a448f9c 100644
--- a/ui/packages/docs/src/components/AoE/character_data.json
+++ b/ui/packages/docs/src/components/AoE/character_data.json
@@ -4436,6 +4436,135 @@
}
]
},
+ "jahoda": {
+ "cons": [
+ {
+ "ability": "C1-Bounce",
+ "shape": "Circle",
+ "center": "PrimaryTarget",
+ "radius": 4
+ }
+ ],
+ "normal": [
+ {
+ "ability": "N1",
+ "shape": "Box",
+ "center": "PrimaryTarget",
+ "offsetY": -0.5,
+ "boxX": 0.1,
+ "boxY": 1
+ },
+ {
+ "ability": "N2-1",
+ "shape": "Box",
+ "center": "PrimaryTarget",
+ "offsetY": -0.5,
+ "boxX": 0.1,
+ "boxY": 1
+ },
+ {
+ "ability": "N2-2",
+ "shape": "Box",
+ "center": "PrimaryTarget",
+ "offsetY": -0.5,
+ "boxX": 0.1,
+ "boxY": 1
+ },
+ {
+ "ability": "N3",
+ "shape": "Box",
+ "center": "PrimaryTarget",
+ "offsetY": -0.5,
+ "boxX": 0.1,
+ "boxY": 1
+ }
+ ],
+ "aim": [
+ {
+ "ability": "Aim",
+ "shape": "Box",
+ "center": "PrimaryTarget",
+ "offsetY": -0.5,
+ "boxX": 0.1,
+ "boxY": 1.0
+ },
+ {
+ "ability": "Aim-Head",
+ "shape": "Box",
+ "center": "PrimaryTarget",
+ "offsetY": -0.5,
+ "boxX": 0.1,
+ "boxY": 1.0
+ },
+ {
+ "ability": "FullAim",
+ "shape": "Box",
+ "center": "PrimaryTarget",
+ "offsetY": -0.5,
+ "boxX": 0.1,
+ "boxY": 1.0
+ },
+ {
+ "ability": "FullAim-Head",
+ "shape": "Box",
+ "center": "PrimaryTarget",
+ "offsetY": -0.5,
+ "boxX": 0.1,
+ "boxY": 1.0
+ }
+ ],
+ "skill": [
+ {
+ "ability": "E-Unfill",
+ "shape": "Circle",
+ "center": "PrimaryTarget",
+ "offsetY": 2.5,
+ "radius": 5
+ },
+ {
+ "ability": "E-Fill",
+ "shape": "Circle",
+ "center": "PrimaryTarget",
+ "offsetY": 2.5,
+ "radius": 5
+ },
+ {
+ "ability": "E-DoT",
+ "shape": "Circle",
+ "center": "PrimaryTarget",
+ "radius": 4
+ },
+ {
+ "ability": "E-AbsorbCheck",
+ "shape": "Circle",
+ "center": "Player",
+ "radius": 4
+ }
+ ],
+ "burst": [
+ {
+ "ability": "Q-Cast",
+ "shape": "Circle",
+ "center": "Player",
+ "offsetY": 1,
+ "radius": 5
+ },
+ {
+ "ability": "Q-DoT",
+ "shape": "Circle",
+ "center": "GlobalValue",
+ "radius": 1.2,
+ "notes": "Spawns on 3 closest enemies to the player. The detection area is not implemented. The exact radius of the hitbox is not known."
+ },
+ {
+ "ability": "Q-AbsorbCheck",
+ "shape": "Circle",
+ "center": "Player",
+ "radius": 1.2,
+ "notes": "The exact radius of the hitbox is not known."
+ }
+ ]
+ },
"jean": {
"normal": [
{
diff --git a/ui/packages/docs/src/components/Frames/character_data.json b/ui/packages/docs/src/components/Frames/character_data.json
index b0dec484e5..a603f06b50 100644
--- a/ui/packages/docs/src/components/Frames/character_data.json
+++ b/ui/packages/docs/src/components/Frames/character_data.json
@@ -825,6 +825,20 @@
"count": "https://docs.google.com/spreadsheets/d/16ZDmEBZOD4xeUcodxFJVuLC7IIqM9LGLilkct65mElg/edit?usp=sharing"
}
],
+ "jahoda": [
+ {
+ "vid_credit": "yamitoka",
+ "count_credit": "yamitoka",
+ "vid": "https://www.youtube.com/z4zaCN_dWwA",
+ "count": "https://docs.google.com/spreadsheets/d/18YvIoV1Ym1jDhlcTK_DOnKoKFRjkCyElpFCD8p8jncY/edit?usp=sharing"
+ },
+ {
+ "vid_credit": "yamitoka",
+ "count_credit": "yamitoka",
+ "vid": "https://www.youtube.com/ypF3osO9S4o",
+ "count": "https://docs.google.com/spreadsheets/d/18YvIoV1Ym1jDhlcTK_DOnKoKFRjkCyElpFCD8p8jncY/edit?usp=sharing"
+ }
+ ],
"jean": [
{
"vid_credit": "Kolibri#7675",
diff --git a/ui/packages/docs/src/components/Issues/character_data.json b/ui/packages/docs/src/components/Issues/character_data.json
index 0cf55241f5..584d92daeb 100644
--- a/ui/packages/docs/src/components/Issues/character_data.json
+++ b/ui/packages/docs/src/components/Issues/character_data.json
@@ -5,6 +5,9 @@
"chasca": [
"Cannot jump while in Nightsoul Blessing"
],
+ "jahoda": [
+ "Smoke Bomb skill is not implemented, since it only trigger when no enemies are nearby."
+ ],
"kinich": [
"The actual Kinich's circular movement is not simulated, so Loop Shots/Scalespiker Cannon will damage the Primary target, which does not depend on attack angle."
],
diff --git a/ui/packages/docs/src/components/Names/character_data.json b/ui/packages/docs/src/components/Names/character_data.json
index 9c96ddb892..a23b57c944 100644
--- a/ui/packages/docs/src/components/Names/character_data.json
+++ b/ui/packages/docs/src/components/Names/character_data.json
@@ -60,6 +60,7 @@
"tao",
"ht"
],
+ "jahoda": [],
"jean": [],
"kazuha": [
"kaedeharakazuha",
diff --git a/ui/packages/docs/src/components/Params/character_data.json b/ui/packages/docs/src/components/Params/character_data.json
index facb35f280..094201e6d7 100644
--- a/ui/packages/docs/src/components/Params/character_data.json
+++ b/ui/packages/docs/src/components/Params/character_data.json
@@ -672,6 +672,33 @@
"param": "collision",
"desc": "0 for no collision dmg (default), 1 for collision dmg."
}
+ ],
+ "jahoda": [
+ {
+ "ability": "attack",
+ "param": "travel",
+ "desc": "Projectile travel time. Default 10 frames."
+ },
+ {
+ "ability": "aim",
+ "param": "hold",
+ "desc": "0 for Physical Aimed Shot, 1 for Fully-Charged Aimed Shot (default)."
+ },
+ {
+ "ability": "aim",
+ "param": "travel",
+ "desc": "Projectile travel time. Default 10 frames."
+ },
+ {
+ "ability": "aim",
+ "param": "weakspot",
+ "desc": "Hit weakspot with aimed shot. Default 0 (false), 1 for true."
+ },
+ {
+ "ability": "skill",
+ "param": "travel",
+ "desc": "Projectile travel time for Meowball. Default 13 frames."
+ }
],
"jean": [
{
diff --git a/ui/packages/ui/src/Data/char_data.generated.json b/ui/packages/ui/src/Data/char_data.generated.json
index b002a6625f..6c3a806d2b 100644
--- a/ui/packages/ui/src/Data/char_data.generated.json
+++ b/ui/packages/ui/src/Data/char_data.generated.json
@@ -771,6 +771,23 @@
},
"name_text_hash_map ": "3068316954"
},
+ "jahoda": {
+ "id": 10000124,
+ "key": "jahoda",
+ "rarity": "QUALITY_PURPLE",
+ "body": "BODY_GIRL",
+ "region": "ASSOC_TYPE_NODKRAI",
+ "element": "Wind",
+ "weapon_class": "WEAPON_BOW",
+ "icon_name": "UI_AvatarIcon_Jahoda",
+ "skill_details": {
+ "skill": 11242,
+ "burst": 11245,
+ "attack": 11241,
+ "burst_energy_cost": 70
+ },
+ "name_text_hash_map ": "1438964522"
+ },
"jean": {
"id": 10000003,
"key": "jean",
diff --git a/ui/packages/ui/src/util/mode-gcsim.js b/ui/packages/ui/src/util/mode-gcsim.js
index f100edc3ff..3cc3f4b65c 100644
--- a/ui/packages/ui/src/util/mode-gcsim.js
+++ b/ui/packages/ui/src/util/mode-gcsim.js
@@ -148,6 +148,7 @@ ace.define(
'ht',
'hutao',
'itto',
+ 'jahoda',
'jean',
'kabukimono',
'kaedeharakazuha',