client io architecture
This commit is contained in:
@@ -0,0 +1,922 @@
|
||||
# AGENTS.md
|
||||
|
||||
> This file defines how Codex and other coding agents should operate in this repository.
|
||||
> It is intentionally strict, verbose, and easy to trim down.
|
||||
> If any instruction here conflicts with an explicit user request in the chat, the user request wins.
|
||||
> If any instruction here conflicts with a deeper repository-local instruction in a subdirectory `AGENTS.md`, the deeper file wins for files inside that subtree.
|
||||
|
||||
---
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
This repository is developed primarily in Go.
|
||||
|
||||
The agent must optimize for:
|
||||
|
||||
- correctness before speed,
|
||||
- readability before cleverness,
|
||||
- explicit behavior before hidden magic,
|
||||
- small, reviewable changes,
|
||||
- reproducible builds and tests,
|
||||
- clear written reasoning for non-obvious decisions.
|
||||
|
||||
The agent should behave like a careful senior Go engineer working in an existing codebase with real maintenance costs.
|
||||
|
||||
---
|
||||
|
||||
## 2. Core operating rules
|
||||
|
||||
### 2.1 Main priorities
|
||||
|
||||
When making changes, follow this order of priority:
|
||||
|
||||
1. Preserve correctness.
|
||||
2. Preserve or improve clarity.
|
||||
3. Preserve compatibility unless the task explicitly allows breaking changes.
|
||||
4. Keep the diff minimal.
|
||||
5. Keep the implementation idiomatic for modern Go.
|
||||
6. Keep performance reasonable, but do not micro-optimize without evidence.
|
||||
|
||||
### 2.2 What the agent must not do
|
||||
|
||||
The agent must not:
|
||||
|
||||
- rewrite large areas of code without clear need,
|
||||
- introduce speculative abstractions,
|
||||
- rename many symbols “for cleanliness” unless required,
|
||||
- mix unrelated refactors with the requested task,
|
||||
- silently change public behavior,
|
||||
- silently change wire formats, database semantics, or API contracts,
|
||||
- add dependencies unless necessary,
|
||||
- invent requirements not stated by the user or codebase,
|
||||
- leave TODOs instead of implementing the requested behavior, unless explicitly asked,
|
||||
- claim code was tested if it was not actually tested,
|
||||
- claim a root cause without evidence,
|
||||
- fix extra bugs opportunistically unless they are tightly adjacent and clearly explained.
|
||||
|
||||
### 2.3 Expected default behavior
|
||||
|
||||
Unless the user asks otherwise, the agent should:
|
||||
|
||||
- inspect the relevant code path before editing,
|
||||
- understand current behavior before proposing changes,
|
||||
- prefer the smallest correct patch,
|
||||
- update or add tests for every functional change,
|
||||
- keep public interfaces stable,
|
||||
- preserve log/event/metric semantics unless a change is needed,
|
||||
- explain assumptions,
|
||||
- mention trade-offs when they matter.
|
||||
|
||||
### 2.3 Expected documentation behavior
|
||||
|
||||
Unless the user asks otherwise, the agent should:
|
||||
|
||||
- supply added packages, types, funcs, consts and vars with a commentaries explaining its purpose and behavior,
|
||||
- supply public functions with a more comprehensive commentary and supplemental funcs with more concise comments,
|
||||
- provide comments respecting the Go Doc Comments syntax,
|
||||
- provide comments only in English language,
|
||||
- translate any non-English commetraries met in existing code,
|
||||
- correct obvious grammatical and style errors in existing commentaries.
|
||||
|
||||
---
|
||||
|
||||
## 3. Repository familiarization workflow
|
||||
|
||||
Before making non-trivial changes, the agent should quickly map the local conventions.
|
||||
|
||||
### 3.1 Files to inspect first
|
||||
|
||||
Prefer inspecting, when present:
|
||||
|
||||
- `go.mod`
|
||||
- `go.sum`
|
||||
- `README.md`
|
||||
- `Makefile`
|
||||
- `Taskfile.yml` / `Taskfile.yaml`
|
||||
- `.golangci.yml` / `.golangci.yaml`
|
||||
- `.editorconfig`
|
||||
- `buf.yaml`
|
||||
- `buf.gen.yaml`
|
||||
- `Dockerfile*`
|
||||
- `compose*.yml`
|
||||
- CI files under `.github/workflows/`, `.gitlab-ci.yml`, etc.
|
||||
- migration directories
|
||||
- existing `AGENTS.md` files in subdirectories
|
||||
- representative files in the affected package
|
||||
- representative tests in the affected package
|
||||
|
||||
### 3.2 Conventions to infer
|
||||
|
||||
The agent should infer and follow:
|
||||
|
||||
- package layout style,
|
||||
- naming conventions,
|
||||
- error handling conventions,
|
||||
- logging conventions,
|
||||
- context usage conventions,
|
||||
- test style,
|
||||
- benchmark style,
|
||||
- dependency injection pattern,
|
||||
- API versioning conventions,
|
||||
- DTO/model separation style,
|
||||
- storage and transaction conventions,
|
||||
- lint and formatting requirements.
|
||||
|
||||
If conventions are inconsistent, prefer the one used in the closest affected code.
|
||||
|
||||
---
|
||||
|
||||
## 4. Scope control
|
||||
|
||||
### 4.1 Stay within scope
|
||||
|
||||
The agent must solve the user’s request directly and avoid unrelated cleanup.
|
||||
|
||||
Allowed adjacent changes:
|
||||
|
||||
- fixing a test broken by the main change,
|
||||
- adding a missing helper required by the main change,
|
||||
- small refactors necessary to make the change safe,
|
||||
- updating documentation directly affected by the change.
|
||||
|
||||
Not allowed without explicit justification:
|
||||
|
||||
- formatting unrelated files,
|
||||
- reorganizing package structure,
|
||||
- replacing libraries,
|
||||
- changing error taxonomy globally,
|
||||
- changing logging framework,
|
||||
- broad “modernization” passes,
|
||||
- large dependency bumps.
|
||||
|
||||
### 4.2 When the requested change is underspecified
|
||||
|
||||
If details are missing, the agent should:
|
||||
|
||||
1. infer the most conservative behavior from existing code,
|
||||
2. avoid breaking current behavior,
|
||||
3. document the chosen assumption in the final response.
|
||||
|
||||
Do not block on avoidable clarification if a reasonable implementation path exists.
|
||||
|
||||
---
|
||||
|
||||
## 5. Go version and language guidance
|
||||
|
||||
### 5.1 Target version
|
||||
|
||||
Target the Go version declared in `go.mod`.
|
||||
|
||||
If the repository does not make this obvious, assume modern stable Go and avoid experimental features unless already present.
|
||||
|
||||
### 5.2 Idiomatic Go requirements
|
||||
|
||||
The agent should prefer:
|
||||
|
||||
- simple package APIs,
|
||||
- concrete types when interfaces are not needed,
|
||||
- small interfaces defined by consumers,
|
||||
- explicit error handling,
|
||||
- early returns,
|
||||
- table-driven tests where appropriate,
|
||||
- `context.Context` as the first parameter for request-scoped operations,
|
||||
- `errors.AsType` first, `errors.Is` / `errors.As` last,
|
||||
- standard library first.
|
||||
|
||||
The agent should avoid:
|
||||
|
||||
- unnecessary generics,
|
||||
- unnecessary reflection,
|
||||
- hidden global state,
|
||||
- panics for expected errors,
|
||||
- overuse of empty interfaces or `any`,
|
||||
- deeply nested control flow,
|
||||
- concurrency without clear benefit,
|
||||
- channel-based designs where a simple call flow is better.
|
||||
|
||||
### 5.3 Style details
|
||||
|
||||
Prefer:
|
||||
|
||||
- short, focused functions,
|
||||
- package-level cohesion,
|
||||
- exported identifiers only when needed,
|
||||
- comments for exported symbols,
|
||||
- comments explaining “why”, not narrating trivial code,
|
||||
- stable and unsurprising zero values where appropriate.
|
||||
|
||||
Avoid:
|
||||
|
||||
- single-letter names except tight local scopes,
|
||||
- clever helper layers that obscure flow,
|
||||
- Boolean parameter lists that are hard to read,
|
||||
- hidden side effects,
|
||||
- magic constants without names.
|
||||
|
||||
---
|
||||
|
||||
## 6. Editing rules for Go code
|
||||
|
||||
### 6.1 Function and type changes
|
||||
|
||||
When modifying a function or method, the agent should:
|
||||
|
||||
- preserve signature compatibility unless the task explicitly requires change,
|
||||
- preserve context and cancellation behavior,
|
||||
- preserve caller expectations,
|
||||
- update all call sites,
|
||||
- update tests that express expected behavior.
|
||||
|
||||
When adding new exported API:
|
||||
|
||||
- keep it minimal,
|
||||
- document it,
|
||||
- justify why export is needed,
|
||||
- prefer package-private helpers if external use is not required.
|
||||
|
||||
### 6.2 Error handling
|
||||
|
||||
The agent must:
|
||||
|
||||
- return errors, not swallow them,
|
||||
- wrap errors when adding useful context,
|
||||
- avoid duplicative wrapping,
|
||||
- preserve sentinel errors or typed errors already used in the codebase,
|
||||
- use `%w` correctly,
|
||||
- not log and return the same error at multiple layers unless the codebase explicitly does that.
|
||||
|
||||
If the codebase distinguishes user-facing, domain, transport, and storage errors, preserve that separation.
|
||||
|
||||
### 6.3 Context usage
|
||||
|
||||
The agent must:
|
||||
|
||||
- pass context through relevant call chains,
|
||||
- not store contexts in structs,
|
||||
- not use `context.Background()` in request flows unless clearly appropriate,
|
||||
- respect cancellation and deadlines when existing code expects that,
|
||||
- avoid creating child contexts unnecessarily.
|
||||
|
||||
### 6.4 Concurrency
|
||||
|
||||
Only introduce concurrency if it clearly improves the requested behavior and does not degrade maintainability.
|
||||
|
||||
If adding concurrency, the agent must consider:
|
||||
|
||||
- cancellation,
|
||||
- data races,
|
||||
- goroutine lifetime,
|
||||
- bounded parallelism,
|
||||
- error propagation,
|
||||
- testability,
|
||||
- deterministic shutdown.
|
||||
|
||||
Avoid spawning goroutines without a clear ownership model.
|
||||
|
||||
### 6.5 Logging and observability
|
||||
|
||||
Follow existing repository conventions.
|
||||
|
||||
The agent should:
|
||||
|
||||
- keep logs structured if the codebase uses structured logging,
|
||||
- avoid logging sensitive values,
|
||||
- avoid noisy logs in hot paths,
|
||||
- preserve stable field names when logs are used operationally,
|
||||
- update metrics/traces only when directly relevant.
|
||||
|
||||
Do not add logs as a substitute for error handling.
|
||||
|
||||
---
|
||||
|
||||
## 7. Testing requirements
|
||||
|
||||
### 7.1 General rule
|
||||
|
||||
Every behavior change should be covered by tests unless the repository clearly does not test that layer.
|
||||
|
||||
A functional code change without tests requires a clear reason in the final response.
|
||||
|
||||
### 7.2 Preferred testing style
|
||||
|
||||
Prefer:
|
||||
|
||||
- table-driven tests,
|
||||
- focused tests per behavior,
|
||||
- `testify` for assertions and requirements if the repository already uses it or if new tests are added and no conflicting convention exists,
|
||||
- deterministic tests,
|
||||
- subtests with meaningful names,
|
||||
- minimal fixtures,
|
||||
- clear failure messages.
|
||||
|
||||
### 7.3 What tests should verify
|
||||
|
||||
Tests should verify:
|
||||
|
||||
- externally observable behavior,
|
||||
- error cases,
|
||||
- edge cases,
|
||||
- nil / empty / zero-value behavior where relevant,
|
||||
- backward compatibility where relevant,
|
||||
- concurrency behavior if changed,
|
||||
- serialization/deserialization boundaries if relevant.
|
||||
|
||||
### 7.4 What tests should avoid
|
||||
|
||||
Avoid tests that are:
|
||||
|
||||
- tightly coupled to private implementation details without need,
|
||||
- flaky,
|
||||
- timing-sensitive without control,
|
||||
- dependent on wall clock when fake time can be used,
|
||||
- dependent on random behavior without fixed seed,
|
||||
- dependent on external services unless the repository already uses integration test infrastructure.
|
||||
|
||||
### 7.5 Test commands
|
||||
|
||||
Prefer repository-native commands first.
|
||||
|
||||
Common examples:
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
go test ./... -race
|
||||
go test ./... -cover
|
||||
```
|
||||
|
||||
If a narrower command is sufficient, use the smallest command that provides confidence.
|
||||
|
||||
---
|
||||
|
||||
## 8. Dependency policy
|
||||
|
||||
### 8.1 Default rule
|
||||
|
||||
Prefer the Go standard library and existing repository dependencies.
|
||||
|
||||
Do not add a new dependency unless it provides clear value that is difficult to replicate safely with existing tools.
|
||||
|
||||
### 8.2 If adding a dependency is necessary
|
||||
|
||||
The agent must:
|
||||
|
||||
- choose a well-maintained package,
|
||||
- minimize dependency surface,
|
||||
- avoid dependency overlap,
|
||||
- explain why the new dependency is needed,
|
||||
- update tests and usage accordingly.
|
||||
|
||||
Avoid adding heavy frameworks into lightweight packages.
|
||||
|
||||
---
|
||||
|
||||
## 9. Performance policy
|
||||
|
||||
### 9.1 Default stance
|
||||
|
||||
Do not optimize speculatively.
|
||||
|
||||
Prefer clear code first, then optimize only if:
|
||||
|
||||
- the task is explicitly performance-related,
|
||||
- the affected path is obviously hot,
|
||||
- profiling evidence is available,
|
||||
- the repository already treats this path as performance-sensitive.
|
||||
|
||||
### 9.2 When performance matters
|
||||
|
||||
The agent should consider:
|
||||
|
||||
- allocations,
|
||||
- copies,
|
||||
- unnecessary conversions,
|
||||
- lock contention,
|
||||
- query count,
|
||||
- I/O amplification,
|
||||
- algorithmic complexity.
|
||||
|
||||
If making a performance optimization, document the trade-off and preserve readability as much as possible.
|
||||
|
||||
---
|
||||
|
||||
## 10. API, wire format, and compatibility rules
|
||||
|
||||
### 10.1 Backward compatibility
|
||||
|
||||
Assume compatibility matters unless the task says otherwise.
|
||||
|
||||
The agent must not casually change:
|
||||
|
||||
- JSON field names,
|
||||
- protobuf field numbers,
|
||||
- SQL schema semantics,
|
||||
- HTTP status codes,
|
||||
- error codes,
|
||||
- event payloads,
|
||||
- config keys,
|
||||
- environment variable names,
|
||||
- CLI flags,
|
||||
- file formats.
|
||||
|
||||
### 10.2 If a breaking change is necessary
|
||||
|
||||
The agent should:
|
||||
|
||||
- keep the change localized,
|
||||
- update affected tests,
|
||||
- update docs and examples,
|
||||
- explicitly call out the break in the final response.
|
||||
|
||||
---
|
||||
|
||||
## 11. Database and persistence guidance
|
||||
|
||||
If the repository interacts with a database, the agent should preserve data safety first.
|
||||
|
||||
### 11.1 Queries and mutations
|
||||
|
||||
The agent must:
|
||||
|
||||
- understand existing transaction boundaries,
|
||||
- avoid introducing N+1 query patterns,
|
||||
- preserve idempotency where relevant,
|
||||
- preserve isolation expectations,
|
||||
- handle `sql.ErrNoRows` or equivalent consistently.
|
||||
|
||||
### 11.2 Migrations
|
||||
|
||||
If adding or changing migrations:
|
||||
|
||||
- make them forward-safe,
|
||||
- avoid destructive changes unless explicitly requested,
|
||||
- preserve rollback strategy if the repository uses one,
|
||||
- avoid combining schema and risky data backfills blindly,
|
||||
- update related models, queries, and tests.
|
||||
|
||||
### 11.3 Data correctness
|
||||
|
||||
The agent must be conservative with:
|
||||
|
||||
- nullability,
|
||||
- defaults,
|
||||
- unique constraints,
|
||||
- indexes,
|
||||
- timestamp semantics,
|
||||
- timezone handling,
|
||||
- soft-delete semantics.
|
||||
|
||||
---
|
||||
|
||||
## 12. HTTP / RPC / messaging guidance
|
||||
|
||||
### 12.1 Handlers and transport code
|
||||
|
||||
When editing transport-layer code, preserve:
|
||||
|
||||
- status code semantics,
|
||||
- request validation behavior,
|
||||
- response shape,
|
||||
- middleware expectations,
|
||||
- authn/authz boundaries,
|
||||
- timeout and cancellation behavior.
|
||||
|
||||
### 12.2 Serialization
|
||||
|
||||
The agent must:
|
||||
|
||||
- keep wire compatibility,
|
||||
- avoid changing omitempty behavior casually,
|
||||
- handle unknown fields according to existing patterns,
|
||||
- preserve canonical formats if already established.
|
||||
|
||||
### 12.3 Messaging / events
|
||||
|
||||
For queues, streams, or pub/sub:
|
||||
|
||||
- preserve event contract stability,
|
||||
- preserve delivery assumptions,
|
||||
- preserve idempotency handling,
|
||||
- avoid changing partitioning or keys without reason.
|
||||
|
||||
---
|
||||
|
||||
## 13. CLI and developer-experience guidance
|
||||
|
||||
If the repository includes CLI commands or tooling, the agent should preserve UX consistency.
|
||||
|
||||
Do not casually change:
|
||||
|
||||
- command names,
|
||||
- flag names,
|
||||
- exit code semantics,
|
||||
- help text style,
|
||||
- config resolution order.
|
||||
|
||||
When adding a flag or command:
|
||||
|
||||
- keep naming consistent,
|
||||
- document defaults,
|
||||
- handle invalid input cleanly,
|
||||
- add tests where feasible.
|
||||
|
||||
---
|
||||
|
||||
## 14. Security and secrets handling
|
||||
|
||||
The agent must treat security as a default concern.
|
||||
|
||||
### 14.1 Must avoid
|
||||
|
||||
Never:
|
||||
|
||||
- commit secrets,
|
||||
- log tokens, passwords, cookies, private keys, or connection strings,
|
||||
- weaken auth checks casually,
|
||||
- disable TLS verification without explicit reason,
|
||||
- interpolate untrusted input into shell/SQL/HTML/paths unsafely,
|
||||
- introduce path traversal risks,
|
||||
- trust user input without validation.
|
||||
|
||||
### 14.2 Must consider
|
||||
|
||||
Consider:
|
||||
|
||||
- input validation,
|
||||
- output encoding,
|
||||
- least privilege,
|
||||
- SSRF risk,
|
||||
- command injection,
|
||||
- SQL injection,
|
||||
- deserialization safety,
|
||||
- sensitive data redaction,
|
||||
- constant-time comparisons where relevant,
|
||||
- secure defaults.
|
||||
|
||||
### 14.3 Authentication and authorization
|
||||
|
||||
Preserve existing auth boundaries.
|
||||
|
||||
If a task touches auth logic, the agent must be especially conservative and update tests for both allowed and denied cases.
|
||||
|
||||
---
|
||||
|
||||
## 15. Configuration guidance
|
||||
|
||||
The agent should preserve current configuration patterns.
|
||||
|
||||
Do not casually change:
|
||||
|
||||
- env var names,
|
||||
- precedence rules,
|
||||
- default values,
|
||||
- required/optional behavior,
|
||||
- config file schema.
|
||||
|
||||
When adding configuration:
|
||||
|
||||
- prefer clear names,
|
||||
- define sane defaults,
|
||||
- validate values,
|
||||
- document behavior,
|
||||
- update examples if present.
|
||||
|
||||
---
|
||||
|
||||
## 16. Documentation update policy
|
||||
|
||||
Update documentation when the user-visible or developer-visible behavior changes.
|
||||
|
||||
Potential files to update:
|
||||
|
||||
- `README.md`
|
||||
- package docs
|
||||
- API docs
|
||||
- CLI help
|
||||
- examples
|
||||
- migration notes
|
||||
- deployment docs
|
||||
|
||||
Do not rewrite large docs unless necessary.
|
||||
|
||||
---
|
||||
|
||||
## 17. Commenting policy
|
||||
|
||||
### 17.1 Code comments
|
||||
|
||||
Use comments sparingly but effectively.
|
||||
|
||||
Add comments when:
|
||||
|
||||
- exporting a symbol,
|
||||
- explaining why a non-obvious approach is used,
|
||||
- documenting invariants,
|
||||
- clarifying ownership/lifecycle/concurrency rules.
|
||||
|
||||
Do not add comments that merely restate obvious code.
|
||||
|
||||
### 17.2 Commit-style explanations in response
|
||||
|
||||
In the final response, the agent should explain:
|
||||
|
||||
- what changed,
|
||||
- why it changed,
|
||||
- what assumptions were made,
|
||||
- what was tested,
|
||||
- any notable trade-offs.
|
||||
|
||||
---
|
||||
|
||||
## 18. How to present work in chat
|
||||
|
||||
When the agent responds with implementation details, it should be concise but complete.
|
||||
|
||||
### 18.1 Final response should usually include
|
||||
|
||||
- a short summary of the change,
|
||||
- the key files modified,
|
||||
- important reasoning or assumptions,
|
||||
- test commands executed,
|
||||
- any remaining risks or follow-ups if relevant.
|
||||
|
||||
### 18.2 The agent must not
|
||||
|
||||
- dump huge irrelevant code blocks if files were already edited,
|
||||
- exaggerate confidence,
|
||||
- claim tests passed if they were not run,
|
||||
- omit important caveats.
|
||||
|
||||
---
|
||||
|
||||
## 19. Patch construction guidance
|
||||
|
||||
### 19.1 Preferred change shape
|
||||
|
||||
Prefer a sequence like:
|
||||
|
||||
1. smallest safe production change,
|
||||
2. tests that capture behavior,
|
||||
3. minimal docs update if needed.
|
||||
|
||||
### 19.2 Refactoring threshold
|
||||
|
||||
Refactor only when necessary to support the requested change.
|
||||
|
||||
Good reasons:
|
||||
|
||||
- current structure prevents a safe fix,
|
||||
- testability is too poor to validate behavior,
|
||||
- the bug stems from tangled responsibilities,
|
||||
- a small extraction materially reduces risk.
|
||||
|
||||
Bad reasons:
|
||||
|
||||
- personal style preference,
|
||||
- “cleaner architecture” ambitions,
|
||||
- speculative future use cases.
|
||||
|
||||
---
|
||||
|
||||
## 20. Large or risky changes
|
||||
|
||||
For changes with broad blast radius, the agent should be more conservative.
|
||||
|
||||
Examples:
|
||||
|
||||
- auth,
|
||||
- billing,
|
||||
- persistence,
|
||||
- migrations,
|
||||
- concurrency,
|
||||
- public APIs,
|
||||
- shared libraries,
|
||||
- critical hot paths.
|
||||
|
||||
In such cases, the agent should:
|
||||
|
||||
- minimize the changed surface area,
|
||||
- add focused regression coverage,
|
||||
- call out risk explicitly,
|
||||
- avoid mixing in refactors.
|
||||
|
||||
---
|
||||
|
||||
## 21. When the agent should stop and report limits
|
||||
|
||||
The agent should explicitly say so if:
|
||||
|
||||
- the repository is missing files needed to implement safely,
|
||||
- tests cannot be run in the environment,
|
||||
- behavior depends on unknown external systems,
|
||||
- a breaking design choice is required but unspecified,
|
||||
- the requested change would be unsafe without broader context.
|
||||
|
||||
In those cases, still provide the best grounded partial result possible.
|
||||
|
||||
---
|
||||
|
||||
## 22. Preferred workflow for bug fixes
|
||||
|
||||
When fixing a bug, the agent should generally follow this order:
|
||||
|
||||
1. identify the failing behavior,
|
||||
2. inspect the smallest relevant code path,
|
||||
3. preserve existing public contract,
|
||||
4. implement the minimal fix,
|
||||
5. add or update regression tests,
|
||||
6. verify no adjacent behavior was unintentionally changed.
|
||||
|
||||
If the root cause is uncertain, state that clearly and avoid overstating certainty.
|
||||
|
||||
---
|
||||
|
||||
## 23. Preferred workflow for new features
|
||||
|
||||
When implementing a feature, the agent should generally:
|
||||
|
||||
1. inspect similar existing features,
|
||||
2. match established architecture,
|
||||
3. add the smallest useful surface area,
|
||||
4. keep compatibility where possible,
|
||||
5. add tests for success and failure paths,
|
||||
6. update minimal necessary docs.
|
||||
|
||||
---
|
||||
|
||||
## 24. Preferred workflow for refactoring
|
||||
|
||||
For refactors, the agent must preserve behavior.
|
||||
|
||||
The agent should:
|
||||
|
||||
- keep refactors mechanical and reviewable,
|
||||
- avoid semantic drift,
|
||||
- maintain test coverage,
|
||||
- separate pure refactor from behavior change whenever practical.
|
||||
|
||||
If both are unavoidable in one patch, explain that clearly.
|
||||
|
||||
---
|
||||
|
||||
## 25. Monorepo / multi-package guidance
|
||||
|
||||
If this repository contains multiple services or packages, the agent should:
|
||||
|
||||
- change only the relevant module/package unless broader edits are required,
|
||||
- respect local conventions of the touched area,
|
||||
- check for local `AGENTS.md` files,
|
||||
- avoid introducing cross-package coupling casually.
|
||||
|
||||
---
|
||||
|
||||
## 26. File and package organization guidance
|
||||
|
||||
When adding new files:
|
||||
|
||||
- place them near the owning package,
|
||||
- use existing naming conventions,
|
||||
- avoid generic names like `common.go`, `helpers.go`, `utils.go` unless that pattern already exists,
|
||||
- keep package boundaries clear.
|
||||
|
||||
When adding helpers, prefer names tied to the domain or behavior.
|
||||
|
||||
---
|
||||
|
||||
## 27. Example Go-specific preferences
|
||||
|
||||
These are defaults unless the repository already uses a different style.
|
||||
|
||||
### 27.1 Error examples
|
||||
|
||||
Preferred:
|
||||
|
||||
```go
|
||||
func ParsePort(s string) (int, error) {
|
||||
port, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("parse port %q: %w", s, err)
|
||||
}
|
||||
if port < 1 || port > 65535 {
|
||||
return 0, fmt.Errorf("parse port %q: out of range", s)
|
||||
}
|
||||
return port, nil
|
||||
}
|
||||
```
|
||||
|
||||
Avoid:
|
||||
|
||||
```go
|
||||
func ParsePort(s string) (int, error) {
|
||||
i, _ := strconv.Atoi(s)
|
||||
return i, nil
|
||||
}
|
||||
```
|
||||
|
||||
### 27.2 Context examples
|
||||
|
||||
Preferred:
|
||||
|
||||
```go
|
||||
func (s *Service) Fetch(ctx context.Context, id string) (*Item, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.Fetch(ctx, id)
|
||||
}
|
||||
```
|
||||
|
||||
Avoid:
|
||||
|
||||
```go
|
||||
func (s *Service) Fetch(id string) (*Item, error) {
|
||||
return s.repo.Fetch(context.Background(), id)
|
||||
}
|
||||
```
|
||||
|
||||
### 27.3 Table-driven tests
|
||||
|
||||
Preferred:
|
||||
|
||||
```go
|
||||
func TestParsePort(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want int
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "valid", input: "8080", want: 8080},
|
||||
{name: "non-numeric", input: "abc", wantErr: true},
|
||||
{name: "out of range", input: "70000", wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := ParsePort(tt.input)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 28. Suggested command checklist
|
||||
|
||||
Before concluding, the agent should use the smallest relevant subset of these commands when available and appropriate:
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
go test ./... -race
|
||||
go test ./... -cover
|
||||
go vet ./...
|
||||
golangci-lint run
|
||||
staticcheck ./...
|
||||
go test ./path/to/pkg -run TestName -v
|
||||
```
|
||||
|
||||
Use repository-native wrappers first if they exist, for example:
|
||||
|
||||
```bash
|
||||
make test
|
||||
make lint
|
||||
task test
|
||||
task lint
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 29. Suggested final response template
|
||||
|
||||
Use this shape unless the user asked for something else:
|
||||
|
||||
1. What changed.
|
||||
2. Why it changed.
|
||||
3. Files touched.
|
||||
4. Tests run.
|
||||
5. Assumptions or caveats.
|
||||
|
||||
Be direct. Do not pad the response.
|
||||
|
||||
---
|
||||
|
||||
## 30. Bottom-line instruction
|
||||
|
||||
When in doubt, the agent should choose the safest change that:
|
||||
|
||||
- solves the actual user request,
|
||||
- matches existing repository conventions,
|
||||
- preserves compatibility,
|
||||
- adds or updates tests,
|
||||
- keeps the diff small and reviewable.
|
||||
+190
-11
@@ -1,24 +1,203 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"image"
|
||||
"sync"
|
||||
|
||||
"galaxy/client/world"
|
||||
"galaxy/connector"
|
||||
mc "galaxy/model/client"
|
||||
"galaxy/model/report"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/app"
|
||||
"fyne.io/fyne/v2/canvas"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
)
|
||||
|
||||
type ui struct {
|
||||
type client struct {
|
||||
conn connector.UIConnector
|
||||
app fyne.App
|
||||
window fyne.Window
|
||||
|
||||
world *world.World
|
||||
drawer *world.GGDrawer
|
||||
raster *canvas.Raster
|
||||
co *RasterCoalescer[world.RenderParams]
|
||||
pan *PanController
|
||||
|
||||
// Protected camera/options state (UI-facing). This is the "base" params snapshot.
|
||||
// Viewport/margins are NOT stored here; they come from raster draw callback.
|
||||
mu sync.RWMutex
|
||||
wp *world.RenderParams
|
||||
canvasScale float32
|
||||
|
||||
// Latest raster geometry metadata for correct event->pixel conversion:
|
||||
// - logical size: raster.Size() (Fyne units)
|
||||
// - pixel size: last (wPx,hPx) passed to draw callback
|
||||
metaMu sync.RWMutex
|
||||
lastRasterLogicW float32
|
||||
lastRasterLogicH float32
|
||||
lastRasterPxW int
|
||||
lastRasterPxH int
|
||||
lastCanvasScale float32 // optional, useful for debugging
|
||||
|
||||
// Snapshot of params actually used for the last render (includes viewport/margins).
|
||||
// Used for HitTest and to keep UI interactions consistent with what the user sees.
|
||||
lastRenderedMu sync.RWMutex
|
||||
lastRenderedParams world.RenderParams
|
||||
|
||||
// Indexing / backing-canvas caches (owned by client because it depends on UI geometry)
|
||||
lastIndexedViewportW int
|
||||
lastIndexedViewportH int
|
||||
lastIndexedZoomFp int
|
||||
|
||||
lastCanvasW int
|
||||
lastCanvasH int
|
||||
|
||||
viewportImg *image.RGBA
|
||||
viewportW int
|
||||
viewportH int
|
||||
|
||||
hits []world.Hit
|
||||
}
|
||||
|
||||
func NewUI() *ui {
|
||||
c := &ui{}
|
||||
c.app = app.New()
|
||||
c.window = c.app.NewWindow("Galaxy Plus")
|
||||
client := NewClient()
|
||||
client.BuildUI(c.window)
|
||||
return c
|
||||
func NewClient(conn connector.UIConnector, app fyne.App, settings mc.Settings) (mc.Client, error) {
|
||||
e := &client{
|
||||
conn: conn,
|
||||
app: app,
|
||||
window: app.NewWindow("Galaxy Plus"),
|
||||
world: nil,
|
||||
wp: &world.RenderParams{
|
||||
CameraZoom: 1.0,
|
||||
Options: &world.RenderOptions{DisableWrapScroll: false},
|
||||
},
|
||||
lastCanvasScale: 1.0,
|
||||
hits: make([]world.Hit, 5),
|
||||
}
|
||||
|
||||
e.drawer = &world.GGDrawer{DC: nil}
|
||||
|
||||
e.raster = canvas.NewRaster(func(wPx, hPx int) image.Image {
|
||||
return e.draw(wPx, hPx)
|
||||
})
|
||||
|
||||
e.pan = NewPanController(e)
|
||||
|
||||
e.co = NewRasterCoalescer(
|
||||
FyneExecutor{},
|
||||
e.raster,
|
||||
func(wPx, hPx int, p world.RenderParams) image.Image {
|
||||
return e.renderRasterImage(wPx, hPx, p)
|
||||
},
|
||||
)
|
||||
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func (c *ui) Run() {
|
||||
c.window.ShowAndRun()
|
||||
func (e *client) loadReport(t uint) {
|
||||
e.conn.FetchReport("GAME_ID", t, func(r report.Report, err error) {
|
||||
if err != nil {
|
||||
e.handlerError(err)
|
||||
} else {
|
||||
e.setReport(r)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (e *client) setReport(r report.Report) {
|
||||
w := world.NewWorld(int(r.Width), int(r.Height))
|
||||
for i := range r.LocalPlanet {
|
||||
p := r.LocalPlanet[i]
|
||||
w.AddCircle(p.X.F(), p.Y.F(), p.Size.F())
|
||||
}
|
||||
for i := range r.UnidentifiedPlanet {
|
||||
p := r.UnidentifiedPlanet[i]
|
||||
w.AddPoint(p.X.F(), p.Y.F())
|
||||
}
|
||||
e.loadWorld(w)
|
||||
}
|
||||
|
||||
func (e *client) handlerError(err error) {
|
||||
|
||||
}
|
||||
|
||||
func (e *client) BuildUI(w fyne.Window) {
|
||||
mapCanvas := newInteractiveRaster(e, e.raster, e.onRasterWidgetLayout, e.onScrolled, e.onDragged, e.onDradEnd, e.onTapped)
|
||||
mapCanvas.SetMinSize(fyne.NewSize(292, 292))
|
||||
|
||||
toolbar := widget.NewToolbar(
|
||||
widget.NewToolbarAction(
|
||||
theme.FolderIcon(),
|
||||
func() { e.loadWorld(mockWorld()) }),
|
||||
widget.NewToolbarSeparator(),
|
||||
widget.NewToolbarAction(
|
||||
theme.NavigateBackIcon(),
|
||||
func() {}),
|
||||
widget.NewToolbarAction(
|
||||
theme.NavigateNextIcon(),
|
||||
func() {}),
|
||||
)
|
||||
|
||||
tabs := container.NewAppTabs(
|
||||
container.NewTabItemWithIcon(
|
||||
"Map",
|
||||
theme.GridIcon(),
|
||||
mapCanvas),
|
||||
container.NewTabItemWithIcon(
|
||||
"Calculator",
|
||||
theme.ComputerIcon(),
|
||||
container.NewStack(widget.NewButton("Calc", func() {})),
|
||||
),
|
||||
)
|
||||
|
||||
content := container.NewBorder(
|
||||
toolbar, // top
|
||||
nil, // bottom
|
||||
nil, // left
|
||||
nil, // right
|
||||
tabs, // center
|
||||
)
|
||||
|
||||
w.CenterOnScreen()
|
||||
w.SetContent(content)
|
||||
}
|
||||
|
||||
func (e *client) loadWorld(w *world.World) {
|
||||
w.SetCircleRadiusScaleFp(world.SCALE / 4)
|
||||
e.world = w
|
||||
// TODO: store camera position in user settings
|
||||
e.wp.CameraXWorldFp = w.W / 2
|
||||
e.wp.CameraYWorldFp = w.H / 2
|
||||
e.world.SetTheme(world.ThemeDark)
|
||||
|
||||
// if e.world == nil {
|
||||
// w.SetCircleRadiusScaleFp(world.SCALE / 4)
|
||||
// e.world = w
|
||||
// e.wp.CameraXWorldFp = w.W / 2
|
||||
// e.wp.CameraYWorldFp = w.H / 2
|
||||
// e.world.SetTheme(world.ThemeDark)
|
||||
// } else {
|
||||
// if e.world.Theme().ID() == "theme.light.v1" {
|
||||
// e.world.SetTheme(world.ThemeDark)
|
||||
// } else {
|
||||
// e.world.SetTheme(world.ThemeLight)
|
||||
// }
|
||||
// }
|
||||
|
||||
e.RequestRefresh()
|
||||
}
|
||||
|
||||
func (e *client) Run() error {
|
||||
e.BuildUI(e.window)
|
||||
e.window.ShowAndRun()
|
||||
e.RequestRefresh()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *client) Shutdown() {
|
||||
e.window.Close()
|
||||
}
|
||||
|
||||
func (e *client) OnConnection(bool) {}
|
||||
|
||||
+30
-3
@@ -1,8 +1,35 @@
|
||||
package main
|
||||
|
||||
import "galaxy/client"
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"galaxy/client"
|
||||
mc "galaxy/model/client"
|
||||
"os"
|
||||
|
||||
"fyne.io/fyne/v2/app"
|
||||
)
|
||||
|
||||
func main() {
|
||||
c := client.NewUI()
|
||||
c.Run()
|
||||
var err error
|
||||
defer func() {
|
||||
if err == nil {
|
||||
if r := recover(); r != nil {
|
||||
err = errors.Join(err, fmt.Errorf("app panics: %v", r))
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
app := app.New()
|
||||
settings := mc.Settings{
|
||||
StoragePath: ".",
|
||||
}
|
||||
c, err := client.NewClient(nil, app, settings)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = c.Run()
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"galaxy/model/report"
|
||||
)
|
||||
|
||||
type Connector interface {
|
||||
Turn(uint, func(report.Report, error)) error
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package http
|
||||
|
||||
type httpConnector struct {
|
||||
}
|
||||
|
||||
func NewHttpConnector() *httpConnector {
|
||||
h := &httpConnector{}
|
||||
return h
|
||||
}
|
||||
@@ -1,289 +0,0 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"math"
|
||||
"sync"
|
||||
|
||||
"galaxy/client/world"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/canvas"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
)
|
||||
|
||||
type client struct {
|
||||
world *world.World
|
||||
drawer *world.GGDrawer
|
||||
raster *canvas.Raster
|
||||
co *RasterCoalescer[world.RenderParams]
|
||||
pan *PanController
|
||||
|
||||
// Protected camera/options state (UI-facing). This is the "base" params snapshot.
|
||||
// Viewport/margins are NOT stored here; they come from raster draw callback.
|
||||
mu sync.RWMutex
|
||||
wp *world.RenderParams
|
||||
canvasScale float32
|
||||
|
||||
// Latest raster geometry metadata for correct event->pixel conversion:
|
||||
// - logical size: raster.Size() (Fyne units)
|
||||
// - pixel size: last (wPx,hPx) passed to draw callback
|
||||
metaMu sync.RWMutex
|
||||
lastRasterLogicW float32
|
||||
lastRasterLogicH float32
|
||||
lastRasterPxW int
|
||||
lastRasterPxH int
|
||||
lastCanvasScale float32 // optional, useful for debugging
|
||||
|
||||
// Snapshot of params actually used for the last render (includes viewport/margins).
|
||||
// Used for HitTest and to keep UI interactions consistent with what the user sees.
|
||||
lastRenderedMu sync.RWMutex
|
||||
lastRenderedParams world.RenderParams
|
||||
|
||||
// Indexing / backing-canvas caches (owned by client because it depends on UI geometry)
|
||||
lastIndexedViewportW int
|
||||
lastIndexedViewportH int
|
||||
lastIndexedZoomFp int
|
||||
|
||||
lastCanvasW int
|
||||
lastCanvasH int
|
||||
|
||||
viewportImg *image.RGBA
|
||||
viewportW int
|
||||
viewportH int
|
||||
|
||||
hits []world.Hit
|
||||
}
|
||||
|
||||
func NewClient() *client {
|
||||
e := &client{
|
||||
world: nil,
|
||||
wp: &world.RenderParams{
|
||||
CameraZoom: 1.0,
|
||||
Options: &world.RenderOptions{DisableWrapScroll: false},
|
||||
},
|
||||
lastCanvasScale: 1.0,
|
||||
hits: make([]world.Hit, 5),
|
||||
}
|
||||
|
||||
e.drawer = &world.GGDrawer{DC: nil}
|
||||
|
||||
e.raster = canvas.NewRaster(func(wPx, hPx int) image.Image {
|
||||
return e.draw(wPx, hPx)
|
||||
})
|
||||
|
||||
e.pan = NewPanController(e)
|
||||
|
||||
e.co = NewRasterCoalescer(
|
||||
FyneExecutor{},
|
||||
e.raster,
|
||||
func(wPx, hPx int, p world.RenderParams) image.Image {
|
||||
return e.renderRasterImage(wPx, hPx, p)
|
||||
},
|
||||
)
|
||||
|
||||
e.RequestRefresh()
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *client) CanvasScale() float32 {
|
||||
e.metaMu.RLock()
|
||||
defer e.metaMu.RUnlock()
|
||||
if e.lastCanvasScale <= 0 {
|
||||
return 1
|
||||
}
|
||||
return e.lastCanvasScale
|
||||
}
|
||||
|
||||
func (e *client) ForceFullRedraw() {
|
||||
if e.world == nil {
|
||||
return
|
||||
}
|
||||
e.world.ForceFullRedrawNext()
|
||||
}
|
||||
|
||||
func (e *client) onRasterWidgetLayout(fyne.Size) {
|
||||
e.updateSizes()
|
||||
}
|
||||
|
||||
// updateSizes updates only metadata we need for event->pixel conversion and schedules a redraw.
|
||||
// It must NOT try to compute pixel viewport sizes (those are known in raster draw callback).
|
||||
func (e *client) updateSizes() {
|
||||
canvasObj := fyne.CurrentApp().Driver().CanvasForObject(e.raster)
|
||||
if canvasObj == nil {
|
||||
return
|
||||
}
|
||||
|
||||
sz := e.raster.Size() // logical (Fyne units)
|
||||
scale := canvasObj.Scale()
|
||||
|
||||
e.metaMu.Lock()
|
||||
e.lastRasterLogicW = sz.Width
|
||||
e.lastRasterLogicH = sz.Height
|
||||
e.lastCanvasScale = scale
|
||||
e.metaMu.Unlock()
|
||||
|
||||
e.RequestRefresh()
|
||||
}
|
||||
|
||||
func (e *client) onDragged(ev *fyne.DragEvent) {
|
||||
e.pan.Dragged(ev)
|
||||
}
|
||||
|
||||
func (e *client) onDradEnd() {
|
||||
e.pan.DragEnd()
|
||||
}
|
||||
|
||||
func (e *client) onTapped(ev *fyne.PointEvent) {
|
||||
if e.world == nil || ev == nil {
|
||||
return
|
||||
}
|
||||
|
||||
xPx, yPx, ok := e.eventPosToPixel(ev.Position.X, ev.Position.Y)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
params := e.getLastRenderedParams()
|
||||
hits, err := e.world.HitTest(e.hits, ¶ms, xPx, yPx)
|
||||
if err != nil {
|
||||
// In UI you probably don't want panic; keep your existing handling.
|
||||
panic(err)
|
||||
}
|
||||
|
||||
m := func(v int) float64 { return float64(v) / float64(world.SCALE) }
|
||||
|
||||
for _, hit := range hits {
|
||||
var coord string
|
||||
if hit.Kind == world.KindLine {
|
||||
coord = fmt.Sprintf("{%f,%f - %f,%f}", m(hit.X1), m(hit.Y1), m(hit.X2), m(hit.Y2))
|
||||
} else {
|
||||
coord = fmt.Sprintf("{%f,%f}", m(hit.X), m(hit.Y))
|
||||
}
|
||||
fmt.Println("hit:", hit.ID, "Coord:", coord)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *client) onScrolled(s *fyne.ScrollEvent) {
|
||||
if e.world == nil || s == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Use last rendered viewport sizes (pixel) for zoom logic.
|
||||
e.metaMu.RLock()
|
||||
vw := e.lastRasterPxW
|
||||
vh := e.lastRasterPxH
|
||||
e.metaMu.RUnlock()
|
||||
if vw <= 0 || vh <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
cxPx, cyPx, ok := e.eventPosToPixel(s.Position.X, s.Position.Y)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
e.mu.Lock()
|
||||
oldZoom := e.wp.CameraZoom
|
||||
|
||||
// Exponential zoom factor; tune later.
|
||||
const base = 1.005
|
||||
delta := float64(s.Scrolled.DY)
|
||||
newZoom := oldZoom * math.Pow(base, delta)
|
||||
|
||||
newZoom = e.world.CorrectCameraZoom(newZoom, vw, vh)
|
||||
if newZoom == oldZoom {
|
||||
e.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
oldZoomFp, err := world.CameraZoomToWorldFixed(oldZoom)
|
||||
if err != nil {
|
||||
e.mu.Unlock()
|
||||
return
|
||||
}
|
||||
newZoomFp, err := world.CameraZoomToWorldFixed(newZoom)
|
||||
if err != nil {
|
||||
e.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
// Pivot zoom for no-wrap behavior.
|
||||
newCamX, newCamY := world.PivotZoomCameraNoWrap(
|
||||
e.wp.CameraXWorldFp, e.wp.CameraYWorldFp,
|
||||
vw, vh,
|
||||
cxPx, cyPx,
|
||||
oldZoomFp, newZoomFp,
|
||||
)
|
||||
|
||||
e.wp.CameraZoom = newZoom
|
||||
e.wp.CameraXWorldFp = newCamX
|
||||
e.wp.CameraYWorldFp = newCamY
|
||||
e.mu.Unlock()
|
||||
|
||||
// Any zoom change should rebuild index and force full redraw.
|
||||
e.world.ForceFullRedrawNext()
|
||||
e.RequestRefresh()
|
||||
}
|
||||
|
||||
func (e *client) BuildUI(w fyne.Window) {
|
||||
mapCanvas := newInteractiveRaster(e, e.raster, e.onRasterWidgetLayout, e.onScrolled, e.onDragged, e.onDradEnd, e.onTapped)
|
||||
mapCanvas.SetMinSize(fyne.NewSize(292, 292))
|
||||
|
||||
toolbar := widget.NewToolbar(
|
||||
widget.NewToolbarAction(
|
||||
theme.FolderIcon(),
|
||||
func() { e.loadWorld(mockWorld()) }),
|
||||
widget.NewToolbarSeparator(),
|
||||
widget.NewToolbarAction(
|
||||
theme.NavigateBackIcon(),
|
||||
func() {}),
|
||||
widget.NewToolbarAction(
|
||||
theme.NavigateNextIcon(),
|
||||
func() {}),
|
||||
)
|
||||
|
||||
tabs := container.NewAppTabs(
|
||||
container.NewTabItemWithIcon(
|
||||
"Map",
|
||||
theme.GridIcon(),
|
||||
mapCanvas),
|
||||
container.NewTabItemWithIcon(
|
||||
"Calculator",
|
||||
theme.ComputerIcon(),
|
||||
container.NewStack(widget.NewButton("Calc", func() {})),
|
||||
),
|
||||
)
|
||||
|
||||
content := container.NewBorder(
|
||||
toolbar, // top
|
||||
nil, // bottom
|
||||
nil, // left
|
||||
nil, // right
|
||||
tabs, // center
|
||||
)
|
||||
|
||||
w.CenterOnScreen()
|
||||
w.SetContent(content)
|
||||
}
|
||||
|
||||
func (e *client) loadWorld(w *world.World) {
|
||||
if e.world == nil {
|
||||
w.SetCircleRadiusScaleFp(world.SCALE / 4)
|
||||
e.world = w
|
||||
// TODO: store camera position in user settings
|
||||
e.wp.CameraXWorldFp = w.W / 2
|
||||
e.wp.CameraYWorldFp = w.H / 2
|
||||
e.world.SetTheme(world.ThemeDark)
|
||||
} else {
|
||||
if e.world.Theme().ID() == "theme.light.v1" {
|
||||
e.world.SetTheme(world.ThemeDark)
|
||||
} else {
|
||||
e.world.SetTheme(world.ThemeLight)
|
||||
}
|
||||
}
|
||||
e.RequestRefresh()
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
package model
|
||||
@@ -0,0 +1,42 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
model "galaxy/model/client"
|
||||
"galaxy/model/order"
|
||||
"galaxy/model/report"
|
||||
)
|
||||
|
||||
// Storage manages Client's data local storing and retrieval.
|
||||
// It performs all I/O operations asynchronously to avoid UI main thread blocking.
|
||||
type Storage interface {
|
||||
// StateExists check if previously saved [model.State] exists on filesystem and returns result.
|
||||
// I/O error may occur, it that case returned result will be false and error is non-nil.
|
||||
StateExists() (bool, error)
|
||||
|
||||
// LoadState loads Client's [model.State] from filesystem data asynchronously.
|
||||
// Passed callback func will accept non-nil error in case of I/O or decoding errors occuried,
|
||||
// otherwise callback func accepts loaded [model.State].
|
||||
LoadState(func(model.State, error))
|
||||
|
||||
// SaveState stores Client's state at the filesystem asynchronously.
|
||||
// I/O or encoding error may occur, it that case callback func will be called with non-nil error.
|
||||
SaveState(model.State, func(error))
|
||||
|
||||
// LoadReport loads a [report.Report] for a given [model.GameID] and turn number from filesystem asynchronously.
|
||||
// Passed callback func will will accept non-nil error in case of I/O or decoding errors occuried,
|
||||
// otherwise callback func accepts loaded [report.Report].
|
||||
LoadReport(model.GameID, uint, func(report.Report, error))
|
||||
|
||||
// PutReport stores given [report.Report] for a given [model.GameID] and turn number at the filesystem asynchronously.
|
||||
// I/O or encoding error may occur, it that case callback func will be called with non-nil error.
|
||||
PutReport(model.GameID, uint, report.Report, func(error))
|
||||
|
||||
// LoadOrder loads a [order.Order] for a given [model.GameID] and turn number from filesystem asynchronously.
|
||||
// Passed callback func will will accept non-nil error in case of I/O or decoding errors occuried,
|
||||
// otherwise callback func accepts loaded [order.Order].
|
||||
LoadOrder(model.GameID, uint, func(order.Order, error))
|
||||
|
||||
// PutOrder stores given [order.Order] for a given [model.GameID] and turn number at the filesystem asynchronously.
|
||||
// I/O or encoding error may occur, it that case callback func will be called with non-nil error.
|
||||
PutOrder(model.GameID, uint, order.Order, func(error))
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"galaxy/util"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
const (
|
||||
// Name of the file under the storage's root where [model.State] is stored.
|
||||
stateFileName = "state.dat"
|
||||
|
||||
// Suffix of a Game's file inder the storage's root where [model.GameData] is stored.
|
||||
gameDataFileSuffix = ".dat"
|
||||
)
|
||||
|
||||
type storage struct {
|
||||
root string
|
||||
}
|
||||
|
||||
// NewStorage returns implementation of the "galaxy/client.Storage" interface
|
||||
// or nil with a non-nil error when filesystem storage initialisation failed.
|
||||
func NewStorage(rootPath string) (*storage, error) {
|
||||
ok, err := util.Writable(rootPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, errors.New("user does not have write permissions to the storage root")
|
||||
}
|
||||
s := &storage{
|
||||
root: rootPath,
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// StateFilePath returns client's state file path relative to the root,
|
||||
// file name and extension are pre-defined constant.
|
||||
func StateFilePath(root string) string {
|
||||
return filepath.Join(root, stateFileName)
|
||||
}
|
||||
|
||||
// GameDataPath returns game's data file path relative to the root,
|
||||
// data file name is GameID string representation and extension is a pre-defined constant.
|
||||
func GameDataFilePath(root string, id fmt.Stringer) string {
|
||||
return filepath.Join(root, id.String()) + gameDataFileSuffix
|
||||
}
|
||||
+141
@@ -1,6 +1,7 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"math"
|
||||
|
||||
@@ -193,6 +194,146 @@ func (e *client) eventPosToPixel(eventX, eventY float32) (xPx, yPx int, ok bool)
|
||||
return x, y, true
|
||||
}
|
||||
|
||||
func (e *client) CanvasScale() float32 {
|
||||
e.metaMu.RLock()
|
||||
defer e.metaMu.RUnlock()
|
||||
if e.lastCanvasScale <= 0 {
|
||||
return 1
|
||||
}
|
||||
return e.lastCanvasScale
|
||||
}
|
||||
|
||||
func (e *client) ForceFullRedraw() {
|
||||
if e.world == nil {
|
||||
return
|
||||
}
|
||||
e.world.ForceFullRedrawNext()
|
||||
}
|
||||
|
||||
func (e *client) onRasterWidgetLayout(fyne.Size) {
|
||||
e.updateSizes()
|
||||
}
|
||||
|
||||
// updateSizes updates only metadata we need for event->pixel conversion and schedules a redraw.
|
||||
// It must NOT try to compute pixel viewport sizes (those are known in raster draw callback).
|
||||
func (e *client) updateSizes() {
|
||||
canvasObj := fyne.CurrentApp().Driver().CanvasForObject(e.raster)
|
||||
if canvasObj == nil {
|
||||
return
|
||||
}
|
||||
|
||||
sz := e.raster.Size() // logical (Fyne units)
|
||||
scale := canvasObj.Scale()
|
||||
|
||||
e.metaMu.Lock()
|
||||
e.lastRasterLogicW = sz.Width
|
||||
e.lastRasterLogicH = sz.Height
|
||||
e.lastCanvasScale = scale
|
||||
e.metaMu.Unlock()
|
||||
|
||||
e.RequestRefresh()
|
||||
}
|
||||
|
||||
func (e *client) onDragged(ev *fyne.DragEvent) {
|
||||
e.pan.Dragged(ev)
|
||||
}
|
||||
|
||||
func (e *client) onDradEnd() {
|
||||
e.pan.DragEnd()
|
||||
}
|
||||
|
||||
func (e *client) onTapped(ev *fyne.PointEvent) {
|
||||
if e.world == nil || ev == nil {
|
||||
return
|
||||
}
|
||||
|
||||
xPx, yPx, ok := e.eventPosToPixel(ev.Position.X, ev.Position.Y)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
params := e.getLastRenderedParams()
|
||||
hits, err := e.world.HitTest(e.hits, ¶ms, xPx, yPx)
|
||||
if err != nil {
|
||||
// In UI you probably don't want panic; keep your existing handling.
|
||||
panic(err)
|
||||
}
|
||||
|
||||
m := func(v int) float64 { return float64(v) / float64(world.SCALE) }
|
||||
|
||||
for _, hit := range hits {
|
||||
var coord string
|
||||
if hit.Kind == world.KindLine {
|
||||
coord = fmt.Sprintf("{%f,%f - %f,%f}", m(hit.X1), m(hit.Y1), m(hit.X2), m(hit.Y2))
|
||||
} else {
|
||||
coord = fmt.Sprintf("{%f,%f}", m(hit.X), m(hit.Y))
|
||||
}
|
||||
fmt.Println("hit:", hit.ID, "Coord:", coord)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *client) onScrolled(s *fyne.ScrollEvent) {
|
||||
if e.world == nil || s == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Use last rendered viewport sizes (pixel) for zoom logic.
|
||||
e.metaMu.RLock()
|
||||
vw := e.lastRasterPxW
|
||||
vh := e.lastRasterPxH
|
||||
e.metaMu.RUnlock()
|
||||
if vw <= 0 || vh <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
cxPx, cyPx, ok := e.eventPosToPixel(s.Position.X, s.Position.Y)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
e.mu.Lock()
|
||||
oldZoom := e.wp.CameraZoom
|
||||
|
||||
// Exponential zoom factor; tune later.
|
||||
const base = 1.005
|
||||
delta := float64(s.Scrolled.DY)
|
||||
newZoom := oldZoom * math.Pow(base, delta)
|
||||
|
||||
newZoom = e.world.CorrectCameraZoom(newZoom, vw, vh)
|
||||
if newZoom == oldZoom {
|
||||
e.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
oldZoomFp, err := world.CameraZoomToWorldFixed(oldZoom)
|
||||
if err != nil {
|
||||
e.mu.Unlock()
|
||||
return
|
||||
}
|
||||
newZoomFp, err := world.CameraZoomToWorldFixed(newZoom)
|
||||
if err != nil {
|
||||
e.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
// Pivot zoom for no-wrap behavior.
|
||||
newCamX, newCamY := world.PivotZoomCameraNoWrap(
|
||||
e.wp.CameraXWorldFp, e.wp.CameraYWorldFp,
|
||||
vw, vh,
|
||||
cxPx, cyPx,
|
||||
oldZoomFp, newZoomFp,
|
||||
)
|
||||
|
||||
e.wp.CameraZoom = newZoom
|
||||
e.wp.CameraXWorldFp = newCamX
|
||||
e.wp.CameraYWorldFp = newCamY
|
||||
e.mu.Unlock()
|
||||
|
||||
// Any zoom change should rebuild index and force full redraw.
|
||||
e.world.ForceFullRedrawNext()
|
||||
e.RequestRefresh()
|
||||
}
|
||||
|
||||
// copyViewportRGBA copies a viewport rectangle from src RGBA into dst RGBA.
|
||||
// dst must be sized exactly (0,0)-(vw,vh). This is allocation-free.
|
||||
// It avoids SubImage aliasing issues: dst becomes independent from src backing memory.
|
||||
|
||||
@@ -147,18 +147,11 @@ func (w *World) Theme() StyleTheme {
|
||||
}
|
||||
|
||||
// SetTheme updates the world's current theme.
|
||||
// Step 1 behavior:
|
||||
// - Does NOT mutate built-in default styles (1/2/3).
|
||||
// - Materializes three theme default styles as new StyleIDs in the style table.
|
||||
// - New objects (and later, theme-relative ones) can use these IDs.
|
||||
// - Forces next render to full redraw.
|
||||
func (w *World) SetTheme(theme StyleTheme) {
|
||||
if theme == nil {
|
||||
theme = DefaultTheme{}
|
||||
}
|
||||
// fmt.Println("current theme:", w.theme.ID())
|
||||
w.theme = theme
|
||||
// fmt.Println("new theme:", w.theme.ID())
|
||||
|
||||
// Drop derived cache when theme changes to avoid unbounded growth.
|
||||
for k := range w.derivedCache {
|
||||
@@ -174,7 +167,6 @@ func (w *World) SetTheme(theme StyleTheme) {
|
||||
|
||||
// Full redraw to apply new background and base styles.
|
||||
w.renderState.Reset()
|
||||
// w.forceFullRedraw = true
|
||||
w.ForceFullRedrawNext()
|
||||
}
|
||||
|
||||
@@ -503,36 +495,6 @@ func (w *World) AddLine(x1, y1, x2, y2 float64, opts ...LineOpt) (PrimitiveID, e
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// func (g *World) resolvePointStyleID(o PointOptions) StyleID {
|
||||
// if o.hasStyleID {
|
||||
// return o.StyleID
|
||||
// }
|
||||
// if o.Override.IsZero() {
|
||||
// return StyleIDDefaultPoint
|
||||
// }
|
||||
// return g.styles.AddDerived(StyleIDDefaultPoint, o.Override)
|
||||
// }
|
||||
|
||||
// func (g *World) resolveCircleStyleID(o CircleOptions) StyleID {
|
||||
// if o.hasStyleID {
|
||||
// return o.StyleID
|
||||
// }
|
||||
// if o.Override.IsZero() {
|
||||
// return StyleIDDefaultCircle
|
||||
// }
|
||||
// return g.styles.AddDerived(StyleIDDefaultCircle, o.Override)
|
||||
// }
|
||||
|
||||
// func (g *World) resolveLineStyleID(o LineOptions) StyleID {
|
||||
// if o.hasStyleID {
|
||||
// return o.StyleID
|
||||
// }
|
||||
// if o.Override.IsZero() {
|
||||
// return StyleIDDefaultLine
|
||||
// }
|
||||
// return g.styles.AddDerived(StyleIDDefaultLine, o.Override)
|
||||
// }
|
||||
|
||||
// worldToCellX converts a fixed-point X coordinate to a grid column index.
|
||||
func (w *World) worldToCellX(x int) int {
|
||||
return worldToCell(x, w.W, w.cols, w.cellSize)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package connector
|
||||
|
||||
import (
|
||||
model "galaxy/model/client"
|
||||
"galaxy/model/report"
|
||||
)
|
||||
|
||||
// Connector is a main interface to provide connectivity with app's server.
|
||||
type Connector interface {
|
||||
UIConnector
|
||||
|
||||
// CheckConnection is called asynchronously every 5 seconds and tests is connection available with a specific backend server endpoint.
|
||||
// There is guaranteed backoff 5s -> 15s -> 30s -> 60s when no connection is available.
|
||||
CheckConnection() bool
|
||||
|
||||
// CheckVersion is called asynchronously every 30 minutes and receives from backend server information about currently available app versions.
|
||||
CheckVersion() ([]VersionInfo, error)
|
||||
|
||||
// DownloadVersion asynchronously retrieves from a specific string URL a binary artifact from backend server.
|
||||
DownloadVersion(string) ([]byte, error)
|
||||
}
|
||||
|
||||
// UIConnector contains only funcs are needed for the client app to be functional.
|
||||
type UIConnector interface {
|
||||
// FetchReport asynchronously requests from backend server a [report.Report] for a given [model.GameID] and turn number.
|
||||
// Passed callback func will will accept non-nil error in case of I/O or decoding errors occuried,
|
||||
// otherwise callback func accepts loaded [report.Report].
|
||||
FetchReport(model.GameID, uint, func(report.Report, error))
|
||||
}
|
||||
|
||||
type VersionInfo struct {
|
||||
OS string `json:"os"` // Operating System name (unix, darwin, windows, etc.)
|
||||
Version string `json:"version"` // Semver format: X.Y.Z
|
||||
URL string `json:"url"` // URL for download artifacto for this version
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module galaxy/connector
|
||||
|
||||
go 1.26.0
|
||||
@@ -0,0 +1,24 @@
|
||||
// Package implements "galaxy/connector.Connector" interface with HTTP REST API protocol
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
type httpConnector struct {
|
||||
ctx context.Context
|
||||
backendURL *url.URL // HTTP REST API Server URL
|
||||
}
|
||||
|
||||
func NewHttpConnector(ctx context.Context, backendURL string) (*httpConnector, error) {
|
||||
u, err := url.Parse(backendURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h := &httpConnector{
|
||||
ctx: ctx,
|
||||
backendURL: u,
|
||||
}
|
||||
return h, nil
|
||||
}
|
||||
@@ -2,6 +2,8 @@ go 1.26.0
|
||||
|
||||
use (
|
||||
./client
|
||||
./connector
|
||||
./loader
|
||||
./pkg/error
|
||||
./pkg/model
|
||||
./pkg/util
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"galaxy/loader"
|
||||
"os"
|
||||
"os/signal"
|
||||
|
||||
"fyne.io/fyne/v2/app"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var err error
|
||||
defer func() {
|
||||
if err == nil {
|
||||
if r := recover(); r != nil {
|
||||
err = errors.Join(err, fmt.Errorf("app panics: %v", r))
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
defer cancel()
|
||||
|
||||
app := app.New()
|
||||
l, err := loader.NewLoader(app, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = l.Run(ctx)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
module galaxy/loader
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require fyne.io/fyne/v2 v2.7.3
|
||||
|
||||
require (
|
||||
fyne.io/systray v1.12.0 // indirect
|
||||
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/fredbi/uri v1.1.1 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/fyne-io/gl-js v0.2.0 // indirect
|
||||
github.com/fyne-io/glfw-js v0.3.0 // indirect
|
||||
github.com/fyne-io/image v0.1.1 // indirect
|
||||
github.com/fyne-io/oksvg v0.2.0 // indirect
|
||||
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20250301202403-da16c1255728 // indirect
|
||||
github.com/go-text/render v0.2.0 // indirect
|
||||
github.com/go-text/typesetting v0.3.3 // indirect
|
||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||
github.com/hack-pad/go-indexeddb v0.3.2 // indirect
|
||||
github.com/hack-pad/safejs v0.1.1 // indirect
|
||||
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect
|
||||
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.10.0 // indirect
|
||||
github.com/rymdport/portal v0.4.2 // indirect
|
||||
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect
|
||||
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
|
||||
github.com/stretchr/testify v1.11.1 // indirect
|
||||
github.com/yuin/goldmark v1.7.16 // indirect
|
||||
golang.org/x/image v0.36.0 // indirect
|
||||
golang.org/x/net v0.50.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
fyne.io/fyne/v2 v2.7.3 h1:xBT/iYbdnNHONWO38fZMBrVBiJG8rV/Jypmy4tVfRWE=
|
||||
fyne.io/systray v1.12.0 h1:CA1Kk0e2zwFlxtc02L3QFSiIbxJ/P0n582YrZHT7aTM=
|
||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g=
|
||||
github.com/fredbi/uri v1.1.1 h1:xZHJC08GZNIUhbP5ImTHnt5Ya0T8FI2VAwI/37kh2Ko=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fyne-io/gl-js v0.2.0 h1:+EXMLVEa18EfkXBVKhifYB6OGs3HwKO3lUElA0LlAjs=
|
||||
github.com/fyne-io/glfw-js v0.3.0 h1:d8k2+Y7l+zy2pc7wlGRyPfTgZoqDf3AI4G+2zOWhWUk=
|
||||
github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA=
|
||||
github.com/fyne-io/oksvg v0.2.0 h1:mxcGU2dx6nwjJsSA9PCYZDuoAcsZ/OuJlvg/Q9Njfo8=
|
||||
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20250301202403-da16c1255728 h1:RkGhqHxEVAvPM0/R+8g7XRwQnHatO0KAuVcwHo8q9W8=
|
||||
github.com/go-text/render v0.2.0 h1:LBYoTmp5jYiJ4NPqDc2pz17MLmA3wHw1dZSVGcOdeAc=
|
||||
github.com/go-text/typesetting v0.3.3 h1:ihGNJU9KzdK2QRDy1Bm7FT5RFQoYb+3n3EIhI/4eaQc=
|
||||
github.com/go-text/typesetting-utils v0.0.0-20250618110550-c820a94c77b8 h1:4KCscI9qYWMGTuz6BpJtbUSRzcBrUSSE0ENMJbNSrFs=
|
||||
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y=
|
||||
github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A=
|
||||
github.com/hack-pad/safejs v0.1.1 h1:d5qPO0iQ7h2oVtpzGnLExE+Wn9AtytxIfltcS2b9KD8=
|
||||
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE=
|
||||
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
|
||||
github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rymdport/portal v0.4.2 h1:7jKRSemwlTyVHHrTGgQg7gmNPJs88xkbKcIL3NlcmSU=
|
||||
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
|
||||
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
@@ -0,0 +1,85 @@
|
||||
package loader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"galaxy/connector"
|
||||
mc "galaxy/model/client"
|
||||
"plugin"
|
||||
"time"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
)
|
||||
|
||||
type ClientInit func(connector.UIConnector, fyne.App, mc.Settings) (mc.Client, error)
|
||||
|
||||
type loader struct {
|
||||
conn connector.Connector
|
||||
cli mc.Client
|
||||
}
|
||||
|
||||
func NewLoader(app fyne.App, conn connector.Connector) (*loader, error) {
|
||||
app.Storage().List()
|
||||
settings := mc.Settings{}
|
||||
cli, err := loadClientPlugin(conn, app, settings, "./client.so", "NewClient")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
l := &loader{
|
||||
conn: conn,
|
||||
cli: cli,
|
||||
}
|
||||
return l, nil
|
||||
}
|
||||
|
||||
func (l *loader) Run(ctx context.Context) error {
|
||||
final := make(chan struct{}, 1)
|
||||
go l.backgroundLoop(ctx, final)
|
||||
if err := l.cli.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
final <- struct{}{}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *loader) backgroundLoop(ctx context.Context, final <-chan struct{}) {
|
||||
t := time.NewTicker(time.Second * 5)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
l.cli.Shutdown()
|
||||
return
|
||||
case <-final:
|
||||
return
|
||||
case <-t.C:
|
||||
isGood := l.conn.CheckConnection()
|
||||
l.cli.OnConnection(isGood)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// loadClientPlugin loads a Client implementation from a shared object (.so) file at the specified path.
|
||||
// It calls the constructor function by name, passing the necessary dependencies, and returns the initialized Client.
|
||||
func loadClientPlugin(conn connector.UIConnector, app fyne.App, s mc.Settings, path, name string) (mc.Client, error) {
|
||||
if path == "" {
|
||||
return nil, errors.New("no plugin path given")
|
||||
}
|
||||
|
||||
plug, err := plugin.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open plugin %q: %w", path, err)
|
||||
}
|
||||
|
||||
sym, err := plug.Lookup(name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lookup symbol %q: %w", name, err)
|
||||
}
|
||||
|
||||
initializerPtr, ok := sym.(*ClientInit)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected type %T; want %T", sym, initializerPtr)
|
||||
}
|
||||
|
||||
return (*initializerPtr)(conn, app, s)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"galaxy/model/order"
|
||||
"galaxy/model/report"
|
||||
)
|
||||
|
||||
type Client interface {
|
||||
// Run initializes necessary UI layout an settings, and activates client's main window.
|
||||
// This is a blocking operation until client's main window is closed.
|
||||
Run() error
|
||||
|
||||
// Shutdown closes client's main window and performing all necessary data persistence.
|
||||
Shutdown()
|
||||
|
||||
// OnConnection receives an event when connection with client's server may be established (true) or connectivity lost (false).
|
||||
OnConnection(bool)
|
||||
}
|
||||
|
||||
type GameID string
|
||||
|
||||
func (i GameID) String() string {
|
||||
return string(i)
|
||||
}
|
||||
|
||||
type State struct {
|
||||
// TODO: store user login key
|
||||
GameState []GameState `json:"gameState"`
|
||||
ActiveGameID GameID `json:"activeGameId"`
|
||||
}
|
||||
|
||||
type GameState struct {
|
||||
ID GameID `json:"id"`
|
||||
LastTurn uint `json:"lastTurn"`
|
||||
ActiveTurn uint `json:"activeTurn"`
|
||||
}
|
||||
|
||||
type GameData struct {
|
||||
Turn uint `json:"turn"`
|
||||
Report report.Report `json:"report"`
|
||||
Order *order.Order `json:"order,omitempty"`
|
||||
}
|
||||
|
||||
type Settings struct {
|
||||
// TODO: use fyne.Storage for initializing and storing data
|
||||
StoragePath string
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
)
|
||||
|
||||
type Order struct {
|
||||
// TODO: check with already stored order, if any, and generate an error, if newer order exists
|
||||
UpdatedAt int `json:"updatedAt"`
|
||||
Commands []DecodableCommand `json:"cmd"`
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,10 @@ func F(v float64) Float {
|
||||
return Float(u.Fixed3(v))
|
||||
}
|
||||
|
||||
func (f Float) F() float64 {
|
||||
return float64(f)
|
||||
}
|
||||
|
||||
type Report struct {
|
||||
Version uint `json:"version"`
|
||||
Turn uint `json:"turn"`
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
@@ -17,3 +18,25 @@ func CreateWorkDir(t *testing.T) (string, func()) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func DirExists(path string) (bool, error) {
|
||||
return PathExists(path, true)
|
||||
}
|
||||
|
||||
func FileExists(path string) (bool, error) {
|
||||
return PathExists(path, false)
|
||||
}
|
||||
|
||||
func PathExists(path string, isDir bool) (bool, error) {
|
||||
if fi, err := os.Stat(path); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
} else {
|
||||
if isDir != fi.IsDir() {
|
||||
return false, fmt.Errorf("wrong type: "+path+" mode=%s isDir=%t", fi.Mode(), isDir)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
package fs
|
||||
package util_test
|
||||
|
||||
import (
|
||||
"galaxy/util"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"galaxy/util"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
@@ -14,32 +13,20 @@ import (
|
||||
func TestPathExists(t *testing.T) {
|
||||
root, cleanup := util.CreateWorkDir(t)
|
||||
defer cleanup()
|
||||
testDirExistsFunc(t, root, func(s string) (bool, error) { return pathExists(s, true) })
|
||||
testFileExistsFunc(t, root, func(s string) (bool, error) { return pathExists(s, false) })
|
||||
testDirExistsFunc(t, root, func(s string) (bool, error) { return util.PathExists(s, true) })
|
||||
testFileExistsFunc(t, root, func(s string) (bool, error) { return util.PathExists(s, false) })
|
||||
}
|
||||
|
||||
func TestDirExists(t *testing.T) {
|
||||
root, cleanup := util.CreateWorkDir(t)
|
||||
defer cleanup()
|
||||
testDirExistsFunc(t, root, dirExists)
|
||||
testDirExistsFunc(t, root, util.DirExists)
|
||||
}
|
||||
|
||||
func TestFileExists(t *testing.T) {
|
||||
root, cleanup := util.CreateWorkDir(t)
|
||||
defer cleanup()
|
||||
testFileExistsFunc(t, root, fileExists)
|
||||
}
|
||||
|
||||
func TestWritable(t *testing.T) {
|
||||
root, cleanup := util.CreateWorkDir(t)
|
||||
defer cleanup()
|
||||
ok, err := writable(root)
|
||||
assert.NoError(t, err, "directory writable check")
|
||||
assert.True(t, ok, "directory should be writable")
|
||||
|
||||
ok, err = writable(nonWritableDir)
|
||||
assert.NoError(t, err, "system directory writable check")
|
||||
assert.False(t, ok, "system directory should not be writable")
|
||||
testFileExistsFunc(t, root, util.FileExists)
|
||||
}
|
||||
|
||||
func testDirExistsFunc(t *testing.T, root string, dirCheck func(string) (bool, error)) {
|
||||
@@ -1,15 +1,15 @@
|
||||
//go:build unix || (js && wasm) || wasip1
|
||||
|
||||
package fs
|
||||
package util
|
||||
|
||||
import "golang.org/x/sys/unix"
|
||||
|
||||
// writable reports whether path is writable on Windows.
|
||||
// Writable reports whether path is Writable on Windows.
|
||||
//
|
||||
// Semantics:
|
||||
// - for an existing regular file, it tries to open it for writing;
|
||||
// - for an existing directory, it tries to create and remove a temp file inside it;
|
||||
// - for other file types, it returns false with no error.
|
||||
func writable(filepath string) (bool, error) {
|
||||
func Writable(filepath string) (bool, error) {
|
||||
return unix.Access(filepath, unix.W_OK) == nil, nil
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//go:build unix || (js && wasm) || wasip1
|
||||
|
||||
package util_test
|
||||
|
||||
import (
|
||||
"galaxy/util"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestWritable(t *testing.T) {
|
||||
root := t.ArtifactDir()
|
||||
|
||||
ok, err := util.Writable(root)
|
||||
assert.NoError(t, err, "directory writable check")
|
||||
assert.True(t, ok, "directory should be writable")
|
||||
|
||||
ok, err = util.Writable(nonWritableDir)
|
||||
assert.NoError(t, err, "system directory writable check")
|
||||
assert.False(t, ok, "system directory should not be writable")
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package fs
|
||||
package util
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// writable reports whether path is writable on Windows.
|
||||
// Writable reports whether path is Writable on Windows.
|
||||
//
|
||||
// Semantics:
|
||||
// - for an existing regular file, it tries to open it for writing;
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
// This is intentionally an operational check, not a mode-bit check, because
|
||||
// on Windows effective writability is determined by ACLs and file attributes,
|
||||
// not by POSIX-like permission bits from os.FileMode.
|
||||
func writable(path string) (bool, error) {
|
||||
func Writable(path string) (bool, error) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
@@ -1,6 +1,6 @@
|
||||
//go:build windows
|
||||
|
||||
package fs
|
||||
package util
|
||||
|
||||
import (
|
||||
"os"
|
||||
@@ -20,7 +20,7 @@ func TestWritable_NewFile(t *testing.T) {
|
||||
err := os.WriteFile(path, []byte("x"), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
ok, err := writable(path)
|
||||
ok, err := Writable(path)
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
}
|
||||
@@ -32,7 +32,7 @@ func TestWritable_NewDirectory(t *testing.T) {
|
||||
|
||||
dir := t.TempDir()
|
||||
|
||||
ok, err := writable(dir)
|
||||
ok, err := Writable(dir)
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
}
|
||||
@@ -44,7 +44,7 @@ func TestWritable_MissingPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "missing")
|
||||
|
||||
ok, err := writable(path)
|
||||
ok, err := Writable(path)
|
||||
require.Error(t, err)
|
||||
require.False(t, ok)
|
||||
}
|
||||
+5
-1
@@ -2,7 +2,11 @@ module galaxy/util
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require github.com/stretchr/testify v1.11.1
|
||||
require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/sys v0.41.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package util_test
|
||||
|
||||
const (
|
||||
nonWritableDir = "/usr/lib"
|
||||
)
|
||||
+1
-1
@@ -7,7 +7,6 @@ require (
|
||||
github.com/go-playground/validator/v10 v10.30.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/sys v0.41.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -39,6 +38,7 @@ require (
|
||||
golang.org/x/arch v0.24.0 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/net v0.50.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding"
|
||||
"errors"
|
||||
"fmt"
|
||||
"galaxy/util"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -28,13 +29,13 @@ func NewFileStorage(path string) (*fs, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("path %s invalid: %s", path, err)
|
||||
}
|
||||
if ok, err := dirExists(absPath); err != nil {
|
||||
if ok, err := util.DirExists(absPath); err != nil {
|
||||
return nil, fmt.Errorf("check dir exist: %s", err)
|
||||
} else if !ok {
|
||||
return nil, errors.New("directory does not exist: " + absPath)
|
||||
}
|
||||
|
||||
if ok, err := writable(absPath); err != nil {
|
||||
if ok, err := util.Writable(absPath); err != nil {
|
||||
return nil, fmt.Errorf("check dir access: %s", err)
|
||||
} else if !ok {
|
||||
return nil, errors.New("directory should have read-write access: " + absPath)
|
||||
@@ -48,7 +49,7 @@ func NewFileStorage(path string) (*fs, error) {
|
||||
|
||||
func (f *fs) Lock() (func() error, error) {
|
||||
lockPath := f.lockFilePath()
|
||||
exists, err := fileExists(lockPath)
|
||||
exists, err := util.FileExists(lockPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("check lock file exists: %s", err)
|
||||
}
|
||||
@@ -77,7 +78,7 @@ func (f *fs) Lock() (func() error, error) {
|
||||
}
|
||||
|
||||
func (f *fs) Exists(path string) (bool, error) {
|
||||
return fileExists(filepath.Join(f.root, path))
|
||||
return util.FileExists(filepath.Join(f.root, path))
|
||||
}
|
||||
|
||||
func (f *fs) WriteSafe(path string, v encoding.BinaryMarshaler) error {
|
||||
@@ -97,7 +98,7 @@ func (f *fs) WriteSafe(path string, v encoding.BinaryMarshaler) error {
|
||||
|
||||
targetDir := filepath.Dir(targetFilePath)
|
||||
if targetDir != f.root {
|
||||
ok, err := dirExists(targetDir)
|
||||
ok, err := util.DirExists(targetDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check target dir exists: %s", err)
|
||||
}
|
||||
@@ -110,12 +111,12 @@ func (f *fs) WriteSafe(path string, v encoding.BinaryMarshaler) error {
|
||||
}
|
||||
oldFilePath := targetFilePath + oldFileSuffix
|
||||
|
||||
targetExists, err := fileExists(targetFilePath)
|
||||
targetExists, err := util.FileExists(targetFilePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check target file exists: %s", err)
|
||||
}
|
||||
if targetExists {
|
||||
oldFileExists, err := fileExists(oldFilePath)
|
||||
oldFileExists, err := util.FileExists(oldFilePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check old file exists: %s", err)
|
||||
}
|
||||
@@ -125,7 +126,7 @@ func (f *fs) WriteSafe(path string, v encoding.BinaryMarshaler) error {
|
||||
}
|
||||
|
||||
newFilePath := targetFilePath + newFileSuffix
|
||||
newFileExists, err := fileExists(newFilePath)
|
||||
newFileExists, err := util.FileExists(newFilePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check new file exists: %s", err)
|
||||
}
|
||||
|
||||
@@ -1,26 +1,45 @@
|
||||
package fs
|
||||
package fs_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"galaxy/server/internal/repo/fs"
|
||||
"galaxy/util"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
const (
|
||||
lockFile = ".lock"
|
||||
)
|
||||
|
||||
type sampleData struct {
|
||||
data []byte
|
||||
}
|
||||
|
||||
func (sd *sampleData) UnmarshalBinary(data []byte) error {
|
||||
sd.data = slices.Clone(data)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sd sampleData) MarshalBinary() (data []byte, err error) {
|
||||
return sd.data, nil
|
||||
}
|
||||
|
||||
func TestNewFileStorageSuccess(t *testing.T) {
|
||||
root, cleanup := util.CreateWorkDir(t)
|
||||
defer cleanup()
|
||||
_, err := NewFileStorage(root)
|
||||
_, err := fs.NewFileStorage(root)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestLock(t *testing.T) {
|
||||
root, cleanup := util.CreateWorkDir(t)
|
||||
defer cleanup()
|
||||
fs, err := NewFileStorage(root)
|
||||
fs, err := fs.NewFileStorage(root)
|
||||
assert.NoError(t, err, "create file storage")
|
||||
unlock, err := fs.Lock()
|
||||
assert.NoError(t, err, "acquire lock")
|
||||
@@ -40,7 +59,7 @@ func TestExist(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
fs, err := NewFileStorage(root)
|
||||
fs, err := fs.NewFileStorage(root)
|
||||
assert.NoError(t, err, "create file storage")
|
||||
|
||||
exist, err := fs.Exists(fileName)
|
||||
@@ -56,7 +75,7 @@ func TestWrite(t *testing.T) {
|
||||
root, cleanup := util.CreateWorkDir(t)
|
||||
defer cleanup()
|
||||
|
||||
fs, err := NewFileStorage(root)
|
||||
fs, err := fs.NewFileStorage(root)
|
||||
assert.NoError(t, err, "create file storage: %s", err)
|
||||
|
||||
unlock, err := fs.Lock()
|
||||
@@ -99,7 +118,7 @@ func TestRead(t *testing.T) {
|
||||
|
||||
sd := new(sampleData)
|
||||
|
||||
fs, err := NewFileStorage(root)
|
||||
fs, err := fs.NewFileStorage(root)
|
||||
assert.NoError(t, err, "create file storage: %s", err)
|
||||
|
||||
assert.EqualError(t, fs.Read("some.file", sd), "lock must be acquired before read")
|
||||
@@ -144,7 +163,7 @@ func TestRead(t *testing.T) {
|
||||
func TestWriteErrorWithoutLock(t *testing.T) {
|
||||
root, cleanup := util.CreateWorkDir(t)
|
||||
defer cleanup()
|
||||
fs, err := NewFileStorage(root)
|
||||
fs, err := fs.NewFileStorage(root)
|
||||
assert.NoError(t, err, "create file storage")
|
||||
sd := &sampleData{[]byte{0, 1, 2, 3}}
|
||||
err = fs.Write("some/path", sd)
|
||||
@@ -153,7 +172,7 @@ func TestWriteErrorWithoutLock(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNewFileStorageErrorNotExists(t *testing.T) {
|
||||
_, err := NewFileStorage(filepath.Join(os.TempDir(), "non-existent-dir"))
|
||||
_, err := fs.NewFileStorage(filepath.Join(os.TempDir(), "non-existent-dir"))
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
@@ -165,7 +184,7 @@ func TestNewFileStorageErrorNotADirectory(t *testing.T) {
|
||||
if err := f.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = NewFileStorage(f.Name())
|
||||
_, err = fs.NewFileStorage(f.Name())
|
||||
assert.Error(t, err)
|
||||
if err := os.Remove(f.Name()); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -173,6 +192,6 @@ func TestNewFileStorageErrorNotADirectory(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNewFileStorageErrorNoAccess(t *testing.T) {
|
||||
_, err := NewFileStorage(nonWritableDir)
|
||||
_, err := fs.NewFileStorage("/some/random/dir")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
package fs
|
||||
|
||||
import (
|
||||
"slices"
|
||||
)
|
||||
|
||||
const (
|
||||
nonWritableDir = "/usr/lib"
|
||||
)
|
||||
|
||||
type sampleData struct {
|
||||
data []byte
|
||||
}
|
||||
|
||||
func (sd *sampleData) UnmarshalBinary(data []byte) error {
|
||||
sd.data = slices.Clone(data)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sd sampleData) MarshalBinary() (data []byte, err error) {
|
||||
return sd.data, nil
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
// for windows builds func [writable] should be refactored
|
||||
package fs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func dirExists(path string) (bool, error) {
|
||||
return pathExists(path, true)
|
||||
}
|
||||
|
||||
func fileExists(path string) (bool, error) {
|
||||
return pathExists(path, false)
|
||||
}
|
||||
|
||||
func pathExists(path string, isDir bool) (bool, error) {
|
||||
if fi, err := os.Stat(path); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
} else {
|
||||
if isDir != fi.IsDir() {
|
||||
return false, fmt.Errorf("wrong type: "+path+" mode=%s isDir=%t", fi.Mode(), isDir)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user