735 lines
17 KiB
Go
735 lines
17 KiB
Go
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>")
|
|
}
|