ui calculator
This commit is contained in:
@@ -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,
|
||||
immediately rejects not-authenticated requests and passes authenticated request to the next service.
|
||||
- The system exposes a single external entry point: **Edge Gateway**.
|
||||
- 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
|
||||
if user is authorized to use specific game api.
|
||||
## Main Components
|
||||
|
||||
- Games Orchestrator Service:
|
||||
- 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.
|
||||
### 1. Edge Gateway
|
||||
|
||||
- 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,
|
||||
- Enlist to a new Game from available onboard Games list,
|
||||
- Request list of Games in which Player participating,
|
||||
- Request, store and display particular Game data,
|
||||
- Use push-like mechanism for receiving asynchronous updates from Server,
|
||||
- Offline mode when no internet connection is available or user desired to work offline.
|
||||
The gateway must not implement domain-specific business logic.
|
||||
|
||||
### 2. Auth / Session Service
|
||||
|
||||
This service owns authentication and device session lifecycle.
|
||||
|
||||
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.
|
||||
|
||||
@@ -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
@@ -1,22 +1,10 @@
|
||||
# Client for Galaxy Plus
|
||||
|
||||
## Ship Calculator
|
||||
UI Client is capable of:
|
||||
|
||||
```text
|
||||
Class: [ ] { Create }
|
||||
Drives: [20.000] x 1.013 [O--]
|
||||
Weapons: [ 0.000] x [1.000] [==0]
|
||||
Armament: [ 0 ]
|
||||
Schields: [ 5.500] @ [1.123]
|
||||
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
|
||||
```
|
||||
- Register a new player and login for an existing player using only e-mail and one-time codes,
|
||||
- Enlist to a new Game from available onboard Games list,
|
||||
- Request list of Games in which Player participating,
|
||||
- Request, store and display particular Game data,
|
||||
- Use push-like mechanism for receiving asynchronous updates from Server,
|
||||
- Offline mode when no internet connection is available or user desired to work offline.
|
||||
|
||||
+50
-3
@@ -1,6 +1,7 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
gerr "galaxy/error"
|
||||
@@ -9,10 +10,11 @@ import (
|
||||
var (
|
||||
checkConnectionInterval = 5 * time.Second
|
||||
checkVersionInterval = time.Hour
|
||||
statePersistInterval = time.Second
|
||||
)
|
||||
|
||||
func (e *client) startBackground() {
|
||||
if e.fullConnector == nil && e.updater == nil {
|
||||
if e.conn == nil || e.updater == nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -28,9 +30,11 @@ func (e *client) stopBackground() {
|
||||
func (e *client) backgroundLoop() {
|
||||
checkConnTimer := time.NewTimer(checkConnectionInterval)
|
||||
checkVersionTimer := time.NewTimer(checkVersionInterval)
|
||||
persistStateTimer := time.NewTimer(statePersistInterval)
|
||||
defer func() {
|
||||
checkConnTimer.Stop()
|
||||
checkVersionTimer.Stop()
|
||||
persistStateTimer.Stop()
|
||||
}()
|
||||
|
||||
for {
|
||||
@@ -38,8 +42,8 @@ func (e *client) backgroundLoop() {
|
||||
case <-e.backgroundStop:
|
||||
return
|
||||
case <-checkConnTimer.C:
|
||||
if e.fullConnector != nil {
|
||||
e.OnConnection(e.fullConnector.CheckConnection())
|
||||
if e.conn != nil {
|
||||
e.OnConnection(e.conn.CheckConnection())
|
||||
}
|
||||
checkConnTimer.Reset(checkConnectionInterval)
|
||||
case <-checkVersionTimer.C:
|
||||
@@ -49,15 +53,58 @@ func (e *client) backgroundLoop() {
|
||||
}
|
||||
}
|
||||
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) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("ERROR: %s\n", err)
|
||||
|
||||
switch {
|
||||
case gerr.IsConnection(err):
|
||||
e.OnConnectionError(err)
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
type interactiveRaster struct {
|
||||
widget.BaseWidget
|
||||
|
||||
edit *client
|
||||
min fyne.Size
|
||||
raster *canvas.Raster
|
||||
onLayout func(fyne.Size)
|
||||
@@ -50,7 +49,6 @@ func (r *interactiveRaster) Tapped(ev *fyne.PointEvent) {
|
||||
func (r *interactiveRaster) TappedSecondary(*fyne.PointEvent) {}
|
||||
|
||||
func newInteractiveRaster(
|
||||
edit *client,
|
||||
raster *canvas.Raster,
|
||||
onLayout func(fyne.Size),
|
||||
onScrolled func(*fyne.ScrollEvent),
|
||||
@@ -60,7 +58,6 @@ func newInteractiveRaster(
|
||||
) *interactiveRaster {
|
||||
r := &interactiveRaster{
|
||||
raster: raster,
|
||||
edit: edit,
|
||||
onLayout: onLayout,
|
||||
onScrolled: onScrolled,
|
||||
onDragged: onDragged,
|
||||
|
||||
+92
-55
@@ -5,15 +5,16 @@ import (
|
||||
"sync"
|
||||
|
||||
"galaxy/client/updater"
|
||||
"galaxy/client/widget/calculator"
|
||||
"galaxy/client/world"
|
||||
"galaxy/connector"
|
||||
mc "galaxy/model/client"
|
||||
"galaxy/model/report"
|
||||
"galaxy/storage"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/canvas"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/lang"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
)
|
||||
@@ -21,12 +22,22 @@ import (
|
||||
const version = "1.0.0"
|
||||
|
||||
type client struct {
|
||||
s storage.UIStorage
|
||||
conn connector.UIConnector
|
||||
s storage.Storage
|
||||
conn connector.Connector
|
||||
app fyne.App
|
||||
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
|
||||
drawer *world.GGDrawer
|
||||
@@ -69,8 +80,6 @@ type client struct {
|
||||
|
||||
hits []world.Hit
|
||||
|
||||
fullStorage storage.Storage
|
||||
fullConnector connector.Connector
|
||||
updater *updater.Manager
|
||||
backgroundStop chan struct{}
|
||||
backgroundOnce sync.Once
|
||||
@@ -81,32 +90,55 @@ type client struct {
|
||||
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{
|
||||
s: s,
|
||||
conn: conn,
|
||||
app: app,
|
||||
window: app.NewWindow("Galaxy Plus"),
|
||||
world: nil,
|
||||
wp: &world.RenderParams{
|
||||
CameraZoom: 1.0,
|
||||
Options: &world.RenderOptions{DisableWrapScroll: false},
|
||||
},
|
||||
reg: newRegistry(),
|
||||
lastCanvasScale: 1.0,
|
||||
world: nil,
|
||||
hits: make([]world.Hit, 5),
|
||||
backgroundStop: make(chan struct{}),
|
||||
}
|
||||
if fullStorage, ok := s.(storage.Storage); ok {
|
||||
e.fullStorage = fullStorage
|
||||
}
|
||||
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.calculator = calculator.NewCaclulator(calculator.WithCreateHandler(e.createShipClass))
|
||||
e.updater = updater.NewManager(e.s, e.conn)
|
||||
|
||||
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}
|
||||
|
||||
@@ -127,40 +159,13 @@ func NewClient(s storage.UIStorage, conn connector.UIConnector, app fyne.App) (m
|
||||
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) {
|
||||
mapCanvas := newInteractiveRaster(e, e.raster, e.onRasterWidgetLayout, e.onScrolled, e.onDragged, e.onDradEnd, e.onTapped)
|
||||
mapCanvas.SetMinSize(fyne.NewSize(640, 480))
|
||||
mapCanvasObject := newInteractiveRaster(e.raster, e.onRasterWidgetLayout, e.onScrolled, e.onDragged, e.onDradEnd, e.onTapped)
|
||||
|
||||
toolbar := widget.NewToolbar(
|
||||
widget.NewToolbarAction(
|
||||
theme.FolderIcon(),
|
||||
func() {
|
||||
e.loadReport(0)
|
||||
// e.loadWorld(mockWorld())
|
||||
}),
|
||||
func() { e.initReportAsync("GAME_ID", 0) }),
|
||||
widget.NewToolbarSeparator(),
|
||||
widget.NewToolbarAction(
|
||||
theme.NavigateBackIcon(),
|
||||
@@ -170,11 +175,24 @@ func (e *client) BuildUI(w fyne.Window) {
|
||||
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(
|
||||
container.NewTabItemWithIcon(
|
||||
"Map",
|
||||
lang.L("title.map"),
|
||||
theme.GridIcon(),
|
||||
mapCanvas),
|
||||
e.mapSplitter),
|
||||
container.NewTabItemWithIcon(
|
||||
"Calculator",
|
||||
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(
|
||||
toolbar, // top
|
||||
nil, // bottom
|
||||
statusBar, // bottom
|
||||
nil, // left
|
||||
nil, // right
|
||||
tabs, // center
|
||||
@@ -192,6 +224,9 @@ func (e *client) BuildUI(w fyne.Window) {
|
||||
|
||||
w.CenterOnScreen()
|
||||
w.SetContent(content)
|
||||
s := statusBar.Size()
|
||||
icon.SetMinSize(fyne.NewSize(s.Height, s.Height))
|
||||
e.initLatestReport()
|
||||
}
|
||||
|
||||
func (e *client) loadWorld(w *world.World) {
|
||||
@@ -215,16 +250,18 @@ func (e *client) Run() error {
|
||||
e.window.SetMaster()
|
||||
e.window.Resize(fyne.NewSize(800, 600))
|
||||
e.window.CenterOnScreen()
|
||||
e.window.SetOnClosed(e.Shutdown)
|
||||
e.window.ShowAndRun()
|
||||
e.stopBackground()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *client) Shutdown() {
|
||||
e.stopBackground()
|
||||
e.ensureStatePersist()
|
||||
e.window.Close()
|
||||
}
|
||||
|
||||
// TODO: remove func?
|
||||
func (e *client) Version() string { return version }
|
||||
|
||||
func (e *client) OnConnection(isGood bool) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"galaxy/client"
|
||||
"galaxy/client/appmeta"
|
||||
"galaxy/client/loader"
|
||||
"galaxy/connector/http"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
"os/signal"
|
||||
|
||||
"fyne.io/fyne/v2/app"
|
||||
"fyne.io/fyne/v2/lang"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -19,7 +21,7 @@ func main() {
|
||||
defer func() {
|
||||
if err == 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 {
|
||||
@@ -32,6 +34,9 @@ func main() {
|
||||
defer cancel()
|
||||
|
||||
app := app.NewWithID(appmeta.AppID)
|
||||
if err = lang.AddTranslationsFS(client.Translations, "resource/lang"); err != nil {
|
||||
return
|
||||
}
|
||||
s, err := fs.NewFS(app.Storage().RootURI().Path())
|
||||
if err != nil {
|
||||
return
|
||||
@@ -44,6 +49,5 @@ func main() {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = l.Run(ctx)
|
||||
}
|
||||
|
||||
@@ -4,14 +4,15 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"galaxy/client/appmeta"
|
||||
"galaxy/client"
|
||||
"galaxy/client/appmeta"
|
||||
"galaxy/connector/http"
|
||||
"galaxy/storage/fs"
|
||||
"os"
|
||||
"os/signal"
|
||||
|
||||
"fyne.io/fyne/v2/app"
|
||||
"fyne.io/fyne/v2/lang"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -19,7 +20,7 @@ func main() {
|
||||
defer func() {
|
||||
if err == 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 {
|
||||
@@ -31,6 +32,9 @@ func main() {
|
||||
defer cancel()
|
||||
|
||||
app := app.NewWithID(appmeta.AppID)
|
||||
if err = lang.AddTranslationsFS(client.Translations, "resource/lang"); err != nil {
|
||||
return
|
||||
}
|
||||
s, err := fs.NewFS(app.Storage().RootURI().Path())
|
||||
if err != nil {
|
||||
return
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package client
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed resource/lang
|
||||
var Translations embed.FS
|
||||
@@ -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, ¶ms, 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()
|
||||
}
|
||||
@@ -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 ®istry{
|
||||
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) {
|
||||
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"math"
|
||||
|
||||
@@ -256,36 +255,6 @@ 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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -1,30 +1,82 @@
|
||||
package numeric
|
||||
|
||||
import (
|
||||
"galaxy/client/widget/validator"
|
||||
"galaxy/util"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/driver/mobile"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
)
|
||||
|
||||
type numericalEntry struct {
|
||||
type FloatEntry struct {
|
||||
widget.Entry
|
||||
origin float64
|
||||
MaxValue float64
|
||||
maxSize uint
|
||||
validator fyne.StringValidator
|
||||
Valid bool
|
||||
}
|
||||
|
||||
func NewNumericalEntry() *numericalEntry {
|
||||
entry := &numericalEntry{}
|
||||
entry.ExtendBaseWidget(entry)
|
||||
return entry
|
||||
type IntEntry struct {
|
||||
widget.Entry
|
||||
origin uint
|
||||
MaxValue uint
|
||||
maxSize uint
|
||||
validator fyne.StringValidator
|
||||
Valid bool
|
||||
}
|
||||
|
||||
func (e *numericalEntry) TypedRune(r rune) {
|
||||
if (r >= '0' && r <= '9') || r == '.' || r == ',' {
|
||||
e.Entry.TypedRune(r)
|
||||
func NewFloatEntry(maxSize uint, onChanged func(string)) *FloatEntry {
|
||||
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)
|
||||
}
|
||||
|
||||
func (e *numericalEntry) TypedShortcut(shortcut fyne.Shortcut) {
|
||||
func (e *FloatEntry) TypedShortcut(shortcut fyne.Shortcut) {
|
||||
paste, ok := shortcut.(*fyne.ShortcutPaste)
|
||||
if !ok {
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -2,11 +2,26 @@ package validator
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"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 {
|
||||
if first == 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 {
|
||||
if _, err := ParseFloat(s); err != nil {
|
||||
return errors.New("not a float value")
|
||||
@@ -50,6 +103,21 @@ func FloatValueValidator(s string) error {
|
||||
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) {
|
||||
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
@@ -415,6 +415,13 @@ func (LightTheme) PointClassOverride(class PointClassID) (StyleOverride, bool) {
|
||||
PointRadiusPx: new(3.5),
|
||||
}, true
|
||||
|
||||
case PointClassUnidentifiedPlanet:
|
||||
// soft orange
|
||||
return StyleOverride{
|
||||
FillColor: cRGBA(192, 192, 192, 255),
|
||||
PointRadiusPx: new(2.5),
|
||||
}, true
|
||||
|
||||
default:
|
||||
return StyleOverride{}, false
|
||||
}
|
||||
@@ -457,15 +464,7 @@ func (LightTheme) CircleClassOverride(class CircleClassID) (StyleOverride, bool)
|
||||
case CircleClassDefault:
|
||||
return StyleOverride{}, false
|
||||
|
||||
case CircleClassHome:
|
||||
// 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:
|
||||
case CircleClassLocalPlanet:
|
||||
// blue
|
||||
return StyleOverride{
|
||||
FillColor: cRGBA(70, 108, 196, 45),
|
||||
@@ -473,7 +472,7 @@ func (LightTheme) CircleClassOverride(class CircleClassID) (StyleOverride, bool)
|
||||
StrokeWidthPx: new(2.2),
|
||||
}, true
|
||||
|
||||
case CircleClassOccupied:
|
||||
case CircleClassOthersPlanet:
|
||||
// orange
|
||||
return StyleOverride{
|
||||
FillColor: cRGBA(222, 142, 70, 50),
|
||||
@@ -481,7 +480,7 @@ func (LightTheme) CircleClassOverride(class CircleClassID) (StyleOverride, bool)
|
||||
StrokeWidthPx: new(2.2),
|
||||
}, true
|
||||
|
||||
case CircleClassFree:
|
||||
case CircleClassFreePlanet:
|
||||
// green
|
||||
return StyleOverride{
|
||||
FillColor: cRGBA(76, 171, 107, 45),
|
||||
@@ -574,6 +573,12 @@ func (*DarkTheme) PointClassOverride(class PointClassID) (StyleOverride, bool) {
|
||||
PointRadiusPx: new(3.5),
|
||||
}, true
|
||||
|
||||
case PointClassUnidentifiedPlanet:
|
||||
return StyleOverride{
|
||||
FillColor: cRGBA(192, 192, 192, 255),
|
||||
PointRadiusPx: new(2.5),
|
||||
}, true
|
||||
|
||||
default:
|
||||
return StyleOverride{}, false
|
||||
}
|
||||
@@ -615,30 +620,23 @@ func (*DarkTheme) CircleClassOverride(class CircleClassID) (StyleOverride, bool)
|
||||
case CircleClassDefault:
|
||||
return StyleOverride{}, false
|
||||
|
||||
case CircleClassHome:
|
||||
case CircleClassLocalPlanet:
|
||||
return StyleOverride{
|
||||
FillColor: nil, // cRGBA(120, 214, 198, 255),
|
||||
StrokeColor: cRGBA(120, 214, 198, 255),
|
||||
StrokeWidthPx: new(2.5),
|
||||
}, true
|
||||
|
||||
case CircleClassAcquired:
|
||||
return StyleOverride{
|
||||
FillColor: nil, // cRGBA(155, 175, 235, 255),
|
||||
FillColor: cRGBA(155, 175, 235, 255),
|
||||
StrokeColor: cRGBA(155, 175, 235, 255),
|
||||
StrokeWidthPx: new(2.2),
|
||||
}, true
|
||||
|
||||
case CircleClassOccupied:
|
||||
case CircleClassOthersPlanet:
|
||||
return StyleOverride{
|
||||
FillColor: nil, // cRGBA(245, 178, 120, 255),
|
||||
FillColor: cRGBA(245, 178, 120, 255),
|
||||
StrokeColor: cRGBA(245, 178, 120, 255),
|
||||
StrokeWidthPx: new(2.2),
|
||||
}, true
|
||||
|
||||
case CircleClassFree:
|
||||
case CircleClassFreePlanet:
|
||||
return StyleOverride{
|
||||
FillColor: nil, // cRGBA(132, 219, 162, 255),
|
||||
FillColor: cRGBA(132, 219, 162, 255),
|
||||
StrokeColor: cRGBA(132, 219, 162, 255),
|
||||
StrokeWidthPx: new(2.2),
|
||||
}, true
|
||||
|
||||
@@ -688,6 +688,8 @@ const (
|
||||
PointClassTrackIncoming
|
||||
// PointClassTrackOutgoing marks a point as an outgoing track marker.
|
||||
PointClassTrackOutgoing
|
||||
// PointClassUnidentifiedPlanet marks an unidentified planet without visivle size.
|
||||
PointClassUnidentifiedPlanet
|
||||
)
|
||||
|
||||
// LineClassID classifies Line primitives for theme-level style overrides.
|
||||
@@ -711,14 +713,12 @@ type CircleClassID uint8
|
||||
const (
|
||||
// CircleClassDefault selects the theme's default circle styling.
|
||||
CircleClassDefault CircleClassID = iota
|
||||
// CircleClassHome marks a circle as a home-world area.
|
||||
CircleClassHome
|
||||
// CircleClassAcquired marks a circle as an acquired world area.
|
||||
CircleClassAcquired
|
||||
// CircleClassOccupied marks a circle as an occupied world area.
|
||||
CircleClassOccupied
|
||||
// CircleClassFree marks a circle as a free world area.
|
||||
CircleClassFree
|
||||
// CircleClassLocalPlanet marks a circle as a player-owned planet.
|
||||
CircleClassLocalPlanet
|
||||
// CircleClassOthersPlanet marks a circle as an occupied planet.
|
||||
CircleClassOthersPlanet
|
||||
// CircleClassFreePlanet marks a circle as a free planet.
|
||||
CircleClassFreePlanet
|
||||
)
|
||||
|
||||
// PrimitiveID is a compact stable identifier for primitives stored in the World.
|
||||
|
||||
@@ -3,10 +3,10 @@ package controller
|
||||
import (
|
||||
"iter"
|
||||
"maps"
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"slices"
|
||||
|
||||
"galaxy/calc"
|
||||
"galaxy/game/internal/model/game"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -65,7 +65,7 @@ func FilterBattleOpponents(c *Cache, attIdx, defIdx int, cacheProbability map[in
|
||||
return true
|
||||
}
|
||||
|
||||
p := DestructionProbability(
|
||||
p := calc.DestructionProbability(
|
||||
c.ShipGroupShipClass(attIdx).Weapons.F(),
|
||||
c.ShipGroup(attIdx).TechLevel(game.TechWeapons).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 {
|
||||
ids := slices.Collect(v)
|
||||
return ids[rand.IntN(len(ids))]
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"galaxy/calc"
|
||||
"galaxy/game/internal/controller"
|
||||
"galaxy/game/internal/model/game"
|
||||
|
||||
@@ -39,25 +40,25 @@ var (
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
undefeatedShip := ship
|
||||
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.)
|
||||
|
||||
disruptiveShip := ship
|
||||
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.)
|
||||
}
|
||||
|
||||
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())
|
||||
defenderEffectiveDefence := controller.EffectiveDefence(defender.Shields.F(), 1, defender.EmptyMass())
|
||||
attackerEffectiveDefence := calc.EffectiveDefence(attacker.Shields.F(), 1, attacker.EmptyMass())
|
||||
defenderEffectiveDefence := calc.EffectiveDefence(defender.Shields.F(), 1, defender.EmptyMass())
|
||||
|
||||
// attacker's effective shields must be 'just' 4 times greater than defender's
|
||||
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.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.NotContains(t, cacheProbability[1], 3)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"iter"
|
||||
"slices"
|
||||
|
||||
"galaxy/calc"
|
||||
"galaxy/util"
|
||||
|
||||
e "galaxy/error"
|
||||
@@ -272,7 +273,7 @@ func ProduceShip(p *game.Planet, productionAvailable, shipMass float64) uint {
|
||||
}
|
||||
ships := uint(0)
|
||||
pa := productionAvailable
|
||||
PRODcost := ShipProductionCost(shipMass)
|
||||
PRODcost := calc.ShipProductionCost(shipMass)
|
||||
var MATneed, MATfarm, totalCost float64
|
||||
for {
|
||||
MATneed = shipMass - float64(p.Material)
|
||||
@@ -281,8 +282,6 @@ func ProduceShip(p *game.Planet, productionAvailable, shipMass float64) uint {
|
||||
}
|
||||
MATfarm = MATneed / float64(p.Resources)
|
||||
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 {
|
||||
progress := pa / totalCost
|
||||
pval := game.F(progress)
|
||||
@@ -292,7 +291,6 @@ func ProduceShip(p *game.Planet, productionAvailable, shipMass float64) uint {
|
||||
p.Production.Progress = &pval
|
||||
fval := game.F(pa)
|
||||
p.Production.ProdUsed = &fval
|
||||
// fmt.Println("pa", pa, "progress", progress, "MAT:", progress*shipMass)
|
||||
return ships
|
||||
} else {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"iter"
|
||||
"slices"
|
||||
|
||||
"galaxy/calc"
|
||||
mr "galaxy/model/report"
|
||||
|
||||
"galaxy/util"
|
||||
@@ -540,7 +541,7 @@ func (c *Cache) ReportShipProduction(ri int, rep *mr.Report) {
|
||||
sliceIndexValidate(&rep.ShipProduction, i)
|
||||
rep.ShipProduction[pi].Planet = p.Number
|
||||
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].ProdUsed = mr.F((*p.Production.ProdUsed).F())
|
||||
rep.ShipProduction[pi].Percent = mr.F((*p.Production.Progress).F())
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"iter"
|
||||
"slices"
|
||||
|
||||
"galaxy/calc"
|
||||
"galaxy/util"
|
||||
|
||||
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 {
|
||||
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
|
||||
}
|
||||
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))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package game
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"galaxy/calc"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
@@ -179,18 +180,13 @@ func (sg ShipGroup) Equal(other ShipGroup) bool {
|
||||
sg.State() == other.State()
|
||||
}
|
||||
|
||||
// Грузоподъёмность
|
||||
// Грузоподъёмность группы
|
||||
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 {
|
||||
if sg.Load.F() == 0 {
|
||||
return 0
|
||||
}
|
||||
return sg.Load.F() / sg.TechLevel(TechCargo).F()
|
||||
return calc.CarryingMass(sg.Load.F(), sg.TechLevel(TechCargo).F())
|
||||
}
|
||||
|
||||
// Масса группы без учёта груза
|
||||
@@ -198,22 +194,16 @@ func (sg ShipGroup) EmptyMass(st *ShipType) float64 {
|
||||
return st.EmptyMass() * float64(sg.Number)
|
||||
}
|
||||
|
||||
// Полная масса -
|
||||
// массу корабля самого по себе плюс масса перевозимого груза
|
||||
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 {
|
||||
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 {
|
||||
return sg.DriveEffective(st) * 20 / sg.FullMass(st)
|
||||
return calc.Speed(sg.DriveEffective(st), sg.FullMass(st))
|
||||
}
|
||||
|
||||
// Мощность бомбардировки
|
||||
|
||||
@@ -2,6 +2,7 @@ package game
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"galaxy/calc"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
@@ -44,10 +45,11 @@ func (st ShipType) DriveBlockMass() 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))
|
||||
} else {
|
||||
return v
|
||||
}
|
||||
return float64(st.Armament+1) * (st.Weapons.F() / 2)
|
||||
}
|
||||
|
||||
func (st ShipType) ShieldsBlockMass() float64 {
|
||||
@@ -59,6 +61,10 @@ func (st ShipType) CargoBlockMass() float64 {
|
||||
}
|
||||
|
||||
func (st ShipType) EmptyMass() float64 {
|
||||
shipMass := st.DriveBlockMass() + st.ShieldsBlockMass() + st.CargoBlockMass() + st.WeaponsBlockMass()
|
||||
return shipMass
|
||||
if v, ok := calc.EmptyMass(st.Drive.F(), st.Weapons.F(), st.Armament, st.Shields.F(), st.Cargo.F()); !ok {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
]
|
||||
}
|
||||
Executable
+6
@@ -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
|
||||
@@ -3,6 +3,7 @@ go 1.26.0
|
||||
use (
|
||||
./client
|
||||
./game
|
||||
./pkg/calc
|
||||
./pkg/connector
|
||||
./pkg/error
|
||||
./pkg/model
|
||||
@@ -12,6 +13,7 @@ use (
|
||||
)
|
||||
|
||||
replace (
|
||||
galaxy/calc v0.0.0 => ./pkg/calc
|
||||
galaxy/connector v0.0.0 => ./pkg/connector
|
||||
galaxy/error v0.0.0 => ./pkg/error
|
||||
galaxy/model v0.0.0 => ./pkg/model
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
module galaxy/calc
|
||||
|
||||
go 1.26.0
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -41,6 +41,13 @@ type State struct {
|
||||
ClientNextVersion *string `json:"clientNextVersion,omitempty"`
|
||||
GameState []GameState `json:"gameState,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 {
|
||||
|
||||
@@ -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)) {
|
||||
go func() {
|
||||
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) {
|
||||
absPath, err := s.resolvePath(gameTurnFilePath(id, turn))
|
||||
if err != nil {
|
||||
|
||||
@@ -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.
|
||||
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.
|
||||
// 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].
|
||||
|
||||
Reference in New Issue
Block a user