primeiro commit
This commit is contained in:
734
internal/vn/engine.go
Normal file
734
internal/vn/engine.go
Normal file
@@ -0,0 +1,734 @@
|
||||
package vn
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type waitingMode int
|
||||
|
||||
const (
|
||||
waitingNone waitingMode = iota
|
||||
waitingAdvance
|
||||
waitingChoice
|
||||
waitingMenu
|
||||
waitingEnd
|
||||
)
|
||||
|
||||
// Dialogue represents the current spoken line.
|
||||
type Dialogue struct {
|
||||
Speaker string
|
||||
Text string
|
||||
}
|
||||
|
||||
// ActiveChoice is a rendered selectable choice.
|
||||
type ActiveChoice struct {
|
||||
Text string
|
||||
Target string
|
||||
}
|
||||
|
||||
// ActiveMenuItem is an interactive positioned sprite entry in a menu command.
|
||||
type ActiveMenuItem struct {
|
||||
ID string `json:"id"`
|
||||
Sprite string `json:"sprite"`
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
Selectable bool `json:"selectable"`
|
||||
Commands []Command `json:"commands"`
|
||||
}
|
||||
|
||||
// VarValue stores script variables as strongly-typed values.
|
||||
type VarValue struct {
|
||||
Type string `json:"type"`
|
||||
Str string `json:"str,omitempty"`
|
||||
Bool bool `json:"bool,omitempty"`
|
||||
Int int64 `json:"int,omitempty"`
|
||||
Float float64 `json:"float,omitempty"`
|
||||
}
|
||||
|
||||
func NewVarValue(v any) (VarValue, error) {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return VarValue{Type: "string", Str: t}, nil
|
||||
case bool:
|
||||
return VarValue{Type: "bool", Bool: t}, nil
|
||||
case int:
|
||||
return VarValue{Type: "int", Int: int64(t)}, nil
|
||||
case int8:
|
||||
return VarValue{Type: "int", Int: int64(t)}, nil
|
||||
case int16:
|
||||
return VarValue{Type: "int", Int: int64(t)}, nil
|
||||
case int32:
|
||||
return VarValue{Type: "int", Int: int64(t)}, nil
|
||||
case int64:
|
||||
return VarValue{Type: "int", Int: t}, nil
|
||||
case uint:
|
||||
return VarValue{Type: "int", Int: int64(t)}, nil
|
||||
case uint8:
|
||||
return VarValue{Type: "int", Int: int64(t)}, nil
|
||||
case uint16:
|
||||
return VarValue{Type: "int", Int: int64(t)}, nil
|
||||
case uint32:
|
||||
return VarValue{Type: "int", Int: int64(t)}, nil
|
||||
case uint64:
|
||||
if t > uint64(^uint64(0)>>1) {
|
||||
return VarValue{}, fmt.Errorf("uint64 out of range: %d", t)
|
||||
}
|
||||
return VarValue{Type: "int", Int: int64(t)}, nil
|
||||
case float32:
|
||||
return VarValue{Type: "float", Float: float64(t)}, nil
|
||||
case float64:
|
||||
return VarValue{Type: "float", Float: t}, nil
|
||||
case nil:
|
||||
return VarValue{Type: "string", Str: ""}, nil
|
||||
default:
|
||||
return VarValue{}, fmt.Errorf("unsupported variable type %T", v)
|
||||
}
|
||||
}
|
||||
|
||||
func (v VarValue) String() string {
|
||||
switch v.Type {
|
||||
case "string":
|
||||
return v.Str
|
||||
case "bool":
|
||||
return strconv.FormatBool(v.Bool)
|
||||
case "int":
|
||||
return strconv.FormatInt(v.Int, 10)
|
||||
case "float":
|
||||
return strconv.FormatFloat(v.Float, 'f', -1, 64)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (v VarValue) asFloat() (float64, bool) {
|
||||
switch v.Type {
|
||||
case "int":
|
||||
return float64(v.Int), true
|
||||
case "float":
|
||||
return v.Float, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func parseLiteral(raw string) VarValue {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if uq, err := strconv.Unquote(raw); err == nil {
|
||||
return VarValue{Type: "string", Str: uq}
|
||||
}
|
||||
if raw == "true" {
|
||||
return VarValue{Type: "bool", Bool: true}
|
||||
}
|
||||
if raw == "false" {
|
||||
return VarValue{Type: "bool", Bool: false}
|
||||
}
|
||||
if i, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
||||
return VarValue{Type: "int", Int: i}
|
||||
}
|
||||
if f, err := strconv.ParseFloat(raw, 64); err == nil {
|
||||
return VarValue{Type: "float", Float: f}
|
||||
}
|
||||
return VarValue{Type: "string", Str: raw}
|
||||
}
|
||||
|
||||
// RenderState is the current state needed by the renderer.
|
||||
type RenderState struct {
|
||||
Title string
|
||||
Background string
|
||||
BackgroundImage string
|
||||
Character string
|
||||
CharacterSprite string
|
||||
Emotion string
|
||||
Dialogue Dialogue
|
||||
Choices []ActiveChoice
|
||||
Selected int
|
||||
MenuItems []ActiveMenuItem
|
||||
MenuSelected int
|
||||
MenuActive bool
|
||||
Ended bool
|
||||
}
|
||||
|
||||
// Engine executes script commands and exposes render-ready state.
|
||||
type Engine struct {
|
||||
script *Script
|
||||
texts *Texts
|
||||
sceneByID map[string]Scene
|
||||
sceneID string
|
||||
index int
|
||||
pending []Command
|
||||
wait waitingMode
|
||||
state RenderState
|
||||
vars map[string]VarValue
|
||||
onEndError error
|
||||
}
|
||||
|
||||
type saveData struct {
|
||||
SceneID string `json:"scene_id"`
|
||||
Index int `json:"index"`
|
||||
Pending []Command `json:"pending"`
|
||||
Wait waitingMode `json:"wait"`
|
||||
State RenderState `json:"state"`
|
||||
Vars map[string]VarValue `json:"vars"`
|
||||
}
|
||||
|
||||
func NewEngine(script *Script, texts *Texts) (*Engine, error) {
|
||||
scenes := make(map[string]Scene, len(script.Scenes))
|
||||
for _, sc := range script.Scenes {
|
||||
if sc.ID == "" {
|
||||
return nil, fmt.Errorf("scene with empty id")
|
||||
}
|
||||
if _, exists := scenes[sc.ID]; exists {
|
||||
return nil, fmt.Errorf("duplicate scene id: %s", sc.ID)
|
||||
}
|
||||
scenes[sc.ID] = sc
|
||||
}
|
||||
if _, ok := scenes[script.Start]; !ok {
|
||||
return nil, fmt.Errorf("start scene %q not found", script.Start)
|
||||
}
|
||||
|
||||
e := &Engine{
|
||||
script: script,
|
||||
texts: texts,
|
||||
sceneByID: scenes,
|
||||
sceneID: script.Start,
|
||||
}
|
||||
e.Reset()
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func (e *Engine) State() RenderState {
|
||||
return e.state
|
||||
}
|
||||
|
||||
func (e *Engine) CurrentSceneID() string {
|
||||
return e.sceneID
|
||||
}
|
||||
|
||||
func (e *Engine) SceneIDs() []string {
|
||||
ids := make([]string, 0, len(e.sceneByID))
|
||||
for id := range e.sceneByID {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
return ids
|
||||
}
|
||||
|
||||
func (e *Engine) VarsSnapshot() map[string]VarValue {
|
||||
out := make(map[string]VarValue, len(e.vars))
|
||||
for k, v := range e.vars {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (e *Engine) Advance() {
|
||||
switch e.wait {
|
||||
case waitingAdvance:
|
||||
e.wait = waitingNone
|
||||
e.runUntilWait()
|
||||
case waitingEnd:
|
||||
e.state.Ended = true
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) MoveChoice(delta int) {
|
||||
if e.wait != waitingChoice || len(e.state.Choices) == 0 {
|
||||
return
|
||||
}
|
||||
n := len(e.state.Choices)
|
||||
e.state.Selected = (e.state.Selected + delta + n) % n
|
||||
}
|
||||
|
||||
func (e *Engine) ConfirmChoice() {
|
||||
if e.wait != waitingChoice || len(e.state.Choices) == 0 {
|
||||
return
|
||||
}
|
||||
choice := e.state.Choices[e.state.Selected]
|
||||
e.jumpTo(choice.Target)
|
||||
e.wait = waitingNone
|
||||
e.runUntilWait()
|
||||
}
|
||||
|
||||
func (e *Engine) MoveMenu(dx, dy int) {
|
||||
if e.wait != waitingMenu || !e.state.MenuActive || len(e.state.MenuItems) == 0 {
|
||||
return
|
||||
}
|
||||
if dx == 0 && dy == 0 {
|
||||
return
|
||||
}
|
||||
current := e.state.MenuSelected
|
||||
if current < 0 || current >= len(e.state.MenuItems) || !e.state.MenuItems[current].Selectable {
|
||||
e.state.MenuSelected = e.findFirstSelectableMenu()
|
||||
return
|
||||
}
|
||||
bestIdx := current
|
||||
bestScore := math.MaxFloat64
|
||||
cur := e.state.MenuItems[current]
|
||||
for i, item := range e.state.MenuItems {
|
||||
if i == current || !item.Selectable {
|
||||
continue
|
||||
}
|
||||
vx := item.X - cur.X
|
||||
vy := item.Y - cur.Y
|
||||
if dx > 0 && vx <= 0 {
|
||||
continue
|
||||
}
|
||||
if dx < 0 && vx >= 0 {
|
||||
continue
|
||||
}
|
||||
if dy > 0 && vy <= 0 {
|
||||
continue
|
||||
}
|
||||
if dy < 0 && vy >= 0 {
|
||||
continue
|
||||
}
|
||||
// Prefer closer items in the intended direction.
|
||||
score := math.Hypot(vx, vy)
|
||||
if dx != 0 {
|
||||
score += math.Abs(vy) * 0.5
|
||||
}
|
||||
if dy != 0 {
|
||||
score += math.Abs(vx) * 0.5
|
||||
}
|
||||
if score < bestScore {
|
||||
bestScore = score
|
||||
bestIdx = i
|
||||
}
|
||||
}
|
||||
if bestIdx != current {
|
||||
e.state.MenuSelected = bestIdx
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) ConfirmMenu() {
|
||||
if e.wait != waitingMenu || !e.state.MenuActive || len(e.state.MenuItems) == 0 {
|
||||
return
|
||||
}
|
||||
sel := e.state.MenuSelected
|
||||
if sel < 0 || sel >= len(e.state.MenuItems) {
|
||||
return
|
||||
}
|
||||
item := e.state.MenuItems[sel]
|
||||
if !item.Selectable {
|
||||
return
|
||||
}
|
||||
e.clearMenuState()
|
||||
e.prependCommands(item.Commands)
|
||||
e.wait = waitingNone
|
||||
e.runUntilWait()
|
||||
}
|
||||
|
||||
func (e *Engine) Error() error {
|
||||
return e.onEndError
|
||||
}
|
||||
|
||||
func (e *Engine) Reset() {
|
||||
e.sceneID = e.script.Start
|
||||
e.index = 0
|
||||
e.pending = nil
|
||||
e.wait = waitingNone
|
||||
e.vars = map[string]VarValue{}
|
||||
e.onEndError = nil
|
||||
e.state = RenderState{Title: e.script.Title, MenuSelected: -1}
|
||||
e.runUntilWait()
|
||||
}
|
||||
|
||||
func (e *Engine) SaveToFile(path string) error {
|
||||
data := saveData{
|
||||
SceneID: e.sceneID,
|
||||
Index: e.index,
|
||||
Pending: e.pending,
|
||||
Wait: e.wait,
|
||||
State: e.state,
|
||||
Vars: e.vars,
|
||||
}
|
||||
if data.Vars == nil {
|
||||
data.Vars = map[string]VarValue{}
|
||||
}
|
||||
raw, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode save: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("create save dir: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(path, raw, 0o644); err != nil {
|
||||
return fmt.Errorf("write save file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Engine) LoadFromFile(path string) error {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read save file: %w", err)
|
||||
}
|
||||
var data saveData
|
||||
if err := json.Unmarshal(raw, &data); err != nil {
|
||||
return fmt.Errorf("parse save file: %w", err)
|
||||
}
|
||||
if _, ok := e.sceneByID[data.SceneID]; !ok {
|
||||
return fmt.Errorf("save references unknown scene: %s", data.SceneID)
|
||||
}
|
||||
if data.Index < 0 {
|
||||
return fmt.Errorf("invalid save index: %d", data.Index)
|
||||
}
|
||||
if data.Vars == nil {
|
||||
data.Vars = map[string]VarValue{}
|
||||
}
|
||||
e.sceneID = data.SceneID
|
||||
e.index = data.Index
|
||||
e.pending = data.Pending
|
||||
e.wait = data.Wait
|
||||
e.state = data.State
|
||||
if e.state.Title == "" {
|
||||
e.state.Title = e.script.Title
|
||||
}
|
||||
e.vars = data.Vars
|
||||
e.onEndError = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Engine) JumpToScene(sceneID string) error {
|
||||
if sceneID == "" {
|
||||
return fmt.Errorf("scene id is empty")
|
||||
}
|
||||
if _, ok := e.sceneByID[sceneID]; !ok {
|
||||
return fmt.Errorf("scene does not exist: %s", sceneID)
|
||||
}
|
||||
e.sceneID = sceneID
|
||||
e.index = 0
|
||||
e.pending = nil
|
||||
e.wait = waitingNone
|
||||
e.state.Choices = nil
|
||||
e.state.Selected = 0
|
||||
e.clearMenuState()
|
||||
e.state.Ended = false
|
||||
e.onEndError = nil
|
||||
e.runUntilWait()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Engine) runUntilWait() {
|
||||
for e.wait == waitingNone {
|
||||
cmd, ok := e.nextCommand()
|
||||
if !ok {
|
||||
e.wait = waitingEnd
|
||||
e.state.Dialogue = Dialogue{Speaker: "", Text: e.texts.EndText}
|
||||
return
|
||||
}
|
||||
e.execCommand(cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) nextCommand() (Command, bool) {
|
||||
if len(e.pending) > 0 {
|
||||
cmd := e.pending[0]
|
||||
e.pending = e.pending[1:]
|
||||
return cmd, true
|
||||
}
|
||||
scene, ok := e.sceneByID[e.sceneID]
|
||||
if !ok {
|
||||
e.fail(fmt.Errorf("unknown scene: %s", e.sceneID))
|
||||
return Command{}, false
|
||||
}
|
||||
if e.index >= len(scene.Commands) {
|
||||
return Command{}, false
|
||||
}
|
||||
cmd := scene.Commands[e.index]
|
||||
e.index++
|
||||
return cmd, true
|
||||
}
|
||||
|
||||
func (e *Engine) prependCommands(cmds []Command) {
|
||||
if len(cmds) == 0 {
|
||||
return
|
||||
}
|
||||
copyCmds := make([]Command, len(cmds))
|
||||
copy(copyCmds, cmds)
|
||||
e.pending = append(copyCmds, e.pending...)
|
||||
}
|
||||
|
||||
func (e *Engine) execCommand(cmd Command) {
|
||||
if cmd.Type == "" {
|
||||
e.fail(fmt.Errorf("command missing type"))
|
||||
return
|
||||
}
|
||||
if cmd.If != "" && strings.ToLower(cmd.Type) != "if" {
|
||||
ok, err := e.evalCondition(cmd.If)
|
||||
if err != nil {
|
||||
e.fail(fmt.Errorf("invalid condition %q: %w", cmd.If, err))
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
switch strings.ToLower(cmd.Type) {
|
||||
case "say":
|
||||
e.clearMenuState()
|
||||
e.state.Dialogue = Dialogue{Speaker: cmd.Speaker, Text: e.interpolate(cmd.Text)}
|
||||
e.wait = waitingAdvance
|
||||
case "background":
|
||||
e.state.Background = e.interpolate(cmd.Background)
|
||||
e.state.BackgroundImage = e.interpolate(cmd.BackgroundImage)
|
||||
case "character":
|
||||
e.state.Character = e.interpolate(cmd.Character)
|
||||
e.state.Emotion = e.interpolate(cmd.Emotion)
|
||||
e.state.CharacterSprite = e.interpolate(cmd.Sprite)
|
||||
case "set":
|
||||
if cmd.Key == "" {
|
||||
e.fail(fmt.Errorf("set command missing key"))
|
||||
return
|
||||
}
|
||||
value, err := e.convertSetValue(cmd.Value)
|
||||
if err != nil {
|
||||
e.fail(fmt.Errorf("set command invalid value for %q: %w", cmd.Key, err))
|
||||
return
|
||||
}
|
||||
e.vars[cmd.Key] = value
|
||||
case "goto":
|
||||
e.jumpTo(e.interpolate(cmd.Target))
|
||||
case "choice":
|
||||
e.clearMenuState()
|
||||
e.execChoice(cmd)
|
||||
case "menu":
|
||||
e.execMenu(cmd)
|
||||
case "if":
|
||||
if cmd.If == "" {
|
||||
e.fail(fmt.Errorf("if command missing condition"))
|
||||
return
|
||||
}
|
||||
ok, err := e.evalCondition(cmd.If)
|
||||
if err != nil {
|
||||
e.fail(fmt.Errorf("invalid condition %q: %w", cmd.If, err))
|
||||
return
|
||||
}
|
||||
if ok {
|
||||
e.prependCommands(cmd.Then)
|
||||
} else {
|
||||
e.prependCommands(cmd.Else)
|
||||
}
|
||||
case "end":
|
||||
e.clearMenuState()
|
||||
e.wait = waitingEnd
|
||||
e.state.Dialogue = Dialogue{Speaker: "", Text: e.texts.EndText}
|
||||
default:
|
||||
e.fail(fmt.Errorf("unknown command type: %s", cmd.Type))
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) execChoice(cmd Command) {
|
||||
choices := make([]ActiveChoice, 0, len(cmd.Choices))
|
||||
for _, c := range cmd.Choices {
|
||||
if c.If != "" {
|
||||
ok, err := e.evalCondition(c.If)
|
||||
if err != nil {
|
||||
e.fail(fmt.Errorf("invalid choice condition %q: %w", c.If, err))
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
choices = append(choices, ActiveChoice{Text: e.interpolate(c.Text), Target: e.interpolate(c.Target)})
|
||||
}
|
||||
if len(choices) == 0 {
|
||||
e.fail(fmt.Errorf("choice command has no available options"))
|
||||
return
|
||||
}
|
||||
e.state.Choices = choices
|
||||
e.state.Selected = 0
|
||||
e.wait = waitingChoice
|
||||
}
|
||||
|
||||
func (e *Engine) execMenu(cmd Command) {
|
||||
if cmd.Background != "" || cmd.BackgroundImage != "" {
|
||||
e.state.Background = e.interpolate(cmd.Background)
|
||||
e.state.BackgroundImage = e.interpolate(cmd.BackgroundImage)
|
||||
}
|
||||
activeItems := make([]ActiveMenuItem, 0, len(cmd.MenuItems))
|
||||
for i, item := range cmd.MenuItems {
|
||||
if item.If != "" {
|
||||
ok, err := e.evalCondition(item.If)
|
||||
if err != nil {
|
||||
e.fail(fmt.Errorf("invalid menu item condition %q: %w", item.If, err))
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
selectable := true
|
||||
if item.Selectable != nil {
|
||||
selectable = *item.Selectable
|
||||
}
|
||||
id := item.ID
|
||||
if id == "" {
|
||||
id = fmt.Sprintf("menu_item_%d", i)
|
||||
}
|
||||
activeItems = append(activeItems, ActiveMenuItem{
|
||||
ID: e.interpolate(id),
|
||||
Sprite: e.interpolate(item.Sprite),
|
||||
X: item.X,
|
||||
Y: item.Y,
|
||||
Selectable: selectable,
|
||||
Commands: item.Commands,
|
||||
})
|
||||
}
|
||||
if len(activeItems) == 0 {
|
||||
e.fail(fmt.Errorf("menu command has no visible items"))
|
||||
return
|
||||
}
|
||||
selected := firstSelectable(activeItems)
|
||||
if selected == -1 {
|
||||
e.fail(fmt.Errorf("menu command has no selectable items"))
|
||||
return
|
||||
}
|
||||
e.state.Choices = nil
|
||||
e.state.Selected = 0
|
||||
e.state.MenuItems = activeItems
|
||||
e.state.MenuSelected = selected
|
||||
e.state.MenuActive = true
|
||||
e.wait = waitingMenu
|
||||
}
|
||||
|
||||
func firstSelectable(items []ActiveMenuItem) int {
|
||||
for i, item := range items {
|
||||
if item.Selectable {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func (e *Engine) findFirstSelectableMenu() int {
|
||||
return firstSelectable(e.state.MenuItems)
|
||||
}
|
||||
|
||||
func (e *Engine) convertSetValue(raw any) (VarValue, error) {
|
||||
if s, ok := raw.(string); ok {
|
||||
return NewVarValue(e.interpolate(s))
|
||||
}
|
||||
return NewVarValue(raw)
|
||||
}
|
||||
|
||||
func (e *Engine) jumpTo(sceneID string) {
|
||||
if sceneID == "" {
|
||||
e.fail(fmt.Errorf("goto target is empty"))
|
||||
return
|
||||
}
|
||||
if _, ok := e.sceneByID[sceneID]; !ok {
|
||||
e.fail(fmt.Errorf("goto target scene does not exist: %s", sceneID))
|
||||
return
|
||||
}
|
||||
e.sceneID = sceneID
|
||||
e.index = 0
|
||||
e.pending = nil
|
||||
e.state.Choices = nil
|
||||
e.state.Selected = 0
|
||||
e.clearMenuState()
|
||||
}
|
||||
|
||||
func (e *Engine) clearMenuState() {
|
||||
e.state.MenuItems = nil
|
||||
e.state.MenuSelected = -1
|
||||
e.state.MenuActive = false
|
||||
}
|
||||
|
||||
func (e *Engine) fail(err error) {
|
||||
e.onEndError = err
|
||||
e.state.Dialogue = Dialogue{Speaker: e.texts.EngineErrorSpeaker, Text: err.Error()}
|
||||
e.state.Choices = nil
|
||||
e.clearMenuState()
|
||||
e.wait = waitingEnd
|
||||
}
|
||||
|
||||
func (e *Engine) interpolate(s string) string {
|
||||
result := s
|
||||
for k, v := range e.vars {
|
||||
token := "{" + k + "}"
|
||||
result = strings.ReplaceAll(result, token, v.String())
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (e *Engine) evalCondition(cond string) (bool, error) {
|
||||
key, op, rawRight, err := parseCondition(cond)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
left, ok := e.vars[key]
|
||||
if !ok {
|
||||
left = VarValue{Type: "string", Str: ""}
|
||||
}
|
||||
right := parseLiteral(rawRight)
|
||||
|
||||
lf, lok := left.asFloat()
|
||||
rf, rok := right.asFloat()
|
||||
numeric := lok && rok
|
||||
|
||||
switch op {
|
||||
case "==":
|
||||
if numeric {
|
||||
return lf == rf, nil
|
||||
}
|
||||
if left.Type != right.Type {
|
||||
return false, nil
|
||||
}
|
||||
return left.String() == right.String(), nil
|
||||
case "!=":
|
||||
if numeric {
|
||||
return lf != rf, nil
|
||||
}
|
||||
if left.Type != right.Type {
|
||||
return true, nil
|
||||
}
|
||||
return left.String() != right.String(), nil
|
||||
case ">":
|
||||
if !numeric {
|
||||
return false, fmt.Errorf("operator > requires int/float operands")
|
||||
}
|
||||
return lf > rf, nil
|
||||
case "<":
|
||||
if !numeric {
|
||||
return false, fmt.Errorf("operator < requires int/float operands")
|
||||
}
|
||||
return lf < rf, nil
|
||||
case ">=":
|
||||
if !numeric {
|
||||
return false, fmt.Errorf("operator >= requires int/float operands")
|
||||
}
|
||||
return lf >= rf, nil
|
||||
case "<=":
|
||||
if !numeric {
|
||||
return false, fmt.Errorf("operator <= requires int/float operands")
|
||||
}
|
||||
return lf <= rf, nil
|
||||
default:
|
||||
return false, fmt.Errorf("unknown operator %q", op)
|
||||
}
|
||||
}
|
||||
|
||||
func parseCondition(cond string) (key, op, value string, err error) {
|
||||
ops := []string{"==", "!=", ">=", "<=", ">", "<"}
|
||||
for _, candidate := range ops {
|
||||
if idx := strings.Index(cond, candidate); idx >= 0 {
|
||||
left := strings.TrimSpace(cond[:idx])
|
||||
right := strings.TrimSpace(cond[idx+len(candidate):])
|
||||
if left == "" || right == "" {
|
||||
return "", "", "", fmt.Errorf("expected format: <key> <op> <value>")
|
||||
}
|
||||
return left, candidate, right, nil
|
||||
}
|
||||
}
|
||||
return "", "", "", fmt.Errorf("expected format: <key> <op> <value>")
|
||||
}
|
||||
Reference in New Issue
Block a user