mapEntity.Step() benchmark and improvements. (#593)

* Initial test and benchmark for mapEntity.Step().

* Removed `&& !m.hasPath()` from mapEntity.atTarget

* Direction is now updated when the path node changes mid-step and target is updated when path is set.

* Test improvements.

* Deleted old benchmark function and tidying.

* d2math.Abs added.

* Abs benchmark and optimisation.

* Negate and Distance benchmark.

* Length and SetLength benchmark.

* Lerp and Dot benchmark.

* Cross and Normalize benchmark.

* Angle and SignedAngle benchmark.

* Trivial change to Vector.Abs()
This commit is contained in:
danhale-git 2020-07-16 17:06:08 +01:00 committed by GitHub
parent 3bdbd5c358
commit 921d44a70c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 389 additions and 119 deletions

View File

@ -150,18 +150,14 @@ func (v *Vector) DivideScalar(s float64) *Vector {
// Abs sets the vector to it's absolute (positive) equivalent.
func (v *Vector) Abs() *Vector {
xm, ym := 1.0, 1.0
if v.x < 0 {
xm = -1
v.x = -v.x
}
if v.y < 0 {
ym = -1
v.y = -v.y
}
v.x *= xm
v.y *= ym
return v
}
@ -183,7 +179,8 @@ func (v *Vector) Length() float64 {
return math.Sqrt(v.Dot(v))
}
// SetLength sets the length of this Vector without changing the direction.
// SetLength sets the length of this Vector without changing the direction. The length will be exact within
// d2math.Epsilon. See d2math.EqualsApprox.
func (v *Vector) SetLength(length float64) *Vector {
v.Normalize()
v.Scale(length)

View File

@ -7,6 +7,19 @@ import (
"github.com/OpenDiablo2/OpenDiablo2/d2common/d2math"
)
/*func TestMain(m *testing.M) {
setup()
os.Exit(m.Run())
}*/
var outVector Vector
var outFloat float64
/*func setup() {
}*/
// TODO: Remove these evaluate functions. Throwing test errors outside the relevant functions means otherwise handly links to failed tests now point here which is annoying.
func evaluateVector(description string, want, got Vector, t *testing.T) {
if !got.Equals(want) {
t.Errorf("%s: wanted %s: got %s", description, want, got)
@ -236,84 +249,158 @@ func TestScale(t *testing.T) {
evaluateVector(fmt.Sprintf("scale %s by 2", v), want, got, t)
}
func TestAbs(t *testing.T) {
func TestVector_Abs(t *testing.T) {
v := NewVector(-1, 1)
want := NewVector(1, 1)
got := v.Clone()
got.Abs()
evaluateVector(fmt.Sprintf("absolute value of %s", v), want, got, t)
if !want.Equals(got) {
t.Errorf("absolute value of %s: want %s: got %s", v, want, got)
}
}
func TestNegate(t *testing.T) {
func BenchmarkVector_Abs(b *testing.B) {
v := NewVector(-1, -1)
for n := 0; n < b.N; n++ {
outVector = *v.Abs()
}
}
func TestVector_Negate(t *testing.T) {
v := NewVector(-1, 1)
want := NewVector(1, -1)
got := v.Clone()
got.Negate()
evaluateVector(fmt.Sprintf("inverse value of %s", v), want, got, t)
if !want.Equals(got) {
t.Errorf("inverse value of %s: want %s: got %s", v, want, got)
}
}
func TestDistance(t *testing.T) {
func BenchmarkVector_Negate(b *testing.B) {
v := NewVector(1, 1)
for n := 0; n < b.N; n++ {
outVector = *v.Negate()
}
}
func TestVector_Distance(t *testing.T) {
v := NewVector(1, 3)
other := NewVector(1, -1)
want := 4.0
c := v.Clone()
got := c.Distance(other)
evaluateScalar(fmt.Sprintf("distance from %s to %s", v, other), want, got, t)
if got != want {
t.Errorf("distance from %s to %s: want %.3f: got %.3f", v, other, want, got)
}
}
func TestLength(t *testing.T) {
func BenchmarkVector_Distance(b *testing.B) {
v := NewVector(1, 1)
d := NewVector(2, 2)
for n := 0; n < b.N; n++ {
outFloat = v.Distance(d)
}
}
func TestVector_Length(t *testing.T) {
v := NewVector(2, 0)
c := v.Clone()
want := 2.0
got := v.Length()
got := c.Length()
d := fmt.Sprintf("length of %s", c)
d := fmt.Sprintf("length of %s", v)
evaluateChanged(d, v, c, t)
if !c.Equals(v) {
t.Errorf("%s: changed vector %s to %s unexpectedly", d, v, c)
}
evaluateScalar(d, want, got, t)
if got != want {
t.Errorf("%s: want %.3f: got %.3f", d, want, got)
}
}
func TestSetLength(t *testing.T) {
func BenchmarkVector_Length(b *testing.B) {
v := NewVector(1, 1)
for n := 0; n < b.N; n++ {
outFloat = v.Length()
}
}
func TestVector_SetLength(t *testing.T) {
v := NewVector(1, 1)
c := v.Clone()
want := 2.0
got := v.SetLength(want).Length()
got := c.SetLength(want).Length()
d := fmt.Sprintf("length of %s", c)
evaluateScalarApprox(d, want, got, t)
if !d2math.EqualsApprox(got, want) {
t.Errorf("set length of %s to %.3f :want %.3f: got %.3f", v, want, want, got)
}
}
func TestLerp(t *testing.T) {
func BenchmarkVector_SetLength(b *testing.B) {
v := NewVector(1, 1)
for n := 0; n < b.N; n++ {
v.SetLength(5)
}
}
func TestVector_Lerp(t *testing.T) {
a := NewVector(0, 0)
b := NewVector(-20, 10)
x := 0.3
interp := 0.3
want := NewVector(-6, 3)
got := a.Lerp(&b, x)
got := a.Lerp(&b, interp)
evaluateVector(fmt.Sprintf("linear interpolation between %s and %s by %.2f", a, b, x), want, *got, t)
if !got.Equals(want) {
t.Errorf("linear interpolation between %s and %s by %.2f: want %s: got %s", a, b, interp, want, got)
}
}
func TestDot(t *testing.T) {
func BenchmarkVector_Lerp(b *testing.B) {
v := NewVector(1, 1)
t := NewVector(1000, 1000)
for n := 0; n < b.N; n++ {
v.Lerp(&t, 1.01)
}
}
func TestVector_Dot(t *testing.T) {
v := NewVector(1, 1)
c := v.Clone()
want := 2.0
got := v.Dot(&v)
got := c.Dot(&c)
d := fmt.Sprintf("dot product of %s", c)
d := fmt.Sprintf("dot product of %s", v)
evaluateChanged(d, v, c, t)
if !c.Equals(v) {
t.Errorf("%s: changed vector %s to %s unexpectedly", d, v, c)
}
evaluateScalar(d, want, got, t)
if got != want {
t.Errorf("%s: want %.3f: got %.3f", d, want, got)
}
}
func TestCross(t *testing.T) {
func BenchmarkVector_Dot(b *testing.B) {
v := NewVector(1, 1)
for n := 0; n < b.N; n++ {
outFloat = v.Dot(&v)
}
}
func TestVector_Cross(t *testing.T) {
v := NewVector(1, 1)
clock := NewVector(1, 0)
@ -322,88 +409,156 @@ func TestCross(t *testing.T) {
want := -1.0
got := v.Cross(clock)
evaluateScalar(fmt.Sprintf("cross product of %s and %s", v, clock), want, got, t)
if got != want {
t.Errorf("cross product of %s and %s: want %.3f: got %.3f", v, clock, want, got)
}
want = 1.0
got = v.Cross(anti)
evaluateScalar(fmt.Sprintf("cross product of %s and %s", v, anti), want, got, t)
if got != want {
t.Errorf("cross product of %s and %s: want %.3f: got %.3f", v, clock, want, got)
}
}
func TestNormalize(t *testing.T) {
func BenchmarkVector_Cross(b *testing.B) {
v := NewVector(1, 1)
o := NewVector(0, 1)
for n := 0; n < b.N; n++ {
outFloat = v.Cross(o)
}
}
func TestVector_Normalize(t *testing.T) {
v := NewVector(10, 0)
c := v.Clone()
want := NewVector(1, 0)
v.Normalize()
c.Normalize()
evaluateVector(fmt.Sprintf("normalize %s", c), want, v, t)
if !want.Equals(c) {
t.Errorf("normalize %s: want %s: got %s", v, want, c)
}
v = NewVector(0, 10)
c = v.Clone()
want = NewVector(0, 1)
reverse := v.Normalize()
reverse := c.Normalize()
evaluateVector(fmt.Sprintf("normalize %s", c), want, v, t)
if !want.Equals(c) {
t.Errorf("normalize %s: want %s: got %s", v, want, c)
}
want = NewVector(0, 10)
v.Scale(reverse)
c.Scale(reverse)
evaluateVector(fmt.Sprintf("reverse normalizing of %s", c), want, v, t)
if !want.Equals(c) {
t.Errorf("reverse normalizing of %s: want %s: got %s", v, want, c)
}
v = NewVector(0, 0)
c = v.Clone()
want = NewVector(0, 0)
v.Normalize()
c.Normalize()
evaluateVector(fmt.Sprintf("normalize zero vector should do nothing %s", c), want, v, t)
if !want.Equals(c) {
t.Errorf("normalize zero vector %s should do nothing: want %s: got %s", v, want, c)
}
}
func TestAngle(t *testing.T) {
func BenchmarkVector_Normalize(b *testing.B) {
v := NewVector(1, 1)
for n := 0; n < b.N; n++ {
v.Normalize()
}
}
func TestVector_Angle(t *testing.T) {
v := NewVector(0, 1)
c := v.Clone()
other := NewVector(1, 0.3)
d := fmt.Sprintf("angle from %s to %s", c, other)
want := 1.2793395323170293
got := v.Angle(other)
got := c.Angle(other)
evaluateScalar(d, want, got, t)
evaluateChanged(d, v, c, t)
d := fmt.Sprintf("angle from %s to %s", v, other)
if got != want {
t.Errorf("%s: want %g: got %g", d, want, got)
}
if !c.Equals(v) {
t.Errorf("%s: changed vector %s to %s unexpectedly", d, v, c)
}
other.Set(-1, 0.3)
c = other.Clone()
co := other.Clone()
got = c.Angle(other)
d = fmt.Sprintf("angle from %s to %s", c, other)
got = v.Angle(other)
if got != want {
t.Errorf("%s: want %g: got %g", d, want, got)
}
evaluateScalar(d, want, got, t)
evaluateChanged(d, other, c, t)
if !co.Equals(other) {
t.Errorf("%s: changed vector %s to %s unexpectedly", d, co, other)
}
}
func TestSignedAngle(t *testing.T) {
func BenchmarkVector_Angle(b *testing.B) {
v := NewVector(1, 1)
o := NewVector(0, 1)
for n := 0; n < b.N; n++ {
outFloat = v.Angle(o)
}
}
func TestVector_SignedAngle(t *testing.T) {
v := NewVector(0, 1)
c := v.Clone()
other := NewVector(1, 0.3)
want := 1.2793395323170293
got := v.SignedAngle(other)
got := c.SignedAngle(other)
d := fmt.Sprintf("angle from %s to %s", v, other)
evaluateScalar(d, want, got, t)
evaluateChanged(d, v, c, t)
if got != want {
t.Errorf("%s: want %g: got %g", d, want, got)
}
if !c.Equals(v) {
t.Errorf("%s: changed vector %s to %s unexpectedly", d, v, c)
}
other.Set(-1, 0.3)
c = other.Clone()
co := other.Clone()
want = 5.0038457214660585
got = v.SignedAngle(other)
got = c.SignedAngle(other)
d = fmt.Sprintf("angle from %s to %s", v, other)
evaluateScalar(d, want, got, t)
evaluateChanged(d, other, c, t)
if got != want {
t.Errorf("%s: want %g: got %g", d, want, got)
}
if !co.Equals(other) {
t.Errorf("%s: changed vector %s to %s unexpectedly", d, co, other)
}
}
func BenchmarkVector_SignedAngle(b *testing.B) {
v := NewVector(1, 1)
o := NewVector(0, 1)
for n := 0; n < b.N; n++ {
outFloat = v.SignedAngle(o)
}
}
func TestReflect(t *testing.T) {

View File

@ -1,7 +1,5 @@
package d2math
import "math"
const (
// Epsilon is used as the threshold for 'almost equal' operations.
Epsilon float64 = 0.0001
@ -16,7 +14,7 @@ const (
// EqualsApprox returns true if the difference between a and b is less than Epsilon.
func EqualsApprox(a, b float64) bool {
return math.Abs(a-b) < Epsilon
return Abs(a-b) < Epsilon
}
// CompareFloat64Fuzzy returns an integer between -1 and 1 describing
@ -24,7 +22,8 @@ func EqualsApprox(a, b float64) bool {
// absolute difference between a and b is less than Epsilon.
func CompareFloat64Fuzzy(a, b float64) int {
delta := a - b
if math.Abs(delta) < Epsilon {
if Abs(delta) < Epsilon {
return 0
}
@ -35,6 +34,15 @@ func CompareFloat64Fuzzy(a, b float64) int {
return -1
}
// Abs returns the absolute value of a. It is a less CPU intensive version of the standard library math.Abs().
func Abs(a float64) float64 {
if a < 0 {
return a * -1
}
return a
}
// ClampFloat64 returns a clamped to min and max.
func ClampFloat64(a, min, max float64) float64 {
if a > max {

View File

@ -17,7 +17,7 @@ type AnimatedEntity struct {
// CreateAnimatedEntity creates an instance of AnimatedEntity
func CreateAnimatedEntity(x, y int, animation d2interface.Animation) *AnimatedEntity {
entity := &AnimatedEntity{
mapEntity: createMapEntity(x, y),
mapEntity: newMapEntity(x, y),
animation: animation,
}
entity.mapEntity.directioner = entity.rotate
@ -30,7 +30,7 @@ func (ae *AnimatedEntity) Render(target d2interface.Surface) {
renderOffset := ae.Position.RenderOffset()
target.PushTranslation(
int((renderOffset.X()-renderOffset.Y())*16),
int(((renderOffset.X() + renderOffset.Y()) * 8)),
int(((renderOffset.X()+renderOffset.Y())*8)-5),
)
defer target.Pop()

View File

@ -11,6 +11,7 @@ import (
type mapEntity struct {
Position d2vector.Position
Target d2vector.Position
velocity d2vector.Vector
Speed float64
path []d2astar.Pather
@ -20,8 +21,8 @@ type mapEntity struct {
directioner func(direction int)
}
// createMapEntity creates an instance of mapEntity
func createMapEntity(x, y int) mapEntity {
// newMapEntity creates an instance of mapEntity
func newMapEntity(x, y int) mapEntity {
locX, locY := float64(x), float64(y)
return mapEntity{
@ -43,6 +44,7 @@ func (m *mapEntity) GetLayer() int {
func (m *mapEntity) SetPath(path []d2astar.Pather, done func()) {
m.path = path
m.done = done
m.nextPath()
}
// ClearPath clears the entity movement path.
@ -60,14 +62,9 @@ func (m *mapEntity) GetSpeed() float64 {
return m.Speed
}
// IsAtTarget returns true if the distance between entity and target is almost zero.
func (m *mapEntity) IsAtTarget() bool {
return m.Position.EqualsApprox(m.Target.Vector) && !m.HasPathFinding()
}
// Step moves the entity along it's path by one tick. If the path is complete it calls entity.done() then returns.
func (m *mapEntity) Step(tickTime float64) {
if m.IsAtTarget() {
if m.atTarget() && !m.hasPath() {
if m.done != nil {
m.done()
m.done = nil
@ -76,47 +73,39 @@ func (m *mapEntity) Step(tickTime float64) {
return
}
velocity := m.velocity(tickTime)
// Set velocity (speed and direction)
m.setVelocity(tickTime * m.Speed)
// This loop handles the situation where the velocity exceeds the distance to the current target. Each repitition applies
// the remaining velocity in the direction of the next path target.
for {
// Add the velocity to the position and set new velocity to remainder
applyVelocity(&m.Position.Vector, &velocity, &m.Target.Vector)
applyVelocity(&m.Position.Vector, &m.velocity, &m.Target.Vector)
// New position is at target
if m.Position.EqualsApprox(m.Target.Vector) {
if len(m.path) > 0 {
// Set next path node
m.SetTarget(m.path[0].(*d2common.PathTile).X*5, m.path[0].(*d2common.PathTile).Y*5, m.done)
if len(m.path) > 1 {
m.path = m.path[1:]
} else {
m.path = []d2astar.Pather{}
}
} else {
// End of path.
m.Position.Copy(&m.Target.Vector)
}
if m.atTarget() {
m.nextPath()
}
if velocity.IsZero() {
if m.velocity.IsZero() {
break
}
}
}
// velocity returns a vector describing the change in position this tick.
func (m *mapEntity) velocity(tickTime float64) d2vector.Vector {
length := tickTime * m.Speed
v := m.Target.Vector.Clone()
v.Subtract(&m.Position.Vector)
v.SetLength(length)
return v
// atTarget returns true if the distance between entity and target is almost zero.
func (m *mapEntity) atTarget() bool {
return m.Position.EqualsApprox(m.Target.Vector)
}
// applyVelocity adds velocity to position. If the new position extends beyond target from the original position, the
// position is set to the target and velocity is set to the overshot amount.
// setVelocity returns a vector describing the given length and the direction to the current target.
func (m *mapEntity) setVelocity(length float64) {
m.velocity.Copy(&m.Target.Vector)
m.velocity.Subtract(&m.Position.Vector)
m.velocity.SetLength(length)
}
// applyVelocity adds velocity to position. If the new position extends beyond the target: Target is set to the next
// path node, Position is set to target and velocity is set to the over-extended length with the direction of to the
// next node.
func applyVelocity(position, velocity, target *d2vector.Vector) {
// Set velocity values to zero if almost zero
x, y := position.CompareApprox(*target)
@ -149,21 +138,49 @@ func applyVelocity(position, velocity, target *d2vector.Vector) {
}
}
// HasPathFinding returns false if the length of the entity movement path is 0.
func (m *mapEntity) HasPathFinding() bool {
// Returns false if the path has ended.
func (m *mapEntity) nextPath() {
if m.hasPath() {
// Set next path node
m.setTarget(
m.path[0].(*d2common.PathTile).X*5,
m.path[0].(*d2common.PathTile).Y*5,
m.done,
)
if len(m.path) > 1 {
m.path = m.path[1:]
} else {
m.path = []d2astar.Pather{}
}
} else {
// End of path.
m.Position.Copy(&m.Target.Vector)
}
}
// hasPath returns false if the length of the entity movement path is 0.
func (m *mapEntity) hasPath() bool {
return len(m.path) > 0
}
// SetTarget sets target coordinates and changes animation based on proximity and direction.
func (m *mapEntity) SetTarget(tx, ty float64, done func()) {
// setTarget sets target coordinates and changes animation based on proximity and direction.
func (m *mapEntity) setTarget(tx, ty float64, done func()) {
// Set the target
m.Target.Set(tx, ty)
m.done = done
// Update the direction
if m.directioner != nil {
d := m.Position.DirectionTo(m.Target.Vector)
m.directioner(d)
}
// Update the velocity direction
if !m.velocity.IsZero() {
m.setVelocity(m.velocity.Length())
}
}
// GetPosition returns the entity's current tile position, always a whole number.

View File

@ -0,0 +1,93 @@
package d2mapentity
import (
"os"
"testing"
"github.com/OpenDiablo2/OpenDiablo2/d2common/d2math/d2vector"
"github.com/OpenDiablo2/OpenDiablo2/d2common"
"github.com/OpenDiablo2/OpenDiablo2/d2common/d2astar"
)
var stepEntity mapEntity
const (
normalTickTime float64 = 0.05
)
func TestMain(m *testing.M) {
setup()
os.Exit(m.Run())
}
func setup() {
setupBenchmarkMapEntityStep()
}
func entity() mapEntity {
return newMapEntity(10, 10)
}
func movingEntity() mapEntity {
e := entity()
e.SetSpeed(9)
newPath := path(10, e.Position)
e.SetPath(newPath, func() {})
return e
}
func path(length int, origin d2vector.Position) []d2astar.Pather {
path := make([]d2astar.Pather, length)
for i := 0; i < length; i++ {
origin.AddScalar(float64(i+1) / 5)
tile := origin.World()
path[i] = pathTile(tile.X(), tile.Y())
}
return path
}
func pathTile(x, y float64) *d2common.PathTile {
return &d2common.PathTile{X: x, Y: y}
}
func TestMapEntity_Step(t *testing.T) {
stepCount := 10
e := movingEntity()
start := e.Position.Clone()
for i := 0; i < stepCount; i++ {
e.Step(normalTickTime)
}
// velocity
change := d2vector.NewVector(0, 0)
change.Copy(&e.Target.Vector)
change.Subtract(&e.Position.Vector)
change.SetLength(e.Speed * normalTickTime)
// change in position
change.Scale(float64(stepCount))
want := change.Add(&start)
if !e.Position.EqualsApprox(*want) {
t.Errorf("entity position after %d steps: want %s: got %s", stepCount, want, e.Position.Vector)
}
if e.Position.Equals(start) {
t.Errorf("entity did not move, still at position %s", start)
}
}
func setupBenchmarkMapEntityStep() {
stepEntity = movingEntity()
}
func BenchmarkMapEntity_Step(b *testing.B) {
for n := 0; n < b.N; n++ {
stepEntity.Step(normalTickTime)
}
}

View File

@ -54,7 +54,7 @@ func (m *Missile) SetRadians(angle float64, done func()) {
x := m.Position.X() + (r * math.Cos(angle))
y := m.Position.Y() + (r * math.Sin(angle))
m.SetTarget(x, y, done)
m.setTarget(x, y, done)
}
// Advance is called once per frame and processes a

View File

@ -31,7 +31,7 @@ type NPC struct {
// CreateNPC creates a new NPC and returns a pointer to it.
func CreateNPC(x, y int, monstat *d2datadict.MonStatsRecord, direction int) *NPC {
result := &NPC{
mapEntity: createMapEntity(x, y),
mapEntity: newMapEntity(x, y),
HasPaths: false,
monstatRecord: monstat,
monstatEx: d2datadict.MonStats2[monstat.ExtraDataKey],
@ -75,7 +75,7 @@ func (v *NPC) Render(target d2interface.Surface) {
renderOffset := v.Position.RenderOffset()
target.PushTranslation(
int((renderOffset.X()-renderOffset.Y())*16),
int(((renderOffset.X() + renderOffset.Y()) * 8)),
int(((renderOffset.X()+renderOffset.Y())*8)-5),
)
defer target.Pop()
@ -116,7 +116,7 @@ func (v *NPC) Advance(tickTime float64) {
// If at the target, set target to the next path.
v.isDone = false
path := v.NextPath()
v.SetTarget(
v.setTarget(
float64(path.X),
float64(path.Y),
v.next,
@ -159,7 +159,7 @@ func (v *NPC) next() {
// rotate sets direction and changes animation
func (v *NPC) rotate(direction int) {
var newMode d2enum.MonsterAnimationMode
if !v.IsAtTarget() {
if !v.atTarget() {
newMode = d2enum.MonsterAnimationModeWalk
} else {
newMode = d2enum.MonsterAnimationModeNeutral

View File

@ -57,7 +57,7 @@ func CreatePlayer(id, name string, x, y int, direction int, heroType d2enum.Hero
result := &Player{
Id: id,
mapEntity: createMapEntity(x, y),
mapEntity: newMapEntity(x, y),
composite: composite,
Equipment: equipment,
Stats: stats,
@ -147,7 +147,7 @@ func (v *Player) Render(target d2interface.Surface) {
renderOffset := v.Position.RenderOffset()
target.PushTranslation(
int((renderOffset.X()-renderOffset.Y())*16),
int(((renderOffset.X() + renderOffset.Y()) * 8)),
int(((renderOffset.X()+renderOffset.Y())*8)-5),
)
defer target.Pop()
@ -159,19 +159,19 @@ func (v *Player) Render(target d2interface.Surface) {
// GetAnimationMode returns the current animation mode based on what the player is doing and where they are.
func (v *Player) GetAnimationMode() d2enum.PlayerAnimationMode {
if v.IsRunning() && !v.IsAtTarget() {
if v.IsRunning() && !v.atTarget() {
return d2enum.PlayerAnimationModeRun
}
if v.IsInTown() {
if !v.IsAtTarget() {
if !v.atTarget() {
return d2enum.PlayerAnimationModeTownWalk
}
return d2enum.PlayerAnimationModeTownNeutral
}
if !v.IsAtTarget() {
if !v.atTarget() {
return d2enum.PlayerAnimationModeWalk
}