diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 7ff01c0..a81077a 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -10,6 +10,10 @@ ### Tests Not Performed +### CI Checks + +- [ ] `make ci` passes locally + ### AI Assisted - [ ] Yes diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..04e52f4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + ci: + name: Build, Test, Lint + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.24" + + - name: Install golangci-lint + uses: golangci/golangci-lint-action@v7 + with: + version: v2.10 + install-mode: binary + args: --help + + - name: Run CI + run: make ci diff --git a/.gitignore b/.gitignore index 6d38adf..4437dcb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Compiled binaries /questcore +/bin/ *.exe *.dll *.so diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..ad2f24e --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,31 @@ +version: "2" + +run: + timeout: 3m + go: "1.24" + +linters: + default: none + enable: + - govet + - errcheck + - staticcheck + - unused + - ineffassign + - gocritic + - misspell + + settings: + gocritic: + disabled-checks: + - captLocal # L is conventional in gopher-lua codebases + + exclusions: + generated: lax + presets: + - comments + - std-error-handling + rules: + - path: _test\.go + linters: + - errcheck diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..37a911f --- /dev/null +++ b/Makefile @@ -0,0 +1,46 @@ +# Makefile for QuestCore +# Single source of truth for build, test, and lint checks. +# Used by both local development and GitHub Actions CI. + +BINARY := bin/questcore +GO := go +GOFLAGS := -v +TIMEOUT := 120s + +.PHONY: build test lint vet fmt-check fmt ci clean + +## build: Compile the questcore binary +build: + $(GO) build $(GOFLAGS) -o $(BINARY) ./cmd/questcore + +## test: Run all tests with race detection +test: + $(GO) test $(GOFLAGS) -timeout $(TIMEOUT) -race ./... + +## lint: Run golangci-lint +lint: + golangci-lint run + +## vet: Run go vet +vet: + $(GO) vet ./... + +## fmt-check: Check that all Go files are gofmt-formatted +fmt-check: + @unformatted=$$(gofmt -l .); \ + if [ -n "$$unformatted" ]; then \ + echo "Unformatted files:"; \ + echo "$$unformatted"; \ + exit 1; \ + fi + +## fmt: Format all Go files in place +fmt: + gofmt -w . + +## ci: Run the full check pipeline (same as GitHub Actions) +ci: fmt-check vet lint build test + +## clean: Remove build artifacts +clean: + rm -rf bin/ diff --git a/docs/plans/ci-setup.md b/docs/plans/ci-setup.md new file mode 100644 index 0000000..287a5ab --- /dev/null +++ b/docs/plans/ci-setup.md @@ -0,0 +1,51 @@ +# CI/CD Setup — GitHub Actions + Makefile + golangci-lint + +## Goal + +Add automated build/test/lint gates for pull requests and post-merge validation +on main. Adopt a "local CI" philosophy where the Makefile is the single source +of truth — GitHub Actions calls `make ci`, the same command developers run locally. + +## Approach + +### Trunk-Based Development + +- All development in short-lived feature branches off `main` +- PRs merge to `main` after CI passes +- Post-merge CI on `main` for validation + +### Local CI Parity + +- `Makefile` defines all check targets: `build`, `test`, `lint`, `vet`, `fmt-check` +- `make ci` runs the full pipeline (fmt-check -> vet -> lint -> build -> test) +- GitHub Actions workflow calls `make ci` — identical to local +- `CI` env var (set automatically by GitHub Actions) available if behavior needs to diverge + +### Linting + +- golangci-lint v2 with practical linter set: govet, errcheck, staticcheck, + unused, gosimple, ineffassign, typecheck, gocritic, misspell +- errcheck suppressed in test files +- No pedantic style linters (wsl, nlreturn, funlen, etc.) + +## Files + +| File | Action | Purpose | +|------|--------|---------| +| `.github/workflows/ci.yml` | Create | GitHub Actions workflow calling `make ci` | +| `Makefile` | Create | Build/test/lint targets for local and CI use | +| `.golangci.yml` | Create | golangci-lint v2 configuration | +| `.gitignore` | Update | Add `/bin/` for Makefile build output | +| `.github/pull_request_template.md` | Update | Add CI checklist item | +| 6 Go source files | Update | Fix existing `gofmt` violations | + +## Task List + +- [x] Create this plan document +- [ ] Create feature branch `ci/github-actions-setup` +- [ ] Fix `gofmt` violations (`gofmt -w .`) +- [ ] Create `.golangci.yml` and fix lint issues +- [ ] Create `Makefile` + update `.gitignore` +- [ ] Create `.github/workflows/ci.yml` + update PR template +- [ ] Verify `make ci` passes locally +- [ ] Push branch and open PR diff --git a/engine/parser/parser.go b/engine/parser/parser.go index d94ec2d..4e8156a 100644 --- a/engine/parser/parser.go +++ b/engine/parser/parser.go @@ -110,44 +110,44 @@ var verbAliases = map[string]string{ "quaff": "drink", // Miscellaneous - "inv": "inventory", - "i": "inventory", - "z": "wait", - "smell": "smell", - "sniff": "smell", - "listen": "listen", - "hear": "listen", - "touch": "touch", - "feel": "touch", - "rub": "touch", - "climb": "climb", - "scale": "climb", - "jump": "jump", - "leap": "jump", - "hop": "jump", - "unlock": "unlock", - "tie": "tie", - "fasten": "tie", - "attach": "tie", - "untie": "untie", - "detach": "untie", - "release": "untie", - "wear": "wear", - "don": "wear", - "wave": "wave", - "sing": "sing", - "pray": "pray", - "sleep": "sleep", - "nap": "sleep", - "rest": "sleep", - "knock": "knock", - "rap": "knock", - "yell": "yell", - "scream": "yell", - "shout": "yell", - "swim": "swim", - "dive": "swim", - "buy": "buy", + "inv": "inventory", + "i": "inventory", + "z": "wait", + "smell": "smell", + "sniff": "smell", + "listen": "listen", + "hear": "listen", + "touch": "touch", + "feel": "touch", + "rub": "touch", + "climb": "climb", + "scale": "climb", + "jump": "jump", + "leap": "jump", + "hop": "jump", + "unlock": "unlock", + "tie": "tie", + "fasten": "tie", + "attach": "tie", + "untie": "untie", + "detach": "untie", + "release": "untie", + "wear": "wear", + "don": "wear", + "wave": "wave", + "sing": "sing", + "pray": "pray", + "sleep": "sleep", + "nap": "sleep", + "rest": "sleep", + "knock": "knock", + "rap": "knock", + "yell": "yell", + "scream": "yell", + "shout": "yell", + "swim": "swim", + "dive": "swim", + "buy": "buy", "purchase": "buy", } diff --git a/loader/compile.go b/loader/compile.go index cf13612..4380138 100644 --- a/loader/compile.go +++ b/loader/compile.go @@ -19,8 +19,8 @@ type rawRoom struct { // rawEntity holds an entity table before compilation. type rawEntity struct { - id string - kind string + id string + kind string table *lua.LTable } @@ -49,15 +49,6 @@ func getString(tbl *lua.LTable, key string) string { return "" } -// getBool returns a bool field from a Lua table, or the default if missing. -func getBool(tbl *lua.LTable, key string, def bool) bool { - v := tbl.RawGetString(key) - if b, ok := v.(lua.LBool); ok { - return bool(b) - } - return def -} - // getNumber returns a numeric field from a Lua table, or 0 if missing. func getNumber(tbl *lua.LTable, key string) float64 { v := tbl.RawGetString(key) @@ -258,8 +249,8 @@ func compileRoom(raw rawRoom) (types.RoomDef, []string, error) { func compileEntity(raw rawEntity) (types.EntityDef, []string, error) { tbl := raw.table entity := types.EntityDef{ - ID: raw.id, - Kind: raw.kind, + ID: raw.id, + Kind: raw.kind, Props: map[string]any{}, } @@ -349,9 +340,9 @@ func compileRule(raw rawRule) (types.RuleDef, error) { func compileMatchCriteria(tbl *lua.LTable) types.MatchCriteria { mc := types.MatchCriteria{ - Verb: getString(tbl, "verb"), - Object: getString(tbl, "object"), - Target: getString(tbl, "target"), + Verb: getString(tbl, "verb"), + Object: getString(tbl, "object"), + Target: getString(tbl, "target"), ObjectKind: getString(tbl, "object_kind"), } if tp := getTable(tbl, "target_prop"); tp != nil { diff --git a/loader/loader_test.go b/loader/loader_test.go index 7f550ab..d7d330e 100644 --- a/loader/loader_test.go +++ b/loader/loader_test.go @@ -139,10 +139,8 @@ func TestLoad_FullGame(t *testing.T) { // Handlers. if len(defs.Handlers) != 1 { t.Errorf("expected 1 handler, got %d", len(defs.Handlers)) - } else { - if defs.Handlers[0].EventType != "door_unlocked" { - t.Errorf("handler event = %q", defs.Handlers[0].EventType) - } + } else if defs.Handlers[0].EventType != "door_unlocked" { + t.Errorf("handler event = %q", defs.Handlers[0].EventType) } } diff --git a/loader/validate.go b/loader/validate.go index 7e2bf6d..a321de9 100644 --- a/loader/validate.go +++ b/loader/validate.go @@ -22,33 +22,33 @@ func (e *ValidationError) Error() string { // Known effect types. var validEffectTypes = map[string]bool{ - "say": true, - "give_item": true, - "remove_item": true, - "set_flag": true, - "inc_counter": true, - "set_counter": true, - "set_prop": true, - "move_entity": true, - "move_player": true, - "open_exit": true, - "close_exit": true, - "emit_event": true, - "start_dialogue": true, - "stop": true, + "say": true, + "give_item": true, + "remove_item": true, + "set_flag": true, + "inc_counter": true, + "set_counter": true, + "set_prop": true, + "move_entity": true, + "move_player": true, + "open_exit": true, + "close_exit": true, + "emit_event": true, + "start_dialogue": true, + "stop": true, } // Known condition types. var validConditionTypes = map[string]bool{ - "has_item": true, - "flag_set": true, - "flag_not": true, - "flag_is": true, - "in_room": true, - "prop_is": true, - "counter_gt": true, - "counter_lt": true, - "not": true, + "has_item": true, + "flag_set": true, + "flag_not": true, + "flag_is": true, + "in_room": true, + "prop_is": true, + "counter_gt": true, + "counter_lt": true, + "not": true, } // validate checks the compiled defs for referential integrity and consistency. diff --git a/tui/style.go b/tui/style.go index 466f86f..b301e23 100644 --- a/tui/style.go +++ b/tui/style.go @@ -103,11 +103,6 @@ func styledYouSee(line string) string { return styleRoomDesc.Render(prefix) + styleYouSee.Render(line[len(prefix):]) } -// styledPlayerInput renders the echoed player input in green with "> " prefix. -func styledPlayerInput(input string) string { - return stylePlayerInput.Render("> " + input) -} - // styledSystemMsg renders a system message in gray with brackets. func styledSystemMsg(text string) string { return styleSystem.Render("[" + text + "]") diff --git a/tui/tui.go b/tui/tui.go index 69214be..277dd14 100644 --- a/tui/tui.go +++ b/tui/tui.go @@ -28,8 +28,8 @@ type rawLine struct { // Model is the Bubble Tea model for the QuestCore TUI. type Model struct { - engine *engine.Engine - defs *state.Defs + engine *engine.Engine + defs *state.Defs viewport viewport.Model input textinput.Model @@ -492,11 +492,11 @@ func (m *Model) formatTrace(result types.Result) []string { // (we use those for input history). func viewportKeyMap() viewport.KeyMap { return viewport.KeyMap{ - PageDown: key.NewBinding(key.WithKeys("pgdown")), - PageUp: key.NewBinding(key.WithKeys("pgup")), + PageDown: key.NewBinding(key.WithKeys("pgdown")), + PageUp: key.NewBinding(key.WithKeys("pgup")), HalfPageDown: key.NewBinding(key.WithKeys("ctrl+d")), HalfPageUp: key.NewBinding(key.WithKeys("ctrl+u")), - Up: key.NewBinding(key.WithDisabled()), - Down: key.NewBinding(key.WithDisabled()), + Up: key.NewBinding(key.WithDisabled()), + Down: key.NewBinding(key.WithDisabled()), } } diff --git a/tui/tui_test.go b/tui/tui_test.go index b8d8507..3b4f59b 100644 --- a/tui/tui_test.go +++ b/tui/tui_test.go @@ -59,9 +59,9 @@ func TestContainsQuotedSpeech(t *testing.T) { want bool }{ {"'Hello, adventurer. Welcome to the castle.'", true}, - {"It's a door.", false}, // short quote segment - {"No quotes here.", false}, // no quotes at all - {"'Hi'", false}, // too short + {"It's a door.", false}, // short quote segment + {"No quotes here.", false}, // no quotes at all + {"'Hi'", false}, // too short {"She says 'the crown is lost forever, you must find it.'", true}, } for _, tt := range tests { diff --git a/types/types.go b/types/types.go index 561ff2b..524e60f 100644 --- a/types/types.go +++ b/types/types.go @@ -67,9 +67,9 @@ type TopicDef struct { // EntityDef is the base definition of a world entity (item, NPC, etc.). type EntityDef struct { ID string - Kind string // "item", "npc", "entity", "room" - Props map[string]any // base properties from Lua - Rules []RuleDef // rules scoped to this entity + Kind string // "item", "npc", "entity", "room" + Props map[string]any // base properties from Lua + Rules []RuleDef // rules scoped to this entity Topics map[string]TopicDef // NPC topics (nil for non-NPCs) }