ui calculator

This commit is contained in:
Ilia Denisov
2026-03-30 19:38:24 +02:00
committed by GitHub
parent 17f366cd6b
commit a7793f5416
37 changed files with 2046 additions and 270 deletions
+218 -23
View File
@@ -1,34 +1,229 @@
# Galaxy # Services Architecture
Galaxy is a turn-based strategy game which took place in space. Galaxy Plus: Turn-based Strategy Game
At the highest level Game has a Backend service and an UI Client. ## Purpose
## Backend service This document fixes the high-level service architecture of the system.
It is the starting point for implementing the external edge layer, authentication/session management, business services, and push delivery.
Backend service is presented by several "microservices" with a different set of responsibilities. ## Main Principles
- AuthN Proxy Service: handles all incoming requests, - The system exposes a single external entry point: **Edge Gateway**.
immediately rejects not-authenticated requests and passes authenticated request to the next service. - Internal business services are **not reachable directly from outside**.
- Any external command, except public auth commands, must be authenticated before it is routed further.
- Gateway handles only edge concerns. Business validation and domain rules remain inside business services.
- Push / long-polling delivery is also handled by the gateway.
- AuthZ Service: checks permissions avaliable to the authenticated user and passes request to the next service ## Main Components
if user is authorized to use specific game api.
- Games Orchestrator Service: ### 1. Edge Gateway
- finds appropriate Game Server according to Client's request data,
- passes user's commands to the selected Game Server,
- reads response and bounces it back up to request chain to the Client,
- manages Game Server state for health monitoring and making next turn.
- Game Servers: several instances of ongoing games with avaliable unprotected API ready to receive Player's and Administrator's commands. The gateway is the only public entry point for client traffic.
## UI Client Responsibilities:
UI Client is capable of: - transport parsing
- authentication of external requests
- transport integrity checks
- session cache lookup
- request signature verification
- timestamp window verification
- anti-replay checks
- rate limiting and abuse protection
- command routing
- basic policy enforcement
- long-polling / push connection handling
- delivery of client-facing events from pub/sub
- Register a new player and login for an existing player using only e-mail and one-time codes, The gateway must not implement domain-specific business logic.
- Enlist to a new Game from available onboard Games list,
- Request list of Games in which Player participating, ### 2. Auth / Session Service
- Request, store and display particular Game data,
- Use push-like mechanism for receiving asynchronous updates from Server, This service owns authentication and device session lifecycle.
- Offline mode when no internet connection is available or user desired to work offline.
Responsibilities:
- `send_email_code`
- `confirm_email_code`
- device session creation
- public key registration for device sessions
- session revoke / logout
- persistence of session state
- publishing session state changes for cache invalidation/update
This service is the source of truth for `device_session` state.
### 3. Session Store
Persistent storage for device sessions.
Typical fields:
- `device_session_id`
- `user_id`
- client public key
- session status
- creation / revoke timestamps
- optional client metadata
### 4. Session Cache
Fast lookup cache used by the gateway.
Purpose:
- resolve `device_session_id -> user_id + public_key + status`
- avoid synchronous calls from gateway to auth/session service on every request
Cache updates should be driven by session lifecycle events and may also use TTL.
### 5. Anti-Replay Store
Edge-level storage for recently seen transport `request_id` values.
Purpose:
- reject replayed authenticated transport messages within the allowed time window
This is transport-level replay protection, not business idempotency.
### 6. Rate Limit Store
Shared state for edge-level throttling and abuse control.
Typical dimensions:
- IP / network
- `device_session_id`
- `user_id`
- command class
### 7. Business Services
Internal services that process authenticated commands.
Responsibilities:
- business validation
- authorization by `user_id`
- ownership checks
- domain invariants
- state transitions
- business idempotency where required
- publishing domain events and/or client-facing events
Business services do not verify external signatures and do not access external clients directly.
### 8. Event Bus / Pub-Sub
Used for internal event distribution.
Purposes:
- session cache invalidation/update
- client-facing event delivery through the gateway
- optional internal domain event propagation between services
## External Flows
### Public Auth Flow
These commands are public and do not require an existing device session:
- `send_email_code`
- `confirm_email_code`
Flow:
1. client sends public auth command to gateway
2. gateway applies public-edge checks (format, rate limits, abuse policy)
3. gateway routes command to auth/session service
4. auth/session service performs auth logic
5. if login is confirmed, auth/session service creates `device_session`
6. auth/session service publishes cache update/invalidation event
### Authenticated Command Flow
All other external commands require authentication.
Flow:
1. client sends authenticated request to gateway
2. gateway validates transport envelope presence and protocol version
3. gateway resolves `device_session_id` through session cache
4. gateway rejects unknown or revoked sessions
5. gateway verifies request signature
6. gateway verifies timestamp window
7. gateway verifies anti-replay constraints
8. gateway applies rate limits and basic policy checks
9. gateway extracts authenticated context, including `user_id`
10. gateway routes the request to the target business service based on `command_type`
No business service should receive an unauthenticated external request.
### Push / Long-Polling Flow
The gateway owns external push / long-polling connections.
Flow:
1. client opens authenticated push / long-polling connection through gateway
2. gateway binds connection to `user_id` and `device_session_id`
3. gateway may send current server time for clock offset calculation
4. internal services publish client-facing events to pub/sub
5. gateway consumes those events and delivers them to the proper client connections
Gateway is a delivery layer, not the source of business events.
## Internal Contract Between Gateway and Business Services
Business services should receive an internal authenticated command, not raw external transport data.
Typical internal authenticated context:
- `user_id`
- `device_session_id`
- `command_type`
- verified payload bytes
- transport `request_id`
- optional command id / trace id
- optional client metadata relevant for logging
Business services must trust only the gateway as their external ingress.
## Separation of Responsibilities
### Gateway is responsible for
- who sent the request
- whether transport integrity is valid
- whether the request is fresh
- whether replay is detected
- whether request volume is acceptable
- where to route the request
### Business services are responsible for
- whether the user is allowed to perform the business action
- whether the target object belongs to the user
- whether the domain state transition is valid
- whether business idempotency rules are satisfied
## Revocation Behavior
When a device session is revoked:
- auth/session service updates the source of truth
- auth/session service publishes revoke/invalidation event
- gateway updates or invalidates session cache
- gateway rejects further requests for that session
- gateway closes active push / long-polling connections bound to that session, if applicable
## Non-Goals
The gateway is not a place for full domain authorization logic.
It must not become a business “god service”.
The auth/session service is not the hot path for every authenticated request.
The gateway should authenticate most requests from cache, not by synchronous round-trips.
+204
View File
@@ -0,0 +1,204 @@
# Secure Exchange Architecture
## Purpose
This document fixes the transport-level secure exchange model between client and server.
It is the starting point for implementing authenticated device sessions, signed requests/responses, and anti-replay protection.
## Main Principles
- No browser cookies are used.
- Authentication is device-session based.
- Each device/session is unique and independently revocable.
- There are no short-lived access tokens or refresh-token flows in the main design.
- Requests are authenticated by client-side signatures.
- Responses are authenticated by server-side signatures.
- Transport integrity and freshness are verified before payload is processed.
## Device Session Model
After successful login through e-mail code:
1. client generates an asymmetric key pair
2. private key remains on the client device
3. public key is registered on the server
4. server creates a persistent `device_session`
5. client stores:
- `device_session_id`
- private key
The server stores at least:
- `device_session_id`
- `user_id`
- client public key
- session status
- revoke metadata
## Key Storage
### Native Clients
Private key should be stored in platform secure storage.
### Browser / WASM Clients
Private key should be created and used through WebCrypto.
Non-exportable key storage is preferred.
Loss of browser storage is acceptable and means re-login is required.
## Request Structure
Each authenticated request logically contains:
- `payload_bytes`
- `request_envelope`
- `signature`
### Request Envelope
Minimal required fields:
- `protocol_version`
- `device_session_id`
- `message_type`
- `timestamp_ms`
- `request_id`
- `payload_hash`
### Request Signing Input
The client signs canonical bytes built from:
- request domain marker, for example `myapp-request-v1`
- `protocol_version`
- `device_session_id`
- `message_type`
- `timestamp_ms`
- `request_id`
- `payload_hash`
`payload_hash` should be computed from raw `payload_bytes`.
The goal is to bind the signature to:
- the concrete device session
- the concrete message type
- the concrete payload
- a fresh request instance
## Response Structure
Each server response logically contains:
- `payload_bytes`
- `response_envelope`
- `signature`
### Response Envelope
Minimal required fields:
- `protocol_version`
- `request_id`
- `timestamp_ms`
- `result_code`
- `payload_hash`
### Response Signing Input
The server signs canonical bytes built from:
- response domain marker, for example `myapp-response-v1`
- `protocol_version`
- `request_id`
- `timestamp_ms`
- `result_code`
- `payload_hash`
The client verifies the signature using a trusted server public key.
## Verification Order on Server
Before processing payload, the server/gateway must:
1. verify that the transport envelope is present and supported
2. resolve `device_session_id`
3. reject unknown or revoked sessions
4. verify client signature using stored public key
5. verify timestamp freshness window
6. verify anti-replay constraints using `request_id`
7. only then pass payload to business processing
## Verification Order on Client
Before accepting response payload, the client must:
1. verify server signature
2. verify `request_id` matches the corresponding request
3. verify `payload_hash`
4. verify timestamp freshness if applicable
5. only then accept the response payload
## Anti-Replay Model
Transport anti-replay uses:
- `timestamp_ms`
- `request_id`
The server accepts requests only inside an allowed time window.
Recently seen `request_id` values must be tracked for the corresponding session and rejected on reuse.
This protects transport freshness.
It does not replace business idempotency.
## Server Time Offset
Clients use server time offset instead of trusting local clock directly.
Expected approach:
- client establishes authenticated long-polling / push connection
- server provides current server time
- client computes local offset
- subsequent signed requests use adjusted time
No extra sync request is required if push / long-polling already exists.
## TLS and MITM Considerations
### Native Clients notes
Native clients should use TLS pinning in addition to signed request/response exchange.
Pinning should be based on public key / SPKI rather than leaf certificate whenever possible.
### Browser / WASM Clients notes
Real TLS pinning is not available in the browser in the same way as in native clients.
Browser clients still use the signed request/response model, but browser-managed TLS remains the platform limitation.
## Threat Model Boundaries
This design protects against:
- request/response tampering in transit
- replay of previously seen transport messages inside the protected window
- use of unknown or revoked device sessions
- forged server responses without server signing key
- forged client requests without client signing key
This design does not guarantee that a legitimate user cannot generate their own valid requests from their own client environment.
That is handled by server-side business validation and authorization.
## Architectural Notes
- Transport authentication and business authorization are separate concerns.
- Signed transport proves message origin and integrity.
- Business services must still validate command correctness, ownership, permissions, and state transitions.
- Transport `request_id` is not the same as business idempotency key.
## Recommended Outcome
The system should treat the secure exchange layer as the mandatory outer contract for all authenticated traffic.
Only after successful transport validation may payload be routed to business logic.
+7 -19
View File
@@ -1,22 +1,10 @@
# Client for Galaxy Plus # Client for Galaxy Plus
## Ship Calculator UI Client is capable of:
```text - Register a new player and login for an existing player using only e-mail and one-time codes,
Class: [ ] { Create } - Enlist to a new Game from available onboard Games list,
Drives: [20.000] x 1.013 [O--] - Request list of Games in which Player participating,
Weapons: [ 0.000] x [1.000] [==0] - Request, store and display particular Game data,
Armament: [ 0 ] - Use push-like mechanism for receiving asynchronous updates from Server,
Schields: [ 5.500] @ [1.123] - Offline mode when no internet connection is available or user desired to work offline.
Cargo: [30.125] @ [1.320]
Mass: [ 123,45 ] [==0]
Speed: ( 12,456 ) [O--]
Attack: 0
Defense: 100,0
Planet { Name } Production:
[ 100.0 ] MAT per turn produced [O--] supplied
{ N.000 } ship(s) per turn
{ M.000 } turn(s) per ship
```
+50 -3
View File
@@ -1,6 +1,7 @@
package client package client
import ( import (
"fmt"
"time" "time"
gerr "galaxy/error" gerr "galaxy/error"
@@ -9,10 +10,11 @@ import (
var ( var (
checkConnectionInterval = 5 * time.Second checkConnectionInterval = 5 * time.Second
checkVersionInterval = time.Hour checkVersionInterval = time.Hour
statePersistInterval = time.Second
) )
func (e *client) startBackground() { func (e *client) startBackground() {
if e.fullConnector == nil && e.updater == nil { if e.conn == nil || e.updater == nil {
return return
} }
@@ -28,9 +30,11 @@ func (e *client) stopBackground() {
func (e *client) backgroundLoop() { func (e *client) backgroundLoop() {
checkConnTimer := time.NewTimer(checkConnectionInterval) checkConnTimer := time.NewTimer(checkConnectionInterval)
checkVersionTimer := time.NewTimer(checkVersionInterval) checkVersionTimer := time.NewTimer(checkVersionInterval)
persistStateTimer := time.NewTimer(statePersistInterval)
defer func() { defer func() {
checkConnTimer.Stop() checkConnTimer.Stop()
checkVersionTimer.Stop() checkVersionTimer.Stop()
persistStateTimer.Stop()
}() }()
for { for {
@@ -38,8 +42,8 @@ func (e *client) backgroundLoop() {
case <-e.backgroundStop: case <-e.backgroundStop:
return return
case <-checkConnTimer.C: case <-checkConnTimer.C:
if e.fullConnector != nil { if e.conn != nil {
e.OnConnection(e.fullConnector.CheckConnection()) e.OnConnection(e.conn.CheckConnection())
} }
checkConnTimer.Reset(checkConnectionInterval) checkConnTimer.Reset(checkConnectionInterval)
case <-checkVersionTimer.C: case <-checkVersionTimer.C:
@@ -49,15 +53,58 @@ func (e *client) backgroundLoop() {
} }
} }
checkVersionTimer.Reset(checkVersionInterval) checkVersionTimer.Reset(checkVersionInterval)
case <-persistStateTimer.C:
e.ensureStatePersist()
persistStateTimer.Reset(statePersistInterval)
} }
} }
} }
func (e *client) ensureStatePersist() {
param := e.GetParams()
needSaving := false
e.stateMu.Lock()
if e.world != nil {
if param.CameraZoom > 0 && param.CameraZoom != e.state.CameraZoom {
e.state.CameraZoom = param.CameraZoom
needSaving = true
}
if param.CameraXWorldFp != e.state.CameraXFp {
e.state.CameraXFp = param.CameraXWorldFp
needSaving = true
}
if param.CameraYWorldFp != e.state.CameraYFp {
e.state.CameraYFp = param.CameraYWorldFp
needSaving = true
}
}
if e.mapSplitter != nil && e.mapSplitter.Offset != e.state.MapSplitterOffset {
e.state.MapSplitterOffset = e.mapSplitter.Offset
needSaving = true
}
if e.accInfo.Open != e.state.AccordionInfoOpen {
e.state.AccordionInfoOpen = e.accInfo.Open
needSaving = true
}
if e.accCalc.Open != e.state.AccordionCalcOpen {
e.state.AccordionCalcOpen = e.accCalc.Open
needSaving = true
}
if needSaving {
if err := e.s.SaveState(*e.state); err != nil {
e.handlerError(err)
}
}
e.stateMu.Unlock()
}
func (e *client) handlerError(err error) { func (e *client) handlerError(err error) {
if err == nil { if err == nil {
return return
} }
fmt.Printf("ERROR: %s\n", err)
switch { switch {
case gerr.IsConnection(err): case gerr.IsConnection(err):
e.OnConnectionError(err) e.OnConnectionError(err)
-3
View File
@@ -11,7 +11,6 @@ import (
type interactiveRaster struct { type interactiveRaster struct {
widget.BaseWidget widget.BaseWidget
edit *client
min fyne.Size min fyne.Size
raster *canvas.Raster raster *canvas.Raster
onLayout func(fyne.Size) onLayout func(fyne.Size)
@@ -50,7 +49,6 @@ func (r *interactiveRaster) Tapped(ev *fyne.PointEvent) {
func (r *interactiveRaster) TappedSecondary(*fyne.PointEvent) {} func (r *interactiveRaster) TappedSecondary(*fyne.PointEvent) {}
func newInteractiveRaster( func newInteractiveRaster(
edit *client,
raster *canvas.Raster, raster *canvas.Raster,
onLayout func(fyne.Size), onLayout func(fyne.Size),
onScrolled func(*fyne.ScrollEvent), onScrolled func(*fyne.ScrollEvent),
@@ -60,7 +58,6 @@ func newInteractiveRaster(
) *interactiveRaster { ) *interactiveRaster {
r := &interactiveRaster{ r := &interactiveRaster{
raster: raster, raster: raster,
edit: edit,
onLayout: onLayout, onLayout: onLayout,
onScrolled: onScrolled, onScrolled: onScrolled,
onDragged: onDragged, onDragged: onDragged,
+92 -55
View File
@@ -5,15 +5,16 @@ import (
"sync" "sync"
"galaxy/client/updater" "galaxy/client/updater"
"galaxy/client/widget/calculator"
"galaxy/client/world" "galaxy/client/world"
"galaxy/connector" "galaxy/connector"
mc "galaxy/model/client" mc "galaxy/model/client"
"galaxy/model/report"
"galaxy/storage" "galaxy/storage"
"fyne.io/fyne/v2" "fyne.io/fyne/v2"
"fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/container" "fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/lang"
"fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget" "fyne.io/fyne/v2/widget"
) )
@@ -21,12 +22,22 @@ import (
const version = "1.0.0" const version = "1.0.0"
type client struct { type client struct {
s storage.UIStorage s storage.Storage
conn connector.UIConnector conn connector.Connector
app fyne.App app fyne.App
window fyne.Window window fyne.Window
loadReportFunc func(uint) state *mc.State
stateMu sync.RWMutex
reg *registry
calculator *calculator.Calculator
mapSplitter *container.Split
accInfo *widget.AccordionItem
accCalc *widget.AccordionItem
// loadReportFunc func(uint)
world *world.World world *world.World
drawer *world.GGDrawer drawer *world.GGDrawer
@@ -69,8 +80,6 @@ type client struct {
hits []world.Hit hits []world.Hit
fullStorage storage.Storage
fullConnector connector.Connector
updater *updater.Manager updater *updater.Manager
backgroundStop chan struct{} backgroundStop chan struct{}
backgroundOnce sync.Once backgroundOnce sync.Once
@@ -81,32 +90,55 @@ type client struct {
onServiceErrFn func(error) onServiceErrFn func(error)
} }
func NewClient(s storage.UIStorage, conn connector.UIConnector, app fyne.App) (mc.Client, error) { func NewClient(s storage.Storage, conn connector.Connector, app fyne.App) (mc.Client, error) {
e := &client{ e := &client{
s: s, s: s,
conn: conn, conn: conn,
app: app, app: app,
window: app.NewWindow("Galaxy Plus"), window: app.NewWindow("Galaxy Plus"),
world: nil, reg: newRegistry(),
wp: &world.RenderParams{
CameraZoom: 1.0,
Options: &world.RenderOptions{DisableWrapScroll: false},
},
lastCanvasScale: 1.0, lastCanvasScale: 1.0,
world: nil,
hits: make([]world.Hit, 5), hits: make([]world.Hit, 5),
backgroundStop: make(chan struct{}), backgroundStop: make(chan struct{}),
} }
if fullStorage, ok := s.(storage.Storage); ok { e.calculator = calculator.NewCaclulator(calculator.WithCreateHandler(e.createShipClass))
e.fullStorage = fullStorage e.updater = updater.NewManager(e.s, e.conn)
}
if fullConnector, ok := conn.(connector.Connector); ok {
e.fullConnector = fullConnector
}
if e.fullStorage != nil && e.fullConnector != nil {
e.updater = updater.NewManager(e.fullStorage, e.fullConnector)
}
e.loadReportFunc = e.loadReport stateExists, err := e.s.StateExists()
if err != nil {
return nil, err
}
if stateExists {
state, err := e.s.LoadState()
if err != nil {
return nil, err
}
e.state = &state
} else {
e.state = &mc.State{
ClientCurrentVersion: e.Version(),
CameraZoom: 1.0,
MapSplitterOffset: 0.5,
AccordionInfoOpen: false,
AccordionCalcOpen: false,
}
if err := e.s.SaveState(*e.state); err != nil {
return nil, err
}
}
if e.state.CameraZoom <= 0 {
e.state.CameraZoom = 1.0
}
if e.state.MapSplitterOffset <= 0 {
e.state.MapSplitterOffset = 0.5
}
e.wp = &world.RenderParams{
Options: &world.RenderOptions{DisableWrapScroll: false},
CameraZoom: e.state.CameraZoom,
CameraXWorldFp: e.state.CameraXFp,
CameraYWorldFp: e.state.CameraYFp,
}
e.drawer = &world.GGDrawer{DC: nil} e.drawer = &world.GGDrawer{DC: nil}
@@ -127,40 +159,13 @@ func NewClient(s storage.UIStorage, conn connector.UIConnector, app fyne.App) (m
return e, nil return e, nil
} }
func (e *client) loadReport(t uint) {
e.conn.FetchReport("GAME_ID", t, func(r report.Report, err error) {
if err != nil {
e.handlerError(err)
} else {
e.setReport(r)
}
})
}
func (e *client) setReport(r report.Report) {
w := world.NewWorld(int(r.Width), int(r.Height))
for i := range r.LocalPlanet {
p := r.LocalPlanet[i]
w.AddCircle(p.X.F(), p.Y.F(), p.Size.F())
}
for i := range r.UnidentifiedPlanet {
p := r.UnidentifiedPlanet[i]
w.AddPoint(p.X.F(), p.Y.F())
}
e.loadWorld(w)
}
func (e *client) BuildUI(w fyne.Window) { func (e *client) BuildUI(w fyne.Window) {
mapCanvas := newInteractiveRaster(e, e.raster, e.onRasterWidgetLayout, e.onScrolled, e.onDragged, e.onDradEnd, e.onTapped) mapCanvasObject := newInteractiveRaster(e.raster, e.onRasterWidgetLayout, e.onScrolled, e.onDragged, e.onDradEnd, e.onTapped)
mapCanvas.SetMinSize(fyne.NewSize(640, 480))
toolbar := widget.NewToolbar( toolbar := widget.NewToolbar(
widget.NewToolbarAction( widget.NewToolbarAction(
theme.FolderIcon(), theme.FolderIcon(),
func() { func() { e.initReportAsync("GAME_ID", 0) }),
e.loadReport(0)
// e.loadWorld(mockWorld())
}),
widget.NewToolbarSeparator(), widget.NewToolbarSeparator(),
widget.NewToolbarAction( widget.NewToolbarAction(
theme.NavigateBackIcon(), theme.NavigateBackIcon(),
@@ -170,11 +175,24 @@ func (e *client) BuildUI(w fyne.Window) {
func() {}), func() {}),
) )
e.accInfo = widget.NewAccordionItem(lang.L("title.info"), container.NewStack())
e.accInfo.Open = e.state.AccordionInfoOpen
e.accCalc = widget.NewAccordionItem(lang.L("title.calculator"), e.calculator.CanvasObject)
e.accCalc.Open = e.state.AccordionCalcOpen
accordion := widget.NewAccordion()
accordion.MultiOpen = true
accordion.Append(e.accCalc)
accordion.Append(e.accInfo)
e.mapSplitter = container.NewHSplit(mapCanvasObject, container.NewHScroll(accordion))
e.mapSplitter.SetOffset(e.state.MapSplitterOffset)
tabs := container.NewAppTabs( tabs := container.NewAppTabs(
container.NewTabItemWithIcon( container.NewTabItemWithIcon(
"Map", lang.L("title.map"),
theme.GridIcon(), theme.GridIcon(),
mapCanvas), e.mapSplitter),
container.NewTabItemWithIcon( container.NewTabItemWithIcon(
"Calculator", "Calculator",
theme.ComputerIcon(), theme.ComputerIcon(),
@@ -182,9 +200,23 @@ func (e *client) BuildUI(w fyne.Window) {
), ),
) )
th := tabs.Theme()
icon := canvas.NewImageFromResource(th.Icon(theme.IconNameInfo))
statusLeft := widget.NewTextGridFromString("Status")
statusAd := widget.NewTextGridFromString("")
statusBar := container.NewBorder(
nil, // top
nil, // bottom
container.NewHBox(statusLeft, widget.NewSeparator()), // left
container.NewHBox(widget.NewSeparator(), icon), // right
statusAd, // center
)
content := container.NewBorder( content := container.NewBorder(
toolbar, // top toolbar, // top
nil, // bottom statusBar, // bottom
nil, // left nil, // left
nil, // right nil, // right
tabs, // center tabs, // center
@@ -192,6 +224,9 @@ func (e *client) BuildUI(w fyne.Window) {
w.CenterOnScreen() w.CenterOnScreen()
w.SetContent(content) w.SetContent(content)
s := statusBar.Size()
icon.SetMinSize(fyne.NewSize(s.Height, s.Height))
e.initLatestReport()
} }
func (e *client) loadWorld(w *world.World) { func (e *client) loadWorld(w *world.World) {
@@ -215,16 +250,18 @@ func (e *client) Run() error {
e.window.SetMaster() e.window.SetMaster()
e.window.Resize(fyne.NewSize(800, 600)) e.window.Resize(fyne.NewSize(800, 600))
e.window.CenterOnScreen() e.window.CenterOnScreen()
e.window.SetOnClosed(e.Shutdown)
e.window.ShowAndRun() e.window.ShowAndRun()
e.stopBackground()
return nil return nil
} }
func (e *client) Shutdown() { func (e *client) Shutdown() {
e.stopBackground() e.stopBackground()
e.ensureStatePersist()
e.window.Close() e.window.Close()
} }
// TODO: remove func?
func (e *client) Version() string { return version } func (e *client) Version() string { return version }
func (e *client) OnConnection(isGood bool) { func (e *client) OnConnection(isGood bool) {
+6 -2
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"galaxy/client"
"galaxy/client/appmeta" "galaxy/client/appmeta"
"galaxy/client/loader" "galaxy/client/loader"
"galaxy/connector/http" "galaxy/connector/http"
@@ -12,6 +13,7 @@ import (
"os/signal" "os/signal"
"fyne.io/fyne/v2/app" "fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/lang"
) )
func main() { func main() {
@@ -19,7 +21,7 @@ func main() {
defer func() { defer func() {
if err == nil { if err == nil {
if r := recover(); r != nil { if r := recover(); r != nil {
err = errors.Join(err, fmt.Errorf("app panics: %v", r)) err = errors.Join(err, fmt.Errorf("panic: %v", r))
} }
} }
if err != nil { if err != nil {
@@ -32,6 +34,9 @@ func main() {
defer cancel() defer cancel()
app := app.NewWithID(appmeta.AppID) app := app.NewWithID(appmeta.AppID)
if err = lang.AddTranslationsFS(client.Translations, "resource/lang"); err != nil {
return
}
s, err := fs.NewFS(app.Storage().RootURI().Path()) s, err := fs.NewFS(app.Storage().RootURI().Path())
if err != nil { if err != nil {
return return
@@ -44,6 +49,5 @@ func main() {
if err != nil { if err != nil {
return return
} }
err = l.Run(ctx) err = l.Run(ctx)
} }
+6 -2
View File
@@ -4,14 +4,15 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"galaxy/client/appmeta"
"galaxy/client" "galaxy/client"
"galaxy/client/appmeta"
"galaxy/connector/http" "galaxy/connector/http"
"galaxy/storage/fs" "galaxy/storage/fs"
"os" "os"
"os/signal" "os/signal"
"fyne.io/fyne/v2/app" "fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/lang"
) )
func main() { func main() {
@@ -19,7 +20,7 @@ func main() {
defer func() { defer func() {
if err == nil { if err == nil {
if r := recover(); r != nil { if r := recover(); r != nil {
err = errors.Join(err, fmt.Errorf("app panics: %v", r)) err = errors.Join(err, fmt.Errorf("panic: %v", r))
} }
} }
if err != nil { if err != nil {
@@ -31,6 +32,9 @@ func main() {
defer cancel() defer cancel()
app := app.NewWithID(appmeta.AppID) app := app.NewWithID(appmeta.AppID)
if err = lang.AddTranslationsFS(client.Translations, "resource/lang"); err != nil {
return
}
s, err := fs.NewFS(app.Storage().RootURI().Path()) s, err := fs.NewFS(app.Storage().RootURI().Path())
if err != nil { if err != nil {
return return
+6
View File
@@ -0,0 +1,6 @@
package client
import "embed"
//go:embed resource/lang
var Translations embed.FS
+61
View File
@@ -0,0 +1,61 @@
package client
import (
"galaxy/client/world"
"fyne.io/fyne/v2"
)
var m = func(v int) float64 { return float64(v) / float64(world.SCALE) }
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, &params, xPx, yPx)
if err != nil {
e.handlerError(err)
return
}
if len(hits) == 0 {
e.calculator.UnloadPlanet()
return
}
for i := range hits {
e.onHit(hits[i])
}
}
func (e *client) onHit(hit world.Hit) {
// 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)
switch hit.Kind {
case world.KindPoint:
case world.KindCircle:
e.onHitCircle(hit.ID)
case world.KindLine:
}
}
func (e *client) onHitCircle(id world.PrimitiveID) {
p, ok := e.reg.localPlanet(id)
if !ok {
return
}
e.calculator.LoadPlanet(p.Name, p.Number, p.FreeIndustry.F(), p.Material.F(), p.Resources.F())
e.calculator.Refresh()
}
+76
View File
@@ -0,0 +1,76 @@
package client
import (
"galaxy/client/world"
"galaxy/model/report"
)
const (
entityClassUnknown int = iota - 1
entityClassLocalPlanet
entityClassOthersPlanet
entityClassFreePlanet
entityClassUnidentifiedPlanet
)
type registry struct {
report *report.Report
localPlanetIndex map[world.PrimitiveID]int
unidentifiedPlanetIndex map[world.PrimitiveID]int
}
func newRegistry() *registry {
return &registry{
localPlanetIndex: make(map[world.PrimitiveID]int),
unidentifiedPlanetIndex: make(map[world.PrimitiveID]int),
}
}
func (r *registry) clear(report *report.Report) {
r.report = report
clear(r.localPlanetIndex)
clear(r.unidentifiedPlanetIndex)
}
func (r *registry) entityClass(id world.PrimitiveID) int {
if r.isLocalPlanet(id) {
return entityClassLocalPlanet
}
if r.isUnidentifiedPlanet(id) {
return entityClassUnidentifiedPlanet
}
return entityClassUnknown
}
func (r *registry) registerLocalPlanet(id world.PrimitiveID, index int) {
r.localPlanetIndex[id] = index
}
func (r *registry) isLocalPlanet(id world.PrimitiveID) bool {
_, ok := r.localPlanetIndex[id]
return ok
}
func (r *registry) localPlanet(id world.PrimitiveID) (*report.LocalPlanet, bool) {
i, ok := r.localPlanetIndex[id]
if !ok {
return nil, false
}
if i > len(r.report.LocalPlanet)-1 {
return nil, false
}
return &r.report.LocalPlanet[i], true
}
func (r *registry) registerUnidentifiedPlanet(id world.PrimitiveID, index int) {
r.unidentifiedPlanetIndex[id] = index
}
func (r *registry) isUnidentifiedPlanet(id world.PrimitiveID) bool {
_, ok := r.unidentifiedPlanetIndex[id]
return ok
}
func (c *client) createShipClass(n string, D float64, A uint, W float64, S float64, C float64) {
}
+119
View File
@@ -0,0 +1,119 @@
package client
import (
"fmt"
"galaxy/client/widget/calculator"
"galaxy/client/world"
mc "galaxy/model/client"
"galaxy/model/report"
"slices"
"fyne.io/fyne/v2"
)
func (e *client) initLatestReport() {
e.stateMu.Lock()
if e.state.ActiveGameID != nil {
if stateIdx := slices.IndexFunc(e.state.GameState, func(gs mc.GameState) bool { return gs.ID == *e.state.ActiveGameID }); stateIdx >= 0 {
e.initReportAsync(*e.state.ActiveGameID, e.state.GameState[stateIdx].ActiveTurn)
}
}
e.stateMu.Unlock()
}
func (e *client) initReportAsync(gid mc.GameID, t uint) {
e.s.ReportExistsAsync(gid, t, func(b bool, err error) { e.reportAtStorageExists(gid, t, b, err) })
}
func (e *client) reportAtStorageExists(gid mc.GameID, t uint, exists bool, err error) {
if err != nil {
e.handlerError(err)
return
}
if exists {
e.s.LoadReportAsync(gid, t, func(r report.Report, err error) { e.loadReportHandler(gid, r, err) })
return
}
e.conn.FetchReport(gid, t, func(r report.Report, err error) { e.fetchReportHandler(gid, r, err) })
}
func (e *client) fetchReportHandler(gid mc.GameID, r report.Report, err error) {
if err != nil {
e.handlerError(err)
return
}
e.s.SaveReportAsync(gid, r.Turn, r, func(err error) { e.loadReportHandler(gid, r, err) })
}
func (e *client) loadReportHandler(gid mc.GameID, r report.Report, err error) {
if err != nil {
e.handlerError(err)
return
}
e.stateMu.Lock()
needSaveState := false
stateIdx := slices.IndexFunc(e.state.GameState, func(gs mc.GameState) bool { return gs.ID == gid })
if stateIdx < 0 {
e.state.GameState = append(e.state.GameState, mc.GameState{ID: gid, LastTurn: r.Turn, ActiveTurn: r.Turn})
stateIdx = len(e.state.GameState) - 1
needSaveState = true
}
if e.state.ActiveGameID == nil {
e.state.ActiveGameID = new(gid)
needSaveState = true
}
if e.state.GameState[stateIdx].LastTurn < r.Turn {
e.state.GameState[stateIdx].LastTurn = r.Turn
e.state.GameState[stateIdx].ActiveTurn = r.Turn
needSaveState = true
}
if needSaveState {
if err := e.s.SaveState(*e.state); err != nil {
e.handlerError(err)
return
}
}
e.stateMu.Unlock()
e.setReport(r)
}
func (e *client) setReport(r report.Report) {
w := world.NewWorld(int(r.Width), int(r.Height))
e.reg.clear(&r)
for i := range r.LocalPlanet {
p := r.LocalPlanet[i]
id, err := w.AddCircle(p.X.F(), p.Y.F(), p.Size.F(), world.CircleWithClass(world.CircleClassLocalPlanet))
if err != nil {
e.handlerError(err)
return
}
e.reg.registerLocalPlanet(id, i)
}
for i := range r.UnidentifiedPlanet {
p := r.UnidentifiedPlanet[i]
id, err := w.AddPoint(p.X.F(), p.Y.F(), world.PointWithClass(world.PointClassTrackIncoming))
if err != nil {
e.handlerError(err)
return
}
e.reg.registerUnidentifiedPlanet(id, i)
}
e.loadWorld(w)
selfIdx := slices.IndexFunc(r.Player, func(p report.Player) bool { return p.Name == r.Race })
if selfIdx >= 0 {
fyne.Do(func() {
e.calculator.Init(
calculator.WithPlayerDrives(r.Player[selfIdx].Drive.F()),
calculator.WithPlayerWeapons(r.Player[selfIdx].Weapons.F()),
calculator.WithPlayerShields(r.Player[selfIdx].Shields.F()),
calculator.WithPlayerCargo(r.Player[selfIdx].Cargo.F()),
)
})
} else {
e.OnServiceError(fmt.Errorf("race %q not found at report players list", r.Race))
}
}
+30
View File
@@ -0,0 +1,30 @@
{
"title": {
"map": "Map",
"calculator": "Ship Calculator",
"info": "Info"
},
"planet": {
"title": "Planet #{{.Number}} '{{.Name}}' production fot this ship:",
"mat": "Materials",
"prod.mass": "Prod. Mass",
"prod.ships": "Ships"
},
"tech": {
"d": "Drive",
"w": "Weapons",
"s": "Shields",
"c": "Cargo"
},
"ship": {
"action.create": "Create",
"mass": "Mass",
"speed": "Speed",
"attack": "Attack",
"defense": "Defense",
"load": "Load"
},
"label": {
"max": "Max."
}
}
-31
View File
@@ -1,7 +1,6 @@
package client package client
import ( import (
"fmt"
"image" "image"
"math" "math"
@@ -256,36 +255,6 @@ func (e *client) onDradEnd() {
e.pan.DragEnd() 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, &params, 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) { func (e *client) onScrolled(s *fyne.ScrollEvent) {
if e.world == nil || s == nil { if e.world == nil || s == nil {
return return
+629
View File
@@ -0,0 +1,629 @@
package calculator
import (
"errors"
"galaxy/calc"
"galaxy/client/widget/numeric"
"galaxy/util"
"slices"
"strconv"
"sync"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/lang"
"fyne.io/fyne/v2/widget"
)
type CalculatorOpt func(*Calculator)
type ShipClass struct {
Name string
Drive float64
Armament uint
Weapons float64
Shields float64
Cargo float64
}
type ShipClassFn func(string, float64, uint, float64, float64, float64)
type Calculator struct {
CanvasObject fyne.CanvasObject
playerDrivesTech float64
playerWeaponsTech float64
playerShieldsTech float64
playerCargoTech float64
shipDriveEntry *numeric.FloatEntry
shipWeaponsEntry *numeric.FloatEntry
shipArmamentEntry *numeric.IntEntry
shipShieldsEntry *numeric.FloatEntry
shipCargoEntry *numeric.FloatEntry
playerDrivesTechEntry *numeric.FloatEntry
playerWeaponsTechEntry *numeric.FloatEntry
playerShieldsTechEntry *numeric.FloatEntry
playerCargoTechEntry *numeric.FloatEntry
drivesTechOverride *widget.Check
weaponsTechOverride *widget.Check
shieldsTechOverride *widget.Check
cargoTechOverride *widget.Check
massEntry *numeric.FloatEntry
speedEntry *numeric.FloatEntry
attackEntry *numeric.FloatEntry
defenseEntry *numeric.FloatEntry
cargoLoadEntry *numeric.FloatEntry
planetMatEntry *numeric.FloatEntry
massOverride *widget.Check
speedOverride *widget.Check
attackOverride *widget.Check
defenseOverride *widget.Check
cargoLoadMaximize *widget.Check
planetMatOverride *widget.Check
planetLabel *widget.Label
planetMassProdLabel *widget.Label
planetShipsProdLabel *widget.Label
planetContainer fyne.CanvasObject
planetProdContainer fyne.CanvasObject
shipSelector *widget.SelectEntry
shipCreateButton *widget.Button
onCreateHandler ShipClassFn
loader ShipClassFn
knownClasses []ShipClass
validateMu sync.RWMutex
l, mat, res float64
Valid bool
}
func WithPlayerDrives(v float64) CalculatorOpt {
return func(c *Calculator) { c.playerDrivesTech = v }
}
func WithPlayerWeapons(v float64) CalculatorOpt {
return func(c *Calculator) { c.playerWeaponsTech = v }
}
func WithPlayerShields(v float64) CalculatorOpt {
return func(c *Calculator) { c.playerShieldsTech = v }
}
func WithPlayerCargo(v float64) CalculatorOpt {
return func(c *Calculator) { c.playerCargoTech = v }
}
func WithCreateHandler(f ShipClassFn) CalculatorOpt {
return func(c *Calculator) { c.onCreateHandler = f }
}
func NewCaclulator(opts ...CalculatorOpt) *Calculator {
c := &Calculator{}
c.shipCreateButton = widget.NewButton(lang.L("ship.action.create"), c.onCreateShipClassButton)
c.shipCreateButton.Disable()
c.loader = c.LoadShipClass
c.planetMatEntry = numeric.NewFloatEntry(10, c.onPlanetMatChange)
c.planetMatOverride = widget.NewCheck("", c.overridePlanetMat)
c.planetMatOverride.Disable()
c.planetLabel = widget.NewLabel("")
c.planetMassProdLabel = bareLabel("")
c.planetShipsProdLabel = bareLabel("")
c.planetProdContainer = container.NewHBox(
label(lang.L("planet.prod.mass")+":"),
fixedLabel(c.planetMassProdLabel, 80),
label(lang.L("planet.prod.ships")+":"),
fixedLabel(c.planetShipsProdLabel, 80),
)
c.planetProdContainer.Hide()
c.planetContainer = container.NewVBox(
widget.NewSeparator(),
container.NewHBox(c.planetLabel),
rowForItem(lang.L("planet.mat")+":", floatEntry(c.planetMatEntry, 100), c.planetMatOverride),
c.planetProdContainer,
)
c.planetContainer.Hide()
c.shipSelector = widget.NewSelectEntry(nil)
c.shipSelector.OnChanged = c.onShipSelectorChange
c.shipDriveEntry = numeric.NewFloatEntry(7, c.onShipDriveChange)
c.shipWeaponsEntry = numeric.NewFloatEntry(7, c.onShipWeaponsChange)
c.shipArmamentEntry = numeric.NewIntEntry(7, c.onShipArmamentChange)
c.shipShieldsEntry = numeric.NewFloatEntry(7, c.onShipShieldsChange)
c.shipCargoEntry = numeric.NewFloatEntry(7, c.onShipCargoChange)
c.playerDrivesTechEntry = numeric.NewFloatEntry(7, c.onDrivesTechChange)
c.playerWeaponsTechEntry = numeric.NewFloatEntry(7, c.onWeaponsTechChange)
c.playerShieldsTechEntry = numeric.NewFloatEntry(7, c.onShieldsTechChange)
c.playerCargoTechEntry = numeric.NewFloatEntry(7, c.onCargoTechChange)
c.massEntry = numeric.NewFloatEntry(7, c.onMassChange)
c.speedEntry = numeric.NewFloatEntry(7, c.onSpeedChange)
c.attackEntry = numeric.NewFloatEntry(7, c.onAttackChange)
c.defenseEntry = numeric.NewFloatEntry(7, c.onDefenseChange)
c.cargoLoadEntry = numeric.NewFloatEntry(7, c.onCargoLoadChange)
c.drivesTechOverride = widget.NewCheck("", c.overrideDrivesTech)
c.drivesTechOverride.Disable()
c.weaponsTechOverride = widget.NewCheck("", c.overrideWeaponsTech)
c.weaponsTechOverride.Disable()
c.shieldsTechOverride = widget.NewCheck("", c.overrideShieldsTech)
c.shieldsTechOverride.Disable()
c.cargoTechOverride = widget.NewCheck("", c.overrideCargoTech)
c.cargoTechOverride.Disable()
c.massOverride = widget.NewCheck("", c.overrideMass)
c.massOverride.Disable()
c.speedOverride = widget.NewCheck("", c.overrideSpeed)
c.speedOverride.Disable()
c.attackOverride = widget.NewCheck("", c.overrideAttack)
c.attackOverride.Disable()
c.defenseOverride = widget.NewCheck("", c.overrideDefense)
c.defenseOverride.Disable()
c.cargoLoadMaximize = widget.NewCheck(lang.L("label.max"), c.maximizeCargoLoad)
c.cargoLoadMaximize.SetChecked(true)
createShip := container.NewBorder(
nil, // top
nil, // bottom
nil, // left
c.shipCreateButton, // right
c.shipSelector, // center
)
c.CanvasObject = container.NewVBox(
container.NewPadded(createShip),
widget.NewSeparator(),
rowForTech(lang.L("tech.d")+":",
c.shipDriveEntry, floatEntry(c.playerDrivesTechEntry, 80), c.drivesTechOverride),
rowForWeapons(lang.L("tech.w")+":",
c.shipArmamentEntry, c.shipWeaponsEntry, floatEntry(c.playerWeaponsTechEntry, 80), c.weaponsTechOverride),
rowForTech(lang.L("tech.s")+":",
c.shipShieldsEntry, floatEntry(c.playerShieldsTechEntry, 80), c.shieldsTechOverride),
rowForTech(lang.L("tech.c")+":",
c.shipCargoEntry, floatEntry(c.playerCargoTechEntry, 80), c.cargoTechOverride),
widget.NewSeparator(),
rowForItem(lang.L("ship.load")+":",
floatEntry(c.cargoLoadEntry, 80), c.cargoLoadMaximize),
rowForItem(lang.L("ship.mass")+":",
floatEntry(c.massEntry, 80), c.massOverride),
rowForItem(lang.L("ship.speed")+":",
floatEntry(c.speedEntry, 80), c.speedOverride),
rowForItem(lang.L("ship.attack")+":",
floatEntry(c.attackEntry, 80), c.attackOverride),
rowForItem(lang.L("ship.defense")+":",
floatEntry(c.defenseEntry, 80), c.defenseOverride),
c.planetContainer,
)
c.Init(opts...)
return c
}
func (c *Calculator) Init(opts ...CalculatorOpt) {
for i := range opts {
opts[i](c)
}
c.playerDrivesTechEntry.SetOrigin(c.playerDrivesTech)
c.playerWeaponsTechEntry.SetOrigin(c.playerWeaponsTech)
c.playerShieldsTechEntry.SetOrigin(c.playerShieldsTech)
c.playerCargoTechEntry.SetOrigin(c.playerCargoTech)
c.CanvasObject.Show()
}
func (c *Calculator) Refresh() {
c.validate()
c.CanvasObject.Refresh()
}
func (c *Calculator) RegisterClasses(shipClass ...ShipClass) {
c.knownClasses = shipClass
names := make([]string, len(c.knownClasses))
for i := range c.knownClasses {
names[i] = c.knownClasses[i].Name
}
slices.Sort(names)
c.shipSelector = widget.NewSelectEntry(names)
c.shipSelector.OnChanged = c.onShipSelectorChange
}
func (c *Calculator) onCreateShipClassButton() {
if c.onCreateHandler == nil || !c.Valid {
return
}
}
func (c *Calculator) validate() {
fyne.Do(func() {
c.validateMu.Lock()
err := c.validateEntries()
c.Valid = err == nil
if err != nil {
} else {
}
c.shipClassNameValidate()
c.validateMu.Unlock()
})
}
func (c *Calculator) validateEntries() (err error) {
defer func() {
if err != nil {
c.cargoLoadEntry.Clear()
if !c.massOverride.Checked {
c.massEntry.Clear()
}
if !c.speedOverride.Checked {
c.speedEntry.Clear()
}
if !c.attackOverride.Checked {
c.attackEntry.Clear()
}
if !c.defenseOverride.Checked {
c.defenseEntry.Clear()
}
// c.planetProdContainer.Hide()
}
}()
drive, ok := c.shipDriveEntry.Value()
if !ok {
err = errors.New("Parameter Drive is not valid")
return
}
driveTech, ok := c.playerDrivesTechEntry.Value()
if !ok {
err = errors.New("Drive tech level is not valid")
return
}
armament, ok := c.shipArmamentEntry.Value()
if !ok {
err = errors.New("Parameter Armament is not valid")
return
}
weapons, ok := c.shipWeaponsEntry.Value()
if !ok {
err = errors.New("Parameter Weapons is not valid")
return
}
weaponsTech, ok := c.playerWeaponsTechEntry.Value()
if !ok {
err = errors.New("Weapons tech level is not valid")
return
}
shields, ok := c.shipShieldsEntry.Value()
if !ok {
err = errors.New("Parameter Shields is not valid")
return
}
shieldsTech, ok := c.playerShieldsTechEntry.Value()
if !ok {
err = errors.New("Shields tech level is not valid")
return
}
cargo, ok := c.shipCargoEntry.Value()
if !ok {
err = errors.New("Parameter Cargo is not valid")
return
}
cargoTech, ok := c.playerCargoTechEntry.Value()
if !ok {
err = errors.New("Cargo tech level is not valid")
return
}
err = calc.ValidateShipTypeValues(drive, armament, weapons, shields, cargo)
if err != nil {
return
}
var cargoLoad float64
if c.cargoLoadMaximize.Checked {
cargoLoad = calc.CargoCapacity(cargo, cargoTech)
c.cargoLoadEntry.SetOrigin(cargoLoad)
} else if cargoLoad, ok = c.cargoLoadEntry.Value(); !ok {
err = errors.New("Cargo load value is not valid")
return
}
emptyMass, ok := calc.EmptyMass(drive, weapons, uint(armament), shields, cargo)
if !ok {
err = errors.New("Unable to calculate empty mass (check armament and weapons)")
return
}
fullMass := calc.FullMass(emptyMass, cargoLoad)
speed := calc.Speed(calc.DriveEffective(drive, driveTech), fullMass)
effectiveAttack := calc.EffectiveAttack(weapons, weaponsTech)
effectiveDefense := calc.EffectiveDefence(shields, shieldsTech, fullMass)
c.massEntry.SetOrigin(emptyMass)
c.speedEntry.SetOrigin(speed)
c.attackEntry.SetOrigin(effectiveAttack)
c.defenseEntry.SetOrigin(effectiveDefense)
planetMat, ok := c.planetMatEntry.Value()
if !ok {
// c.planetProdContainer.Hide()
} else {
massProd := calc.PlanetProduceShipMass(c.l, planetMat, c.res)
c.planetMassProdLabel.SetText(strconv.FormatFloat(util.Fixed3(massProd), 'f', -1, 64))
ships := 0.
if emptyMass > 0 {
ships = massProd / emptyMass
}
c.planetShipsProdLabel.SetText(strconv.FormatFloat(util.Fixed3(ships), 'f', -1, 64))
c.planetProdContainer.Show()
}
return
}
func (c *Calculator) onOriginInputChange(cb *widget.Check, e *numeric.FloatEntry) {
if e == nil {
return
}
if cb != nil {
cb.Checked = e.Overriden()
if !cb.Checked {
cb.Disable()
} else {
cb.Enable()
}
}
c.onFloatEntryChange(e)
}
func (c *Calculator) onFloatEntryChange(e *numeric.FloatEntry) {
if e == nil {
return
}
e.Validate()
c.validate()
}
func (c *Calculator) onIntEntryChange(e *numeric.IntEntry) {
if e == nil {
return
}
e.Validate()
c.validate()
}
func (c *Calculator) overrideChecked(cb *widget.Check, e *numeric.FloatEntry) {
if cb == nil || e == nil {
return
}
if !cb.Checked {
e.Reset()
cb.Disable()
}
}
func (c *Calculator) onShipDriveChange(string) {
c.onFloatEntryChange(c.shipDriveEntry)
}
func (c *Calculator) onShipArmamentChange(string) {
defer c.onIntEntryChange(c.shipArmamentEntry)
if weapons, ok := c.shipWeaponsEntry.Value(); !ok || !c.shipWeaponsEntry.Valid {
return
} else if armament, ok := c.shipArmamentEntry.Value(); !ok || !c.shipArmamentEntry.Valid {
return
} else if armament > 0 && weapons == 0 {
c.shipWeaponsEntry.SetOrigin(1.0)
} else if armament == 0 && weapons > 0 {
c.shipWeaponsEntry.SetOrigin(0.0)
}
}
func (c *Calculator) onShipWeaponsChange(string) {
defer c.onFloatEntryChange(c.shipWeaponsEntry)
if weapons, ok := c.shipWeaponsEntry.Value(); !ok || !c.shipWeaponsEntry.Valid {
return
} else if armament, ok := c.shipArmamentEntry.Value(); !ok || !c.shipArmamentEntry.Valid {
return
} else if weapons > 0 && armament == 0 {
c.shipArmamentEntry.SetOrigin(1)
} else if weapons == 0 && armament > 0 {
c.shipArmamentEntry.SetOrigin(0)
}
}
func (c *Calculator) onShipShieldsChange(string) {
c.onFloatEntryChange(c.shipShieldsEntry)
}
func (c *Calculator) onShipCargoChange(string) {
c.onFloatEntryChange(c.shipCargoEntry)
}
func (c *Calculator) onDrivesTechChange(string) {
c.onOriginInputChange(c.drivesTechOverride, c.playerDrivesTechEntry)
}
func (c *Calculator) overrideDrivesTech(bool) {
c.overrideChecked(c.drivesTechOverride, c.playerDrivesTechEntry)
}
func (c *Calculator) onWeaponsTechChange(string) {
c.onOriginInputChange(c.weaponsTechOverride, c.playerWeaponsTechEntry)
}
func (c *Calculator) overrideWeaponsTech(bool) {
c.overrideChecked(c.weaponsTechOverride, c.playerWeaponsTechEntry)
}
func (c *Calculator) onShieldsTechChange(string) {
c.onOriginInputChange(c.shieldsTechOverride, c.playerShieldsTechEntry)
}
func (c *Calculator) overrideShieldsTech(bool) {
c.overrideChecked(c.shieldsTechOverride, c.playerShieldsTechEntry)
}
func (c *Calculator) onCargoTechChange(string) {
c.onOriginInputChange(c.cargoTechOverride, c.playerCargoTechEntry)
}
func (c *Calculator) overrideCargoTech(bool) {
c.overrideChecked(c.cargoTechOverride, c.playerCargoTechEntry)
}
func (c *Calculator) onCargoLoadChange(string) {
c.onFloatEntryChange(c.cargoLoadEntry)
}
func (c *Calculator) onMassChange(string) {
c.onOriginInputChange(c.massOverride, c.massEntry)
}
func (c *Calculator) overrideMass(bool) {
c.overrideChecked(c.massOverride, c.massEntry)
}
func (c *Calculator) onSpeedChange(string) {
c.onOriginInputChange(c.speedOverride, c.speedEntry)
}
func (c *Calculator) overrideSpeed(bool) {
c.overrideChecked(c.speedOverride, c.speedEntry)
}
func (c *Calculator) onAttackChange(string) {
c.onOriginInputChange(c.attackOverride, c.attackEntry)
}
func (c *Calculator) overrideAttack(bool) {
c.overrideChecked(c.attackOverride, c.attackEntry)
}
func (c *Calculator) onDefenseChange(string) {
c.onOriginInputChange(c.defenseOverride, c.defenseEntry)
}
func (c *Calculator) overrideDefense(bool) {
c.overrideChecked(c.defenseOverride, c.defenseEntry)
}
func (c *Calculator) maximizeCargoLoad(bool) {
c.validate()
}
func (c *Calculator) onPlanetMatChange(string) {
c.onOriginInputChange(c.planetMatOverride, c.planetMatEntry)
}
func (c *Calculator) overridePlanetMat(bool) {
c.overrideChecked(c.planetMatOverride, c.planetMatEntry)
}
func (c *Calculator) onShipSelectorChange(v string) {
i, ok := c.shipClassNameValidate()
if i < 0 || !ok || c.loader == nil {
return
}
c.loader(
c.knownClasses[i].Name,
c.knownClasses[i].Drive,
c.knownClasses[i].Armament,
c.knownClasses[i].Weapons,
c.knownClasses[i].Shields,
c.knownClasses[i].Cargo,
)
}
func (c *Calculator) shipClassNameValidate() (int, bool) {
var canCreateShip bool
defer func() {
if canCreateShip && c.Valid {
c.shipCreateButton.Enable()
} else {
c.shipCreateButton.Disable()
}
}()
name, canCreateShip := util.ValidateTypeName(c.shipSelector.Text)
if canCreateShip {
c.shipSelector.Text = name
}
i := slices.IndexFunc(c.knownClasses, func(v ShipClass) bool { return v.Name == name })
canCreateShip = canCreateShip && i < 0
return i, canCreateShip
}
func (c *Calculator) LoadShipClass(n string, D float64, A uint, W float64, S float64, C float64) {
c.shipDriveEntry.SetOrigin(D)
c.shipArmamentEntry.SetOrigin(int(A))
c.shipWeaponsEntry.SetOrigin(W)
c.shipShieldsEntry.SetOrigin(S)
c.shipCargoEntry.SetOrigin(C)
}
func rowForItem(l string, entry, override fyne.CanvasObject) fyne.CanvasObject {
i := []fyne.CanvasObject{label(l), entry}
if override != nil {
i = append(i, override)
}
return container.NewHBox(i...)
}
func rowForTech(l string, shipEntry, techEntry, btn fyne.CanvasObject) fyne.CanvasObject {
return container.NewHBox(
label(l),
floatEntry(shipEntry, 115),
widget.NewLabel("@"),
techEntry,
btn,
)
}
func rowForWeapons(l string, armamentEntry, weaponsEntry, techEntry, btn fyne.CanvasObject) fyne.CanvasObject {
return container.NewHBox(
label(l),
intEntry(armamentEntry, 35),
floatEntry(weaponsEntry, 75),
widget.NewLabel("@"),
techEntry,
btn,
)
}
func label(l string) fyne.CanvasObject {
return fixedLabel(bareLabel(l), 110)
}
func fixedLabel(w *widget.Label, width float32) fyne.CanvasObject {
s := container.NewHScroll(w)
s.SetMinSize(fyne.NewSize(width, 1))
return s
}
func bareLabel(l string) *widget.Label {
w := widget.NewLabelWithStyle(l, fyne.TextAlignTrailing, fyne.TextStyle{Monospace: true, Symbol: false})
w.Selectable = false
w.Truncation = fyne.TextTruncateOff
return w
}
func intEntry(content fyne.CanvasObject, width float32) fyne.CanvasObject {
s := container.NewHScroll(content)
s.SetMinSize(fyne.NewSize(width, 1))
return s
}
func floatEntry(content fyne.CanvasObject, width float32) fyne.CanvasObject {
s := container.NewHScroll(content)
s.SetMinSize(fyne.NewSize(width, 1))
return s
}
+16
View File
@@ -0,0 +1,16 @@
package calculator
import (
"fyne.io/fyne/v2/lang"
)
func (c *Calculator) UnloadPlanet() {
c.planetContainer.Hide()
}
func (c *Calculator) LoadPlanet(name string, number uint, L, Mat, Res float64) {
c.l, c.mat, c.res = L, Mat, Res
c.planetLabel.SetText(lang.L("planet.title", map[string]any{"Number": number, "Name": name}))
c.planetMatEntry.SetOrigin(Mat)
c.planetContainer.Show()
}
+185 -10
View File
@@ -1,30 +1,82 @@
package numeric package numeric
import ( import (
"galaxy/client/widget/validator"
"galaxy/util"
"strconv" "strconv"
"strings"
"unicode/utf8"
"fyne.io/fyne/v2" "fyne.io/fyne/v2"
"fyne.io/fyne/v2/driver/mobile" "fyne.io/fyne/v2/driver/mobile"
"fyne.io/fyne/v2/widget" "fyne.io/fyne/v2/widget"
) )
type numericalEntry struct { type FloatEntry struct {
widget.Entry widget.Entry
origin float64
MaxValue float64
maxSize uint
validator fyne.StringValidator
Valid bool
} }
func NewNumericalEntry() *numericalEntry { type IntEntry struct {
entry := &numericalEntry{} widget.Entry
entry.ExtendBaseWidget(entry) origin uint
return entry MaxValue uint
maxSize uint
validator fyne.StringValidator
Valid bool
} }
func (e *numericalEntry) TypedRune(r rune) { func NewFloatEntry(maxSize uint, onChanged func(string)) *FloatEntry {
if (r >= '0' && r <= '9') || r == '.' || r == ',' { e := &FloatEntry{maxSize: maxSize, validator: validator.FloatEntryValidator}
e.ExtendBaseWidget(e)
e.Entry.Scroll = fyne.ScrollNone
e.Entry.TextStyle = fyne.TextStyle{Monospace: true}
// e.Validator = validator.FloatEntryValidator
// e.AlwaysShowValidationError = true
e.Entry.ActionItem = nil
e.SetOrigin(0)
e.Validate()
e.Entry.OnChanged = onChanged
return e
}
func NewIntEntry(maxSize uint, onChanged func(string)) *IntEntry {
e := &IntEntry{maxSize: maxSize, validator: validator.IntEntryValidator}
e.ExtendBaseWidget(e)
e.Entry.Scroll = fyne.ScrollNone
e.Entry.TextStyle = fyne.TextStyle{Monospace: true}
// e.Validator = validator.IntEntryValidator
// e.AlwaysShowValidationError = true
e.Entry.ActionItem = nil
e.SetOrigin(0)
e.Validate()
e.Entry.OnChanged = onChanged
return e
}
func (e *FloatEntry) CreateRenderer() fyne.WidgetRenderer {
r := e.Entry.CreateRenderer()
return r
}
func (e *FloatEntry) TypedRune(r rune) {
if !((r >= '0' && r <= '9') || r == '.') {
return
}
if !lengthBelowLimit(e.Entry.Text, e.maxSize) && e.Entry.SelectedText() == "" {
return
}
if r == '.' && strings.Contains(e.Entry.Text, ".") {
return
}
e.Entry.TypedRune(r) e.Entry.TypedRune(r)
} }
}
func (e *numericalEntry) TypedShortcut(shortcut fyne.Shortcut) { func (e *FloatEntry) TypedShortcut(shortcut fyne.Shortcut) {
paste, ok := shortcut.(*fyne.ShortcutPaste) paste, ok := shortcut.(*fyne.ShortcutPaste)
if !ok { if !ok {
e.Entry.TypedShortcut(shortcut) e.Entry.TypedShortcut(shortcut)
@@ -37,6 +89,129 @@ func (e *numericalEntry) TypedShortcut(shortcut fyne.Shortcut) {
} }
} }
func (e *numericalEntry) Keyboard() mobile.KeyboardType { func (e *FloatEntry) Keyboard() mobile.KeyboardType {
return mobile.NumberKeyboard return mobile.NumberKeyboard
} }
func (e *FloatEntry) SetOrigin(v float64) {
if v < 0 {
return
}
e.origin = v
e.Reset()
}
func (e *FloatEntry) Reset() {
e.SetValue(e.origin)
}
func (e *FloatEntry) Clear() {
onChanged := e.Entry.OnChanged
e.Entry.OnChanged = nil
e.Entry.SetText("")
e.Entry.OnChanged = onChanged
}
func (e *FloatEntry) SetValue(v float64) {
if v < 0 {
return
}
e.Entry.SetText(strconv.FormatFloat(util.Fixed3(v), 'f', -1, 64))
}
func (e *FloatEntry) Value() (float64, bool) {
if v, err := validator.ParseFloat(e.Entry.Text); err != nil {
return 0, false
} else {
return v, true
}
}
func (e *FloatEntry) Overriden() bool {
if v, ok := e.Value(); !ok {
return false
} else {
return util.Fixed3(v) != util.Fixed3(e.origin)
}
}
func (e *FloatEntry) Validate() {
if e.validator == nil {
return
}
err := e.validator(e.Entry.Text)
e.Valid = err == nil
}
func (e *IntEntry) TypedRune(r rune) {
if r >= '0' && r <= '9' {
if lengthBelowLimit(e.Entry.Text, e.maxSize) || e.Entry.SelectedText() != "" {
e.Entry.TypedRune(r)
}
}
}
func (e *IntEntry) TypedShortcut(shortcut fyne.Shortcut) {
paste, ok := shortcut.(*fyne.ShortcutPaste)
if !ok {
e.Entry.TypedShortcut(shortcut)
return
}
content := paste.Clipboard.Content()
if _, err := strconv.ParseInt(content, 10, 64); err == nil {
e.Entry.TypedShortcut(shortcut)
}
}
func (e *IntEntry) Keyboard() mobile.KeyboardType {
return mobile.NumberKeyboard
}
func (e *IntEntry) SetOrigin(v int) {
if v < 0 {
return
}
e.origin = uint(v)
e.Reset()
}
func (e *IntEntry) Reset() {
e.SetValue(int(e.origin))
}
func (e *IntEntry) SetValue(v int) {
if v < 0 {
return
}
e.Entry.SetText(strconv.Itoa(v))
}
func (e *IntEntry) Value() (int, bool) {
if v, err := validator.ParseInt(e.Entry.Text); err != nil {
return 0, false
} else {
return v, true
}
}
func (e *IntEntry) Overriden() bool {
if v, ok := e.Value(); !ok {
return false
} else {
return v != int(e.origin)
}
}
func (e *IntEntry) Validate() {
if e.validator == nil {
return
}
err := e.validator(e.Entry.Text)
e.Valid = err == nil
}
func lengthBelowLimit(s string, max uint) bool {
return utf8.RuneCountInString(s) < int(max)
}
+68
View File
@@ -2,11 +2,26 @@ package validator
import ( import (
"errors" "errors"
"fmt"
"strconv" "strconv"
"fyne.io/fyne/v2" "fyne.io/fyne/v2"
) )
type floatValidator func(float64) error
var (
FloatEntryValidator = numericEntryValidator(
nonNegativeValidator,
minOrZeroValueValidator(1.),
)
IntEntryValidator = numericEntryValidator(
intValidator,
nonNegativeValidator,
minOrZeroValueValidator(1.),
)
)
func NewStackValidator(first fyne.StringValidator, rest ...fyne.StringValidator) fyne.StringValidator { func NewStackValidator(first fyne.StringValidator, rest ...fyne.StringValidator) fyne.StringValidator {
if first == nil { if first == nil {
panic("first validator cannot be nil") panic("first validator cannot be nil")
@@ -43,6 +58,44 @@ func NewMutualValidator(other func() float64, valid func(float64) bool) fyne.Str
} }
} }
func numericEntryValidator(other ...floatValidator) fyne.StringValidator {
return func(s string) error {
v, err := ParseFloat(s)
if err != nil {
return errors.New("not a float value")
}
for i := range other {
if err := other[i](v); err != nil {
return err
}
}
return nil
}
}
func nonNegativeValidator(v float64) error {
if v < 0 {
return errors.New("value must be greater of equal to zero")
}
return nil
}
func intValidator(v float64) error {
if float64(int(v)) != v {
return errors.New("value must be an integer")
}
return nil
}
func minOrZeroValueValidator(min float64) floatValidator {
return func(f float64) error {
if f > 0 && f < min {
return fmt.Errorf("value must be zero or >= %f", min)
}
return nil
}
}
func FloatValueValidator(s string) error { func FloatValueValidator(s string) error {
if _, err := ParseFloat(s); err != nil { if _, err := ParseFloat(s); err != nil {
return errors.New("not a float value") return errors.New("not a float value")
@@ -50,6 +103,21 @@ func FloatValueValidator(s string) error {
return nil return nil
} }
func IntValueValidator(s string) error {
if _, err := ParseInt(s); err != nil {
return errors.New("not an integer value")
}
return nil
}
func ParseFloat(s string) (float64, error) { func ParseFloat(s string) (float64, error) {
return strconv.ParseFloat(s, 64) return strconv.ParseFloat(s, 64)
} }
func ParseInt(s string) (int, error) {
if v, err := strconv.ParseInt(s, 10, 64); err != nil {
return 0, err
} else {
return int(v), nil
}
}
+22 -24
View File
@@ -415,6 +415,13 @@ func (LightTheme) PointClassOverride(class PointClassID) (StyleOverride, bool) {
PointRadiusPx: new(3.5), PointRadiusPx: new(3.5),
}, true }, true
case PointClassUnidentifiedPlanet:
// soft orange
return StyleOverride{
FillColor: cRGBA(192, 192, 192, 255),
PointRadiusPx: new(2.5),
}, true
default: default:
return StyleOverride{}, false return StyleOverride{}, false
} }
@@ -457,15 +464,7 @@ func (LightTheme) CircleClassOverride(class CircleClassID) (StyleOverride, bool)
case CircleClassDefault: case CircleClassDefault:
return StyleOverride{}, false return StyleOverride{}, false
case CircleClassHome: case CircleClassLocalPlanet:
// teal-ish, a bit stronger stroke
return StyleOverride{
FillColor: cRGBA(32, 161, 145, 50),
StrokeColor: cRGBA(32, 161, 145, 210),
StrokeWidthPx: new(2.5),
}, true
case CircleClassAcquired:
// blue // blue
return StyleOverride{ return StyleOverride{
FillColor: cRGBA(70, 108, 196, 45), FillColor: cRGBA(70, 108, 196, 45),
@@ -473,7 +472,7 @@ func (LightTheme) CircleClassOverride(class CircleClassID) (StyleOverride, bool)
StrokeWidthPx: new(2.2), StrokeWidthPx: new(2.2),
}, true }, true
case CircleClassOccupied: case CircleClassOthersPlanet:
// orange // orange
return StyleOverride{ return StyleOverride{
FillColor: cRGBA(222, 142, 70, 50), FillColor: cRGBA(222, 142, 70, 50),
@@ -481,7 +480,7 @@ func (LightTheme) CircleClassOverride(class CircleClassID) (StyleOverride, bool)
StrokeWidthPx: new(2.2), StrokeWidthPx: new(2.2),
}, true }, true
case CircleClassFree: case CircleClassFreePlanet:
// green // green
return StyleOverride{ return StyleOverride{
FillColor: cRGBA(76, 171, 107, 45), FillColor: cRGBA(76, 171, 107, 45),
@@ -574,6 +573,12 @@ func (*DarkTheme) PointClassOverride(class PointClassID) (StyleOverride, bool) {
PointRadiusPx: new(3.5), PointRadiusPx: new(3.5),
}, true }, true
case PointClassUnidentifiedPlanet:
return StyleOverride{
FillColor: cRGBA(192, 192, 192, 255),
PointRadiusPx: new(2.5),
}, true
default: default:
return StyleOverride{}, false return StyleOverride{}, false
} }
@@ -615,30 +620,23 @@ func (*DarkTheme) CircleClassOverride(class CircleClassID) (StyleOverride, bool)
case CircleClassDefault: case CircleClassDefault:
return StyleOverride{}, false return StyleOverride{}, false
case CircleClassHome: case CircleClassLocalPlanet:
return StyleOverride{ return StyleOverride{
FillColor: nil, // cRGBA(120, 214, 198, 255), FillColor: cRGBA(155, 175, 235, 255),
StrokeColor: cRGBA(120, 214, 198, 255),
StrokeWidthPx: new(2.5),
}, true
case CircleClassAcquired:
return StyleOverride{
FillColor: nil, // cRGBA(155, 175, 235, 255),
StrokeColor: cRGBA(155, 175, 235, 255), StrokeColor: cRGBA(155, 175, 235, 255),
StrokeWidthPx: new(2.2), StrokeWidthPx: new(2.2),
}, true }, true
case CircleClassOccupied: case CircleClassOthersPlanet:
return StyleOverride{ return StyleOverride{
FillColor: nil, // cRGBA(245, 178, 120, 255), FillColor: cRGBA(245, 178, 120, 255),
StrokeColor: cRGBA(245, 178, 120, 255), StrokeColor: cRGBA(245, 178, 120, 255),
StrokeWidthPx: new(2.2), StrokeWidthPx: new(2.2),
}, true }, true
case CircleClassFree: case CircleClassFreePlanet:
return StyleOverride{ return StyleOverride{
FillColor: nil, // cRGBA(132, 219, 162, 255), FillColor: cRGBA(132, 219, 162, 255),
StrokeColor: cRGBA(132, 219, 162, 255), StrokeColor: cRGBA(132, 219, 162, 255),
StrokeWidthPx: new(2.2), StrokeWidthPx: new(2.2),
}, true }, true
+8 -8
View File
@@ -688,6 +688,8 @@ const (
PointClassTrackIncoming PointClassTrackIncoming
// PointClassTrackOutgoing marks a point as an outgoing track marker. // PointClassTrackOutgoing marks a point as an outgoing track marker.
PointClassTrackOutgoing PointClassTrackOutgoing
// PointClassUnidentifiedPlanet marks an unidentified planet without visivle size.
PointClassUnidentifiedPlanet
) )
// LineClassID classifies Line primitives for theme-level style overrides. // LineClassID classifies Line primitives for theme-level style overrides.
@@ -711,14 +713,12 @@ type CircleClassID uint8
const ( const (
// CircleClassDefault selects the theme's default circle styling. // CircleClassDefault selects the theme's default circle styling.
CircleClassDefault CircleClassID = iota CircleClassDefault CircleClassID = iota
// CircleClassHome marks a circle as a home-world area. // CircleClassLocalPlanet marks a circle as a player-owned planet.
CircleClassHome CircleClassLocalPlanet
// CircleClassAcquired marks a circle as an acquired world area. // CircleClassOthersPlanet marks a circle as an occupied planet.
CircleClassAcquired CircleClassOthersPlanet
// CircleClassOccupied marks a circle as an occupied world area. // CircleClassFreePlanet marks a circle as a free planet.
CircleClassOccupied CircleClassFreePlanet
// CircleClassFree marks a circle as a free world area.
CircleClassFree
) )
// PrimitiveID is a compact stable identifier for primitives stored in the World. // PrimitiveID is a compact stable identifier for primitives stored in the World.
+2 -12
View File
@@ -3,10 +3,10 @@ package controller
import ( import (
"iter" "iter"
"maps" "maps"
"math"
"math/rand/v2" "math/rand/v2"
"slices" "slices"
"galaxy/calc"
"galaxy/game/internal/model/game" "galaxy/game/internal/model/game"
"github.com/google/uuid" "github.com/google/uuid"
@@ -65,7 +65,7 @@ func FilterBattleOpponents(c *Cache, attIdx, defIdx int, cacheProbability map[in
return true return true
} }
p := DestructionProbability( p := calc.DestructionProbability(
c.ShipGroupShipClass(attIdx).Weapons.F(), c.ShipGroupShipClass(attIdx).Weapons.F(),
c.ShipGroup(attIdx).TechLevel(game.TechWeapons).F(), c.ShipGroup(attIdx).TechLevel(game.TechWeapons).F(),
c.ShipGroupShipClass(defIdx).Shields.F(), c.ShipGroupShipClass(defIdx).Shields.F(),
@@ -216,16 +216,6 @@ func SingleBattle(c *Cache, b *Battle) {
} }
} }
func DestructionProbability(attWeapons, attWeaponsTech, defShields, defShiledsTech, defFullMass float64) float64 {
effAttack := attWeapons * attWeaponsTech
effDefence := EffectiveDefence(defShields, defShiledsTech, defFullMass)
return (math.Log10(effAttack/effDefence)/math.Log10(4) + 1) / 2
}
func EffectiveDefence(defShields, defShiledsTech, defFullMass float64) float64 {
return defShields * defShiledsTech / math.Pow(defFullMass, 1./3.) * math.Pow(30., 1./3.)
}
func randomValue(v iter.Seq[int]) int { func randomValue(v iter.Seq[int]) int {
ids := slices.Collect(v) ids := slices.Collect(v)
return ids[rand.IntN(len(ids))] return ids[rand.IntN(len(ids))]
+8 -7
View File
@@ -5,6 +5,7 @@ import (
"slices" "slices"
"testing" "testing"
"galaxy/calc"
"galaxy/game/internal/controller" "galaxy/game/internal/controller"
"galaxy/game/internal/model/game" "galaxy/game/internal/model/game"
@@ -39,25 +40,25 @@ var (
) )
func TestDestructionProbability(t *testing.T) { func TestDestructionProbability(t *testing.T) {
probability := controller.DestructionProbability(ship.Weapons.F(), 1, ship.Shields.F(), 1, ship.EmptyMass()) probability := calc.DestructionProbability(ship.Weapons.F(), 1, ship.Shields.F(), 1, ship.EmptyMass())
assert.Equal(t, .5, probability) assert.Equal(t, .5, probability)
undefeatedShip := ship undefeatedShip := ship
undefeatedShip.Shields = 55 undefeatedShip.Shields = 55
probability = controller.DestructionProbability(ship.Weapons.F(), 1, undefeatedShip.Shields.F(), 1, undefeatedShip.EmptyMass()) probability = calc.DestructionProbability(ship.Weapons.F(), 1, undefeatedShip.Shields.F(), 1, undefeatedShip.EmptyMass())
assert.LessOrEqual(t, probability, 0.) assert.LessOrEqual(t, probability, 0.)
disruptiveShip := ship disruptiveShip := ship
disruptiveShip.Weapons = 40 disruptiveShip.Weapons = 40
probability = controller.DestructionProbability(disruptiveShip.Weapons.F(), 1, ship.Shields.F(), 1, ship.EmptyMass()) probability = calc.DestructionProbability(disruptiveShip.Weapons.F(), 1, ship.Shields.F(), 1, ship.EmptyMass())
assert.GreaterOrEqual(t, probability, 1.) assert.GreaterOrEqual(t, probability, 1.)
} }
func TestEffectiveDefence(t *testing.T) { func TestEffectiveDefence(t *testing.T) {
assert.Equal(t, 10., controller.EffectiveDefence(ship.Shields.F(), 1, ship.EmptyMass())) assert.Equal(t, 10., calc.EffectiveDefence(ship.Shields.F(), 1, ship.EmptyMass()))
attackerEffectiveDefence := controller.EffectiveDefence(attacker.Shields.F(), 1, attacker.EmptyMass()) attackerEffectiveDefence := calc.EffectiveDefence(attacker.Shields.F(), 1, attacker.EmptyMass())
defenderEffectiveDefence := controller.EffectiveDefence(defender.Shields.F(), 1, defender.EmptyMass()) defenderEffectiveDefence := calc.EffectiveDefence(defender.Shields.F(), 1, defender.EmptyMass())
// attacker's effective shields must be 'just' 4 times greater than defender's // attacker's effective shields must be 'just' 4 times greater than defender's
assert.InDelta(t, defenderEffectiveDefence*4, attackerEffectiveDefence, 0) assert.InDelta(t, defenderEffectiveDefence*4, attackerEffectiveDefence, 0)
@@ -123,7 +124,7 @@ func TestFilterBattleOpponents(t *testing.T) {
assert.True(t, controller.FilterBattleOpponents(c, 2, 0, cacheProbability)) assert.True(t, controller.FilterBattleOpponents(c, 2, 0, cacheProbability))
assert.NoError(t, c.UpdateRelation(Race_0_idx, Race_1_idx, game.RelationWar)) assert.NoError(t, c.UpdateRelation(Race_0_idx, Race_1_idx, game.RelationWar))
assert.LessOrEqual(t, controller.DestructionProbability(Cruiser.Weapons.F(), 1, undefeatedShip.Shields.F(), 1, undefeatedShip.EmptyMass()), 0.) assert.LessOrEqual(t, calc.DestructionProbability(Cruiser.Weapons.F(), 1, undefeatedShip.Shields.F(), 1, undefeatedShip.EmptyMass()), 0.)
assert.True(t, controller.FilterBattleOpponents(c, 1, 3, cacheProbability)) assert.True(t, controller.FilterBattleOpponents(c, 1, 3, cacheProbability))
assert.NotContains(t, cacheProbability[1], 3) assert.NotContains(t, cacheProbability[1], 3)
} }
+2 -11
View File
@@ -5,6 +5,7 @@ import (
"iter" "iter"
"slices" "slices"
"galaxy/calc"
"galaxy/util" "galaxy/util"
e "galaxy/error" e "galaxy/error"
@@ -272,7 +273,7 @@ func ProduceShip(p *game.Planet, productionAvailable, shipMass float64) uint {
} }
ships := uint(0) ships := uint(0)
pa := productionAvailable pa := productionAvailable
PRODcost := ShipProductionCost(shipMass) PRODcost := calc.ShipProductionCost(shipMass)
var MATneed, MATfarm, totalCost float64 var MATneed, MATfarm, totalCost float64
for { for {
MATneed = shipMass - float64(p.Material) MATneed = shipMass - float64(p.Material)
@@ -281,8 +282,6 @@ func ProduceShip(p *game.Planet, productionAvailable, shipMass float64) uint {
} }
MATfarm = MATneed / float64(p.Resources) MATfarm = MATneed / float64(p.Resources)
totalCost = PRODcost + MATfarm totalCost = PRODcost + MATfarm
// fmt.Printf("PRODcost: %3.03f MATcost: %3.03f MAThave: %3.03f MATneed: %3.03f MATfarm: %3.03f total: %3.03f \n",
// PRODcost, shipMass, float64(p.Material), MATneed, MATfarm, totalCost)
if pa < totalCost { if pa < totalCost {
progress := pa / totalCost progress := pa / totalCost
pval := game.F(progress) pval := game.F(progress)
@@ -292,7 +291,6 @@ func ProduceShip(p *game.Planet, productionAvailable, shipMass float64) uint {
p.Production.Progress = &pval p.Production.Progress = &pval
fval := game.F(pa) fval := game.F(pa)
p.Production.ProdUsed = &fval p.Production.ProdUsed = &fval
// fmt.Println("pa", pa, "progress", progress, "MAT:", progress*shipMass)
return ships return ships
} else { } else {
pa -= totalCost pa -= totalCost
@@ -301,10 +299,3 @@ func ProduceShip(p *game.Planet, productionAvailable, shipMass float64) uint {
} }
} }
} }
func ShipProductionCost(shipMass float64) float64 {
return shipMass * 10.
}
func ShipMaterialCost(shipMass, planetResource float64) float64 {
return shipMass / planetResource
}
+2 -1
View File
@@ -6,6 +6,7 @@ import (
"iter" "iter"
"slices" "slices"
"galaxy/calc"
mr "galaxy/model/report" mr "galaxy/model/report"
"galaxy/util" "galaxy/util"
@@ -540,7 +541,7 @@ func (c *Cache) ReportShipProduction(ri int, rep *mr.Report) {
sliceIndexValidate(&rep.ShipProduction, i) sliceIndexValidate(&rep.ShipProduction, i)
rep.ShipProduction[pi].Planet = p.Number rep.ShipProduction[pi].Planet = p.Number
rep.ShipProduction[pi].Class = st.Name rep.ShipProduction[pi].Class = st.Name
rep.ShipProduction[pi].Cost = mr.F(ShipProductionCost(st.EmptyMass())) rep.ShipProduction[pi].Cost = mr.F(calc.ShipProductionCost(st.EmptyMass()))
rep.ShipProduction[pi].Free = mr.F(c.PlanetProductionCapacity(p.Number)) rep.ShipProduction[pi].Free = mr.F(c.PlanetProductionCapacity(p.Number))
rep.ShipProduction[pi].ProdUsed = mr.F((*p.Production.ProdUsed).F()) rep.ShipProduction[pi].ProdUsed = mr.F((*p.Production.ProdUsed).F())
rep.ShipProduction[pi].Percent = mr.F((*p.Production.Progress).F()) rep.ShipProduction[pi].Percent = mr.F((*p.Production.Progress).F())
+2 -30
View File
@@ -5,6 +5,7 @@ import (
"iter" "iter"
"slices" "slices"
"galaxy/calc"
"galaxy/util" "galaxy/util"
e "galaxy/error" e "galaxy/error"
@@ -16,7 +17,7 @@ import (
func (c *Cache) ShipClassCreate(ri int, typeName string, drive float64, ammo int, weapons, shileds, cargo float64) error { func (c *Cache) ShipClassCreate(ri int, typeName string, drive float64, ammo int, weapons, shileds, cargo float64) error {
c.validateRaceIndex(ri) c.validateRaceIndex(ri)
if err := validateShipTypeValues(drive, ammo, weapons, shileds, cargo); err != nil { if err := calc.ValidateShipTypeValues(drive, ammo, weapons, shileds, cargo); err != nil {
return err return err
} }
n, ok := util.ValidateTypeName(typeName) n, ok := util.ValidateTypeName(typeName)
@@ -159,32 +160,3 @@ func (c *Cache) MustShipType(ri int, ID uuid.UUID) *game.ShipType {
} }
panic(fmt.Sprintf("ship class not found: race_idx=%d id=%v", ri, ID)) panic(fmt.Sprintf("ship class not found: race_idx=%d id=%v", ri, ID))
} }
func validateShipTypeValues(d float64, a int, w, s, c float64) error {
if !checkShipTypeValueDWSC(d) {
return e.NewDriveValueError(d)
}
if !checkShipTypeValueDWSC(w) {
return e.NewWeaponsValueError(w)
}
if !checkShipTypeValueDWSC(s) {
return e.NewShieldsValueError(s)
}
if !checkShipTypeValueDWSC(c) {
return e.NewCargoValueError(s)
}
if a < 0 {
return e.NewShipTypeArmamentValueError(a)
}
if (w == 0 && a > 0) || (a == 0 && w > 0) {
return e.NewShipTypeArmamentAndWeaponsValueError("A=%d W=%.0f", a, w)
}
if d == 0 && w == 0 && s == 0 && c == 0 && a == 0 {
return e.NewShipTypeShipTypeZeroValuesError()
}
return nil
}
func checkShipTypeValueDWSC(v float64) bool {
return v == 0 || v >= 1
}
+7 -17
View File
@@ -2,6 +2,7 @@ package game
import ( import (
"fmt" "fmt"
"galaxy/calc"
"math" "math"
"strings" "strings"
@@ -179,18 +180,13 @@ func (sg ShipGroup) Equal(other ShipGroup) bool {
sg.State() == other.State() sg.State() == other.State()
} }
// Грузоподъёмность // Грузоподъёмность группы
func (sg ShipGroup) CargoCapacity(st *ShipType) float64 { func (sg ShipGroup) CargoCapacity(st *ShipType) float64 {
return sg.TechLevel(TechCargo).F() * (st.Cargo.F() + (st.Cargo.F()*st.Cargo.F())/20) * float64(sg.Number) return calc.CargoCapacity(st.Cargo.F(), sg.TechLevel(TechCargo).F()) * float64(sg.Number)
} }
// Масса перевозимого груза -
// общее количество единиц груза, деленное на технологический уровень Грузоперевозок
func (sg ShipGroup) CarryingMass() float64 { func (sg ShipGroup) CarryingMass() float64 {
if sg.Load.F() == 0 { return calc.CarryingMass(sg.Load.F(), sg.TechLevel(TechCargo).F())
return 0
}
return sg.Load.F() / sg.TechLevel(TechCargo).F()
} }
// Масса группы без учёта груза // Масса группы без учёта груза
@@ -198,22 +194,16 @@ func (sg ShipGroup) EmptyMass(st *ShipType) float64 {
return st.EmptyMass() * float64(sg.Number) return st.EmptyMass() * float64(sg.Number)
} }
// Полная масса -
// массу корабля самого по себе плюс масса перевозимого груза
func (sg ShipGroup) FullMass(st *ShipType) float64 { func (sg ShipGroup) FullMass(st *ShipType) float64 {
return sg.EmptyMass(st) + sg.CarryingMass() return calc.FullMass(sg.EmptyMass(st), sg.CarryingMass())
} }
// Эффективность двигателя -
// равна мощности Двигателей, умноженной на технологический уровень блока Двигателей
func (sg ShipGroup) DriveEffective(st *ShipType) float64 { func (sg ShipGroup) DriveEffective(st *ShipType) float64 {
return st.Drive.F() * sg.TechLevel(TechDrive).F() return calc.DriveEffective(st.Drive.F(), sg.TechLevel(TechDrive).F())
} }
// Корабли перемещаются за один ход на количество световых лет, равное
// эффективности двигателя, умноженной на 20 и деленной на "Полную массу" корабля
func (sg ShipGroup) Speed(st *ShipType) float64 { func (sg ShipGroup) Speed(st *ShipType) float64 {
return sg.DriveEffective(st) * 20 / sg.FullMass(st) return calc.Speed(sg.DriveEffective(st), sg.FullMass(st))
} }
// Мощность бомбардировки // Мощность бомбардировки
+10 -4
View File
@@ -2,6 +2,7 @@ package game
import ( import (
"fmt" "fmt"
"galaxy/calc"
"github.com/google/uuid" "github.com/google/uuid"
) )
@@ -44,10 +45,11 @@ func (st ShipType) DriveBlockMass() float64 {
} }
func (st ShipType) WeaponsBlockMass() float64 { func (st ShipType) WeaponsBlockMass() float64 {
if (st.Armament == 0 && st.Weapons != 0) || (st.Armament != 0 && st.Weapons == 0) { if v, ok := calc.WeaponsBlockMass(st.Weapons.F(), st.Armament); !ok {
panic(fmt.Sprintf("ship class invalid design: A=%d W=%.03f", st.Armament, st.Weapons)) panic(fmt.Sprintf("ship class invalid design: A=%d W=%.03f", st.Armament, st.Weapons))
} else {
return v
} }
return float64(st.Armament+1) * (st.Weapons.F() / 2)
} }
func (st ShipType) ShieldsBlockMass() float64 { func (st ShipType) ShieldsBlockMass() float64 {
@@ -59,6 +61,10 @@ func (st ShipType) CargoBlockMass() float64 {
} }
func (st ShipType) EmptyMass() float64 { func (st ShipType) EmptyMass() float64 {
shipMass := st.DriveBlockMass() + st.ShieldsBlockMass() + st.CargoBlockMass() + st.WeaponsBlockMass() if v, ok := calc.EmptyMass(st.Drive.F(), st.Weapons.F(), st.Armament, st.Shields.F(), st.Cargo.F()); !ok {
return shipMass panic(fmt.Sprintf("ship class invalid design: D=%.03f A=%d W=%.03f S=%.03f C=%.03f",
st.Drive, st.Armament, st.Weapons, st.Shields, st.Cargo))
} else {
return v
}
} }
+24
View File
@@ -0,0 +1,24 @@
{
"races": [
{"name": "Race_01"},
{"name": "Race_02"},
{"name": "Race_03"},
{"name": "Race_04"},
{"name": "Race_05"},
{"name": "Race_06"},
{"name": "Race_07"},
{"name": "Race_08"},
{"name": "Race_09"},
{"name": "Race_10"},
{"name": "Race_11"},
{"name": "Race_12"},
{"name": "Race_13"},
{"name": "Race_14"},
{"name": "Race_15"},
{"name": "Race_16"},
{"name": "Race_17"},
{"name": "Race_18"},
{"name": "Race_19"},
{"name": "Race_20"}
]
}
+6
View File
@@ -0,0 +1,6 @@
#/bin/bash
curl -X POST \
-H "Content-Type: application/json" \
--data @init.json \
http://127.0.0.1:8080/api/v1/init
+2
View File
@@ -3,6 +3,7 @@ go 1.26.0
use ( use (
./client ./client
./game ./game
./pkg/calc
./pkg/connector ./pkg/connector
./pkg/error ./pkg/error
./pkg/model ./pkg/model
@@ -12,6 +13,7 @@ use (
) )
replace ( replace (
galaxy/calc v0.0.0 => ./pkg/calc
galaxy/connector v0.0.0 => ./pkg/connector galaxy/connector v0.0.0 => ./pkg/connector
galaxy/error v0.0.0 => ./pkg/error galaxy/error v0.0.0 => ./pkg/error
galaxy/model v0.0.0 => ./pkg/model galaxy/model v0.0.0 => ./pkg/model
+3
View File
@@ -0,0 +1,3 @@
module galaxy/calc
go 1.26.0
+13
View File
@@ -0,0 +1,13 @@
package calc
func ShipProductionCost(shipEmptyMass float64) float64 {
return shipEmptyMass * 10.
}
func PlanetProduceShipMass(L, Mat, Res float64) float64 {
result := L / 10
if result <= Mat {
return result
}
return (L + Mat/Res) / (10 + 1/Res)
}
+85
View File
@@ -0,0 +1,85 @@
package calc
import "math"
// Эффективность двигателя -
// равна мощности Двигателей, умноженной на технологический уровень блока Двигателей
func DriveEffective(drive, driveTech float64) float64 {
return drive * driveTech
}
// Масса перевозимого груза -
// общее количество единиц груза, деленное на технологический уровень Грузоперевозок
func CarryingMass(load, cargoTech float64) float64 {
if load <= 0 {
return 0
}
return load / cargoTech
}
// Грузоподъёмность одного корабля
func CargoCapacity(cargo, cargoTech float64) float64 {
return cargoTech * (cargo + (cargo*cargo)/20)
}
// Корабли перемещаются за один ход на количество световых лет, равное
// эффективности двигателя, умноженной на 20 и деленной на "Полную массу" корабля
func Speed(driveEffective, fullMass float64) float64 {
if fullMass <= 0 {
return 0
}
return driveEffective * 20 / fullMass
}
// Полная масса -
// массу корабля самого по себе плюс масса перевозимого груза
func FullMass(emptyMass, carryingMass float64) float64 {
return emptyMass + carryingMass
}
func EmptyMass(drive, weapons float64, armament uint, shields, cargo float64) (float64, bool) {
wm, ok := WeaponsBlockMass(weapons, armament)
if !ok {
return 0, false
}
return drive + shields + cargo + wm, true
}
func WeaponsBlockMass(weapons float64, armament uint) (float64, bool) {
if (armament == 0 && weapons != 0) || (armament != 0 && weapons == 0) {
return 0, false
}
return float64(armament+1) * (weapons / 2), true
}
func DestructionProbability(
attackingWeapons,
attackingWeaponsTech,
defendingShields,
defendingShiledsTech,
defendingFullMass float64,
) float64 {
return DestructionProbabilityEffective(
EffectiveAttack(attackingWeapons, attackingWeaponsTech),
EffectiveDefence(defendingShields, defendingShiledsTech, defendingFullMass),
)
}
func DestructionProbabilityEffective(effectiveAttack, effectiveDefence float64) float64 {
return (math.Log10(effectiveAttack/effectiveDefence)/math.Log10(4) + 1) / 2
}
func EffectiveAttack(weapons, weaponsTech float64) float64 {
return weapons * weaponsTech
}
func EffectiveDefence(
defendingShields,
defendingShiledsTech,
defendingFullMass float64,
) float64 {
if defendingFullMass <= 0 {
return 0
}
return defendingShields * defendingShiledsTech / math.Pow(defendingFullMass, 1./3.) * math.Pow(30., 1./3.)
}
+34
View File
@@ -0,0 +1,34 @@
package calc
import (
e "galaxy/error"
)
func ValidateShipTypeValues(d float64, a int, w, s, c float64) error {
if !CheckShipTypeValueDWSC(d) {
return e.NewDriveValueError(d)
}
if !CheckShipTypeValueDWSC(w) {
return e.NewWeaponsValueError(w)
}
if !CheckShipTypeValueDWSC(s) {
return e.NewShieldsValueError(s)
}
if !CheckShipTypeValueDWSC(c) {
return e.NewCargoValueError(s)
}
if a < 0 {
return e.NewShipTypeArmamentValueError(a)
}
if (w == 0 && a > 0) || (a == 0 && w > 0) {
return e.NewShipTypeArmamentAndWeaponsValueError("A=%d W=%.0f", a, w)
}
if d == 0 && w == 0 && s == 0 && c == 0 && a == 0 {
return e.NewShipTypeShipTypeZeroValuesError()
}
return nil
}
func CheckShipTypeValueDWSC(v float64) bool {
return v == 0 || v >= 1
}
+7
View File
@@ -41,6 +41,13 @@ type State struct {
ClientNextVersion *string `json:"clientNextVersion,omitempty"` ClientNextVersion *string `json:"clientNextVersion,omitempty"`
GameState []GameState `json:"gameState,omitempty"` GameState []GameState `json:"gameState,omitempty"`
ActiveGameID *GameID `json:"activeGameId,omitempty"` ActiveGameID *GameID `json:"activeGameId,omitempty"`
CameraZoom float64 `json:"cameraZoom"`
CameraXFp int `json:"cameraXFp"`
CameraYFp int `json:"cameraYFp"`
MapSplitterOffset float64 `json:"mapSplitterOffset"`
AccordionInfoOpen bool `json:"accInfoOpen"`
AccordionCalcOpen bool `json:"accCalcOpen"`
} }
type GameState struct { type GameState struct {
+23
View File
@@ -128,6 +128,15 @@ func (s *fsStorage) SaveStateAsync(state client.State, callback func(error)) {
}() }()
} }
func (s *fsStorage) ReportExistsAsync(id client.GameID, turn uint, callback func(bool, error)) {
go func() {
exists, err := s.gameDataExistsSync(id, turn)
if callback != nil {
callback(exists, err)
}
}()
}
func (s *fsStorage) LoadReportAsync(id client.GameID, turn uint, callback func(report.Report, error)) { func (s *fsStorage) LoadReportAsync(id client.GameID, turn uint, callback func(report.Report, error)) {
go func() { go func() {
rep, err := s.loadReportSync(id, turn) rep, err := s.loadReportSync(id, turn)
@@ -343,6 +352,20 @@ func (s *fsStorage) saveOrderSync(id client.GameID, turn uint, o order.Order) er
})) }))
} }
func (s *fsStorage) gameDataExistsSync(id client.GameID, turn uint) (bool, error) {
absPath, err := s.resolvePath(gameTurnFilePath(id, turn))
if err != nil {
return false, classifyStorageError(err)
}
exists, err := s.fileExistsUnlocked(absPath)
if err != nil {
return false, classifyStorageError(err)
}
return exists, nil
}
func (s *fsStorage) loadGameDataSync(id client.GameID, turn uint) (client.GameData, error) { func (s *fsStorage) loadGameDataSync(id client.GameID, turn uint) (client.GameData, error) {
absPath, err := s.resolvePath(gameTurnFilePath(id, turn)) absPath, err := s.resolvePath(gameTurnFilePath(id, turn))
if err != nil { if err != nil {
+5
View File
@@ -36,6 +36,11 @@ type UIStorage interface {
// I/O or encoding error may occur, it that case callback func will be called with non-nil error. // I/O or encoding error may occur, it that case callback func will be called with non-nil error.
SaveStateAsync(client.State, func(error)) SaveStateAsync(client.State, func(error))
// ReportExistsAsync asynchronously checks whether given [model.GameID] and turn number exists in the Storage.
// Passed callback func will will accept non-nil error in case of I/O or decoding errors occuried,
// otherwise callback func accepts boolean result.
ReportExistsAsync(client.GameID, uint, func(bool, error))
// LoadReportAsync loads a [report.Report] for a given [model.GameID] and turn number from filesystem asynchronously. // LoadReportAsync 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, // 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]. // otherwise callback func accepts loaded [report.Report].