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
|
package client
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"image"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"galaxy/client/world"
|
||||||
|
"galaxy/connector"
|
||||||
|
mc "galaxy/model/client"
|
||||||
|
"galaxy/model/report"
|
||||||
|
|
||||||
"fyne.io/fyne/v2"
|
"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
|
app fyne.App
|
||||||
window fyne.Window
|
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 {
|
func NewClient(conn connector.UIConnector, app fyne.App, settings mc.Settings) (mc.Client, error) {
|
||||||
c := &ui{}
|
e := &client{
|
||||||
c.app = app.New()
|
conn: conn,
|
||||||
c.window = c.app.NewWindow("Galaxy Plus")
|
app: app,
|
||||||
client := NewClient()
|
window: app.NewWindow("Galaxy Plus"),
|
||||||
client.BuildUI(c.window)
|
world: nil,
|
||||||
return c
|
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() {
|
func (e *client) loadReport(t uint) {
|
||||||
c.window.ShowAndRun()
|
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
|
package main
|
||||||
|
|
||||||
import "galaxy/client"
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"galaxy/client"
|
||||||
|
mc "galaxy/model/client"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"fyne.io/fyne/v2/app"
|
||||||
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
c := client.NewUI()
|
var err error
|
||||||
c.Run()
|
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
|
package client
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
"math"
|
"math"
|
||||||
|
|
||||||
@@ -193,6 +194,146 @@ func (e *client) eventPosToPixel(eventX, eventY float32) (xPx, yPx int, ok bool)
|
|||||||
return x, y, true
|
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.
|
// copyViewportRGBA copies a viewport rectangle from src RGBA into dst RGBA.
|
||||||
// dst must be sized exactly (0,0)-(vw,vh). This is allocation-free.
|
// 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.
|
// 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.
|
// 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) {
|
func (w *World) SetTheme(theme StyleTheme) {
|
||||||
if theme == nil {
|
if theme == nil {
|
||||||
theme = DefaultTheme{}
|
theme = DefaultTheme{}
|
||||||
}
|
}
|
||||||
// fmt.Println("current theme:", w.theme.ID())
|
|
||||||
w.theme = theme
|
w.theme = theme
|
||||||
// fmt.Println("new theme:", w.theme.ID())
|
|
||||||
|
|
||||||
// Drop derived cache when theme changes to avoid unbounded growth.
|
// Drop derived cache when theme changes to avoid unbounded growth.
|
||||||
for k := range w.derivedCache {
|
for k := range w.derivedCache {
|
||||||
@@ -174,7 +167,6 @@ func (w *World) SetTheme(theme StyleTheme) {
|
|||||||
|
|
||||||
// Full redraw to apply new background and base styles.
|
// Full redraw to apply new background and base styles.
|
||||||
w.renderState.Reset()
|
w.renderState.Reset()
|
||||||
// w.forceFullRedraw = true
|
|
||||||
w.ForceFullRedrawNext()
|
w.ForceFullRedrawNext()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -503,36 +495,6 @@ func (w *World) AddLine(x1, y1, x2, y2 float64, opts ...LineOpt) (PrimitiveID, e
|
|||||||
return id, nil
|
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.
|
// worldToCellX converts a fixed-point X coordinate to a grid column index.
|
||||||
func (w *World) worldToCellX(x int) int {
|
func (w *World) worldToCellX(x int) int {
|
||||||
return worldToCell(x, w.W, w.cols, w.cellSize)
|
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 (
|
use (
|
||||||
./client
|
./client
|
||||||
|
./connector
|
||||||
|
./loader
|
||||||
./pkg/error
|
./pkg/error
|
||||||
./pkg/model
|
./pkg/model
|
||||||
./pkg/util
|
./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 {
|
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"`
|
Commands []DecodableCommand `json:"cmd"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ func F(v float64) Float {
|
|||||||
return Float(u.Fixed3(v))
|
return Float(u.Fixed3(v))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f Float) F() float64 {
|
||||||
|
return float64(f)
|
||||||
|
}
|
||||||
|
|
||||||
type Report struct {
|
type Report struct {
|
||||||
Version uint `json:"version"`
|
Version uint `json:"version"`
|
||||||
Turn uint `json:"turn"`
|
Turn uint `json:"turn"`
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package util
|
package util
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"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 (
|
import (
|
||||||
|
"galaxy/util"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"galaxy/util"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
@@ -14,32 +13,20 @@ import (
|
|||||||
func TestPathExists(t *testing.T) {
|
func TestPathExists(t *testing.T) {
|
||||||
root, cleanup := util.CreateWorkDir(t)
|
root, cleanup := util.CreateWorkDir(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
testDirExistsFunc(t, root, func(s string) (bool, error) { return pathExists(s, true) })
|
testDirExistsFunc(t, root, func(s string) (bool, error) { return util.PathExists(s, true) })
|
||||||
testFileExistsFunc(t, root, func(s string) (bool, error) { return pathExists(s, false) })
|
testFileExistsFunc(t, root, func(s string) (bool, error) { return util.PathExists(s, false) })
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDirExists(t *testing.T) {
|
func TestDirExists(t *testing.T) {
|
||||||
root, cleanup := util.CreateWorkDir(t)
|
root, cleanup := util.CreateWorkDir(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
testDirExistsFunc(t, root, dirExists)
|
testDirExistsFunc(t, root, util.DirExists)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFileExists(t *testing.T) {
|
func TestFileExists(t *testing.T) {
|
||||||
root, cleanup := util.CreateWorkDir(t)
|
root, cleanup := util.CreateWorkDir(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
testFileExistsFunc(t, root, fileExists)
|
testFileExistsFunc(t, root, util.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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func testDirExistsFunc(t *testing.T, root string, dirCheck func(string) (bool, error)) {
|
func testDirExistsFunc(t *testing.T, root string, dirCheck func(string) (bool, error)) {
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
//go:build unix || (js && wasm) || wasip1
|
//go:build unix || (js && wasm) || wasip1
|
||||||
|
|
||||||
package fs
|
package util
|
||||||
|
|
||||||
import "golang.org/x/sys/unix"
|
import "golang.org/x/sys/unix"
|
||||||
|
|
||||||
// writable reports whether path is writable on Windows.
|
// Writable reports whether path is Writable on Windows.
|
||||||
//
|
//
|
||||||
// Semantics:
|
// Semantics:
|
||||||
// - for an existing regular file, it tries to open it for writing;
|
// - 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 an existing directory, it tries to create and remove a temp file inside it;
|
||||||
// - for other file types, it returns false with no error.
|
// - 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
|
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 (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
)
|
)
|
||||||
|
|
||||||
// writable reports whether path is writable on Windows.
|
// Writable reports whether path is Writable on Windows.
|
||||||
//
|
//
|
||||||
// Semantics:
|
// Semantics:
|
||||||
// - for an existing regular file, it tries to open it for writing;
|
// - 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
|
// This is intentionally an operational check, not a mode-bit check, because
|
||||||
// on Windows effective writability is determined by ACLs and file attributes,
|
// on Windows effective writability is determined by ACLs and file attributes,
|
||||||
// not by POSIX-like permission bits from os.FileMode.
|
// 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)
|
info, err := os.Stat(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
//go:build windows
|
//go:build windows
|
||||||
|
|
||||||
package fs
|
package util
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
@@ -20,7 +20,7 @@ func TestWritable_NewFile(t *testing.T) {
|
|||||||
err := os.WriteFile(path, []byte("x"), 0o600)
|
err := os.WriteFile(path, []byte("x"), 0o600)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
ok, err := writable(path)
|
ok, err := Writable(path)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.True(t, ok)
|
require.True(t, ok)
|
||||||
}
|
}
|
||||||
@@ -32,7 +32,7 @@ func TestWritable_NewDirectory(t *testing.T) {
|
|||||||
|
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
|
|
||||||
ok, err := writable(dir)
|
ok, err := Writable(dir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.True(t, ok)
|
require.True(t, ok)
|
||||||
}
|
}
|
||||||
@@ -44,7 +44,7 @@ func TestWritable_MissingPath(t *testing.T) {
|
|||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "missing")
|
path := filepath.Join(dir, "missing")
|
||||||
|
|
||||||
ok, err := writable(path)
|
ok, err := Writable(path)
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
require.False(t, ok)
|
require.False(t, ok)
|
||||||
}
|
}
|
||||||
+5
-1
@@ -2,7 +2,11 @@ module galaxy/util
|
|||||||
|
|
||||||
go 1.26.0
|
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 (
|
require (
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
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/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/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
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/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
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/go-playground/validator/v10 v10.30.1
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
golang.org/x/sys v0.41.0
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -39,6 +38,7 @@ require (
|
|||||||
golang.org/x/arch v0.24.0 // indirect
|
golang.org/x/arch v0.24.0 // indirect
|
||||||
golang.org/x/crypto v0.48.0 // indirect
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
golang.org/x/net v0.50.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
|
golang.org/x/text v0.34.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding"
|
"encoding"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"galaxy/util"
|
||||||
"math/big"
|
"math/big"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -28,13 +29,13 @@ func NewFileStorage(path string) (*fs, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("path %s invalid: %s", path, err)
|
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)
|
return nil, fmt.Errorf("check dir exist: %s", err)
|
||||||
} else if !ok {
|
} else if !ok {
|
||||||
return nil, errors.New("directory does not exist: " + absPath)
|
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)
|
return nil, fmt.Errorf("check dir access: %s", err)
|
||||||
} else if !ok {
|
} else if !ok {
|
||||||
return nil, errors.New("directory should have read-write access: " + absPath)
|
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) {
|
func (f *fs) Lock() (func() error, error) {
|
||||||
lockPath := f.lockFilePath()
|
lockPath := f.lockFilePath()
|
||||||
exists, err := fileExists(lockPath)
|
exists, err := util.FileExists(lockPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("check lock file exists: %s", err)
|
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) {
|
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 {
|
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)
|
targetDir := filepath.Dir(targetFilePath)
|
||||||
if targetDir != f.root {
|
if targetDir != f.root {
|
||||||
ok, err := dirExists(targetDir)
|
ok, err := util.DirExists(targetDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("check target dir exists: %s", err)
|
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
|
oldFilePath := targetFilePath + oldFileSuffix
|
||||||
|
|
||||||
targetExists, err := fileExists(targetFilePath)
|
targetExists, err := util.FileExists(targetFilePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("check target file exists: %s", err)
|
return fmt.Errorf("check target file exists: %s", err)
|
||||||
}
|
}
|
||||||
if targetExists {
|
if targetExists {
|
||||||
oldFileExists, err := fileExists(oldFilePath)
|
oldFileExists, err := util.FileExists(oldFilePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("check old file exists: %s", err)
|
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
|
newFilePath := targetFilePath + newFileSuffix
|
||||||
newFileExists, err := fileExists(newFilePath)
|
newFileExists, err := util.FileExists(newFilePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("check new file exists: %s", err)
|
return fmt.Errorf("check new file exists: %s", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,45 @@
|
|||||||
package fs
|
package fs_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"galaxy/server/internal/repo/fs"
|
||||||
"galaxy/util"
|
"galaxy/util"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"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) {
|
func TestNewFileStorageSuccess(t *testing.T) {
|
||||||
root, cleanup := util.CreateWorkDir(t)
|
root, cleanup := util.CreateWorkDir(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
_, err := NewFileStorage(root)
|
_, err := fs.NewFileStorage(root)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLock(t *testing.T) {
|
func TestLock(t *testing.T) {
|
||||||
root, cleanup := util.CreateWorkDir(t)
|
root, cleanup := util.CreateWorkDir(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
fs, err := NewFileStorage(root)
|
fs, err := fs.NewFileStorage(root)
|
||||||
assert.NoError(t, err, "create file storage")
|
assert.NoError(t, err, "create file storage")
|
||||||
unlock, err := fs.Lock()
|
unlock, err := fs.Lock()
|
||||||
assert.NoError(t, err, "acquire lock")
|
assert.NoError(t, err, "acquire lock")
|
||||||
@@ -40,7 +59,7 @@ func TestExist(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fs, err := NewFileStorage(root)
|
fs, err := fs.NewFileStorage(root)
|
||||||
assert.NoError(t, err, "create file storage")
|
assert.NoError(t, err, "create file storage")
|
||||||
|
|
||||||
exist, err := fs.Exists(fileName)
|
exist, err := fs.Exists(fileName)
|
||||||
@@ -56,7 +75,7 @@ func TestWrite(t *testing.T) {
|
|||||||
root, cleanup := util.CreateWorkDir(t)
|
root, cleanup := util.CreateWorkDir(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
fs, err := NewFileStorage(root)
|
fs, err := fs.NewFileStorage(root)
|
||||||
assert.NoError(t, err, "create file storage: %s", err)
|
assert.NoError(t, err, "create file storage: %s", err)
|
||||||
|
|
||||||
unlock, err := fs.Lock()
|
unlock, err := fs.Lock()
|
||||||
@@ -99,7 +118,7 @@ func TestRead(t *testing.T) {
|
|||||||
|
|
||||||
sd := new(sampleData)
|
sd := new(sampleData)
|
||||||
|
|
||||||
fs, err := NewFileStorage(root)
|
fs, err := fs.NewFileStorage(root)
|
||||||
assert.NoError(t, err, "create file storage: %s", err)
|
assert.NoError(t, err, "create file storage: %s", err)
|
||||||
|
|
||||||
assert.EqualError(t, fs.Read("some.file", sd), "lock must be acquired before read")
|
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) {
|
func TestWriteErrorWithoutLock(t *testing.T) {
|
||||||
root, cleanup := util.CreateWorkDir(t)
|
root, cleanup := util.CreateWorkDir(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
fs, err := NewFileStorage(root)
|
fs, err := fs.NewFileStorage(root)
|
||||||
assert.NoError(t, err, "create file storage")
|
assert.NoError(t, err, "create file storage")
|
||||||
sd := &sampleData{[]byte{0, 1, 2, 3}}
|
sd := &sampleData{[]byte{0, 1, 2, 3}}
|
||||||
err = fs.Write("some/path", sd)
|
err = fs.Write("some/path", sd)
|
||||||
@@ -153,7 +172,7 @@ func TestWriteErrorWithoutLock(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestNewFileStorageErrorNotExists(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)
|
assert.Error(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +184,7 @@ func TestNewFileStorageErrorNotADirectory(t *testing.T) {
|
|||||||
if err := f.Close(); err != nil {
|
if err := f.Close(); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
_, err = NewFileStorage(f.Name())
|
_, err = fs.NewFileStorage(f.Name())
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
if err := os.Remove(f.Name()); err != nil {
|
if err := os.Remove(f.Name()); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -173,6 +192,6 @@ func TestNewFileStorageErrorNotADirectory(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestNewFileStorageErrorNoAccess(t *testing.T) {
|
func TestNewFileStorageErrorNoAccess(t *testing.T) {
|
||||||
_, err := NewFileStorage(nonWritableDir)
|
_, err := fs.NewFileStorage("/some/random/dir")
|
||||||
assert.Error(t, err)
|
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