Compare commits

..
Author SHA1 Message Date
adminandClaude Opus 4.6 5b2cf908d1 Fix attacker/defender starting position overlap on Motcia map
Motcia's attacker starting position index 0 had 7 of 10 positions
overlapping with defender starting positions. This caused the AI's
GameStateGuesser to place a guessed defender unit on an attacker
starting position during SET_UP, blocking it and creating a command
count mismatch (27 vs 30) that crashed the server.

Moved attacker index 0 positions east into the plains (columns 3-5)
away from the defender's castle compound at (1,0)/(2,0)/(2,1).

Also adds a CI test that validates no map has attacker starting
positions that overlap with defender starting positions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 21:45:17 -08:00
adminandClaude Opus 4.6 496545b006 Add detailed coords and occupant logging to AI command mismatch diagnostic
The guessed state produces 27 PLACE_UNIT_COMMANDs vs 30 real during SET_UP.
This adds target coordinates to the command dump, identifies the specific
missing positions per unit, checks what the guessed state thinks is occupying
them, and dumps all guessed state units for full visibility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 21:20:11 -08:00
54 changed files with 406 additions and 2791 deletions
-6
View File
@@ -46,12 +46,6 @@ jobs:
lfs: false
- name: Check BUILD.bazel dependencies
run: ./scripts/check_build_deps.sh --strict
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 'lts/*'
- name: Check JavaScript syntax
run: node --check src/main/go/net/eagle0/admin_server/static/map_editor.js
test:
runs-on: [self-hosted, bazel]
-4
View File
@@ -74,10 +74,6 @@ jobs:
with:
lfs: false
- name: Fetch LFS files needed for admin server
if: steps.check-latest.outputs.skip != 'true'
run: git lfs pull --include="src/main/go/net/eagle0/admin_server/static/tiles/*"
- name: Build all Docker images
if: steps.check-latest.outputs.skip != 'true'
id: build-all
-1
View File
@@ -5,7 +5,6 @@ repos:
rev: v4.3.0
hooks:
- id: check-added-large-files
- id: check-merge-conflict
- id: no-commit-to-branch
args: [--branch, main]
- repo: https://github.com/pocc/pre-commit-hooks
-8
View File
@@ -243,13 +243,6 @@ pkg_tar(
package_dir = "/app",
)
# Package the .e0mj map files for the map editor
pkg_tar(
name = "admin_maps_layer",
srcs = ["//src/main/resources/net/eagle0/shardok/maps:maps_json"],
package_dir = "/app/maps",
)
oci_image(
name = "admin_server_image",
base = "@alpine_linux_linux_amd64",
@@ -258,7 +251,6 @@ oci_image(
tars = [
":busybox_layer",
":admin_binary_layer",
":admin_maps_layer",
],
workdir = "/app",
)
-4
View File
@@ -208,8 +208,6 @@ services:
- "${JFR_SIDECAR_ADDR:-jfr-sidecar:8081}" # Can be switched for blue-green
- "--http-port"
- "8080"
- "--maps-dir"
- "/app/maps"
environment:
# Secret for CI to authenticate client update notifications
NOTIFY_SECRET: "${NOTIFY_SECRET:-}"
@@ -217,8 +215,6 @@ services:
DO_SPACES_ENDPOINT: "${DO_SPACES_ENDPOINT:-https://sfo3.digitaloceanspaces.com}"
DO_SPACES_ACCESS_KEY: "${ADMIN_S3_ACCESS_KEY:-}"
DO_SPACES_SECRET_KEY: "${ADMIN_S3_SECRET_KEY:-}"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
# No external port - accessed via nginx at admin.eagle0.net
depends_on:
- auth
-79
View File
@@ -1,79 +0,0 @@
# Renaming a Province
This document explains the steps required to rename a province in Eagle0.
## Files to Modify
### 1. Province Map TSV (Server Source of Truth)
**File:** `src/main/resources/net/eagle0/eagle/province_map.tsv`
This is the primary source of province data. Each row contains:
- Province ID
- Province name
- Neighbor IDs
- Neighbor directions
- Province name (repeated)
- Neighbor names (dot-separated)
- Starting food
You need to:
1. Change the province name in its own row (columns 2 and 5)
2. Update the neighbor name references in all neighboring provinces (column 6)
Example: To rename "Fluria" to "NewName", you would need to update:
- Line 26: The Fluria row itself
- Lines 5, 10, 18, 24, 27, 38, 41: All provinces that list Fluria as a neighbor
### 2. LLM Map Description
**File:** `src/main/scala/net/eagle0/eagle/library/actions/llm_prompt_generators/MapDescription.scala`
This file contains a hardcoded geographic description of the map used in LLM prompts for generating narrative text. Update any references to the old province name.
### 3. Client Centroids JSON
**File:** `src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Eagle/centroids.json`
This JSON file contains province metadata used for map rendering (centroid positions, areas, orientations). Each entry has a `name` field that should be updated to match.
Note: The actual province name displayed to players comes from the server via the `ProvinceView` proto, not from this file. However, this file should be kept in sync for consistency and because it may be used for map label positioning.
### 4. Test Files
**File:** `src/test/scala/net/eagle0/eagle/library/util/validations/RuntimeValidatorTest.scala`
Update any test strings that reference the old province name.
## How Province Names Flow to the Client
Province names are sent from the Eagle server to the Unity client via the `ProvinceView` protobuf message:
```protobuf
message ProvinceView {
int32 id = 1;
string name = 6; // <-- Province name sent from server
...
}
```
The server reads province names from `province_map.tsv` at startup. The client receives names through the proto and displays them via `province.Name` in C# code. This means:
- Changing the TSV automatically updates what clients see
- No C# code changes are needed (the code uses `province.Name` generically)
- The `centroids.json` name field is for map rendering/positioning, not display
## Files That Do NOT Need Changes
- **Hero backstory TSV files** (`heroes.tsv`, `generated_heroes.tsv`) - These don't contain province name references
- **C# client code** - Uses `province.Name` from the server proto, not hardcoded names
- **Proto definitions** - No province names are hardcoded in protos
## Checklist
- [ ] Update `province_map.tsv` - province's own row
- [ ] Update `province_map.tsv` - all neighbor references
- [ ] Update `MapDescription.scala`
- [ ] Update `centroids.json`
- [ ] Update any test files with province name references
- [ ] Build and run tests: `bazel test //src/test/scala/...`
-73
View File
@@ -1,73 +0,0 @@
#!/bin/bash
#
# Restart the active Eagle instance.
# Reads the active instance from /opt/eagle0/.active-instance (set by deploy-blue-green.sh).
#
# This is useful when you need Eagle to reload game state from persistence
# (e.g., after a rewind) without doing a full blue-green deployment.
#
# Usage:
# eagle-restart # Restart active instance, wait for healthy
# eagle-restart --no-wait # Restart without waiting for health check
#
# To create an alias, add to ~/.bashrc:
# alias eagle-restart='/opt/eagle0/scripts/eagle-restart.sh'
#
set -euo pipefail
APP_DIR="${APP_DIR:-/opt/eagle0}"
ACTIVE_FILE="${APP_DIR}/.active-instance"
HEALTH_TIMEOUT=120 # seconds
# Read active instance from file, with fallback
if [ -f "$ACTIVE_FILE" ]; then
ACTIVE=$(cat "$ACTIVE_FILE")
else
# Fallback: check which container is actually running
if docker inspect --format='{{.State.Running}}' eagle-blue 2>/dev/null | grep -q true; then
ACTIVE="eagle-blue"
elif docker inspect --format='{{.State.Running}}' eagle-green 2>/dev/null | grep -q true; then
ACTIVE="eagle-green"
else
ACTIVE=""
fi
fi
if [ -z "$ACTIVE" ]; then
echo "Error: No active Eagle instance found" >&2
exit 1
fi
NO_WAIT=false
if [ "${1:-}" = "--no-wait" ]; then
NO_WAIT=true
fi
echo "Restarting ${ACTIVE}..."
docker restart "$ACTIVE"
if [ "$NO_WAIT" = true ]; then
echo "Restart initiated. Not waiting for health check."
exit 0
fi
echo "Waiting for ${ACTIVE} to become healthy (timeout: ${HEALTH_TIMEOUT}s)..."
elapsed=0
while [ $elapsed -lt $HEALTH_TIMEOUT ]; do
health=$(docker inspect --format='{{.State.Health.Status}}' "$ACTIVE" 2>/dev/null || echo "unknown")
if [ "$health" = "healthy" ]; then
echo "${ACTIVE} is healthy after ${elapsed}s."
exit 0
fi
sleep 2
elapsed=$((elapsed + 2))
# Print progress every 10 seconds
if [ $((elapsed % 10)) -eq 0 ]; then
echo " ...${elapsed}s (status: ${health})"
fi
done
echo "Warning: ${ACTIVE} did not become healthy within ${HEALTH_TIMEOUT}s (status: ${health})" >&2
echo "Check logs with: eagle-logs" >&2
exit 1
@@ -302,13 +302,7 @@ namespace eagle {
kv => kv.Value.ImagePath),
battalionNames: _currentModel.BattalionNames.ToDictionary(
kv => kv.Key,
kv => kv.Value),
heroFallbackLookup: hid => {
if (_currentModel.Heroes.TryGetValue(hid, out var hero)) {
return (hero.NameTextId, hero.ImagePath);
}
return null;
});
kv => kv.Value));
return shardokModel;
}
@@ -129,23 +129,20 @@ namespace eagle {
public ProvinceIDLoader provinceIDLoader;
private readonly Color _selectionColor = Color.grey;
private readonly Color _targetColor = Color.red;
private readonly Color _commandAvailableColor = Color.blue;
private readonly Color _hoverColor = new Color(0, 0, 0.5f, 0.5f);
private const float SelectedBorderMin = 2f, SelectedBorderMax = 6f,
private const float SelectedBorderMin = 4f, SelectedBorderMax = 12f,
SelectedBorderCycle = 1.0f;
private static readonly Color SelectedBorderColor = new Color(0f, 0f, 0f, 0.95f);
private static readonly Color OverrideStrobeColor = Color.black;
private static readonly Color OverrideBorderColor = new Color(0.05f, 0.05f, 0.05f, 0.95f);
private const float OverrideBorderMin = 3f, OverrideBorderMax = 8f,
OverrideBorderCycle = 0.25f;
private static readonly Color TargetedStrobeColor = new Color(0.55f, 0.05f, 0.15f);
private static readonly Color TargetedBorderColor = new Color(0.55f, 0.05f, 0.15f, 0.95f);
private const float TargetedBorderMin = 3f, TargetedBorderMax = 8f,
private const float TargetedBorderMin = 6f, TargetedBorderMax = 15f,
TargetedBorderCycle = 0.25f;
private static readonly Color TargetedBorderColor = new Color(1f, 0.2f, 0.2f, 0.95f);
private const float CommandBorderMin = 1.5f, CommandBorderMax = 5f,
private const float CommandBorderMin = 3f, CommandBorderMax = 10f,
CommandBorderCycle = 0.5f;
private static readonly Color CommandBorderColor = new Color(0.3f, 0.3f, 1f, 0.85f);
@@ -170,17 +167,6 @@ namespace eagle {
return color;
}
private static float Luminance(Color c) {
return 0.299f * c.r + 0.587f * c.g + 0.114f * c.b;
}
private static (Color strobe, Color border) ContrastingHighlightColors(Color baseColor) {
if (Luminance(baseColor) > 0.5f) {
return (Color.black, new Color(0.05f, 0.05f, 0.05f, 0.95f));
}
return (Color.white, new Color(1f, 1f, 1f, 0.95f));
}
private static bool HasBlizzardEvent(ProvinceView province) {
foreach (var evt in province.KnownEvents) {
if (evt.SealedValueCase == ProvinceEvent.SealedValueOneofCase.BlizzardEvent) {
@@ -247,32 +233,27 @@ namespace eagle {
SetDefaultProvinceColor(commandProvince);
}
provinceBorderManager.SetHighlightGroup(OverrideTargetedProvinces);
foreach (var pid in OverrideTargetedProvinces) {
FlashProvince(pid, OverrideStrobeColor, OverrideBorderCycle);
FlashProvince(pid, _targetColor, TargetedBorderCycle);
HighlightProvinceBorder(
pid,
OverrideBorderColor,
OverrideBorderMin,
OverrideBorderMax,
OverrideBorderCycle);
TargetedBorderColor,
TargetedBorderMin,
TargetedBorderMax,
TargetedBorderCycle);
}
} else {
provinceBorderManager.ClearHighlightGroup();
if (SelectedProvinceId is ProvinceId sPid) {
var sBase = ColorForProvince(Model.Provinces[sPid]);
var (sStrobe, sBorder) = ContrastingHighlightColors(sBase);
FlashProvince(sPid, sStrobe, SelectedBorderCycle);
FlashProvince(sPid, _selectionColor, SelectedBorderCycle);
HighlightProvinceBorder(
sPid,
sBorder,
SelectedBorderColor,
SelectedBorderMin,
SelectedBorderMax,
SelectedBorderCycle);
}
foreach (ProvinceId tPid in TargetedProvinces) {
FlashProvince(tPid, TargetedStrobeColor, TargetedBorderCycle);
FlashProvince(tPid, _targetColor, TargetedBorderCycle);
HighlightProvinceBorder(
tPid,
TargetedBorderColor,
@@ -39,14 +39,6 @@ Material:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _CrossProvinceIDMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _HighlightGroupLookup:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _MainTex:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
@@ -63,7 +55,7 @@ Material:
m_Floats:
- _ColorMask: 15
- _Cutoff: 0.308
- _DefaultBorderWidth: 0.5
- _DefaultBorderWidth: 0.35
- _OceanWaveSpeed: 2
- _OceanWaveStrength: 0.0337
- _Stencil: 0
@@ -6,24 +6,19 @@ namespace eagle {
/// Each pixel stores the Euclidean distance (Chamfer approximation, in texels) to
/// the nearest province boundary, clamped to 255. The shader uses this to render
/// variable-width borders with a single texture sample per fragment.
///
/// Also generates a cross-province ID map: for each pixel, the province ID on the
/// other side of the nearest border. The shader uses this to suppress highlight
/// borders between provinces in the same highlight group.
/// </summary>
public class ProvinceBorderDistanceGenerator : MonoBehaviour {
public ProvinceIDLoader provinceIDLoader;
public Material provinceMapMaterial;
private Texture2D _distanceTexture;
private Texture2D _crossProvinceTexture;
void Start() {
var mapBytes = provinceIDLoader.DecompressedBytes;
int w = provinceIDLoader.mapWidth;
int h = provinceIDLoader.mapHeight;
var (dist, crossIds) = ComputeDistanceMap(mapBytes, w, h);
var dist = ComputeDistanceMap(mapBytes, w, h);
_distanceTexture = new Texture2D(w, h, TextureFormat.R8, false);
_distanceTexture.filterMode = FilterMode.Bilinear;
@@ -31,21 +26,13 @@ namespace eagle {
_distanceTexture.LoadRawTextureData(dist);
_distanceTexture.Apply();
_crossProvinceTexture = new Texture2D(w, h, TextureFormat.R8, false);
_crossProvinceTexture.filterMode = FilterMode.Point;
_crossProvinceTexture.wrapMode = TextureWrapMode.Clamp;
_crossProvinceTexture.LoadRawTextureData(crossIds);
_crossProvinceTexture.Apply();
provinceMapMaterial.SetTexture("_BorderDistanceMap", _distanceTexture);
provinceMapMaterial.SetTexture("_CrossProvinceIDMap", _crossProvinceTexture);
}
private static (byte[] dist, byte[] crossIds) ComputeDistanceMap(byte[] ids, int w, int h) {
private static byte[] ComputeDistanceMap(byte[] ids, int w, int h) {
const float DIAG = 1.414f;
int size = w * h;
var dist = new float[size];
var crossIds = new byte[size];
// Init: border pixels = 0, interior = large value
for (int y = 0; y < h; y++) {
@@ -56,29 +43,24 @@ namespace eagle {
// Ocean/invalid pixels are always border
if (id == 0 || id == 0xFF) {
dist[idx] = 0f;
crossIds[idx] = id;
continue;
}
// Check 8 neighbors for different province ID
bool isBorder = false;
byte crossId = id;
for (int dy = -1; dy <= 1 && !isBorder; dy++) {
for (int dx = -1; dx <= 1 && !isBorder; dx++) {
if (dx == 0 && dy == 0) continue;
int nx = x + dx, ny = y + dy;
if (nx < 0 || nx >= w || ny < 0 || ny >= h) {
isBorder = true;
crossId = 0;
} else if (ids[ny * w + nx] != id) {
isBorder = true;
crossId = ids[ny * w + nx];
}
}
}
dist[idx] = isBorder ? 0f : 255f;
crossIds[idx] = crossId;
}
}
@@ -89,27 +71,11 @@ namespace eagle {
if (dist[idx] == 0f) continue;
float d = dist[idx];
int bestIdx = idx;
if (x > 0 && dist[idx - 1] + 1f < d) {
d = dist[idx - 1] + 1f;
bestIdx = idx - 1;
}
if (y > 0 && dist[(y - 1) * w + x] + 1f < d) {
d = dist[(y - 1) * w + x] + 1f;
bestIdx = (y - 1) * w + x;
}
if (x > 0 && y > 0 && dist[(y - 1) * w + x - 1] + DIAG < d) {
d = dist[(y - 1) * w + x - 1] + DIAG;
bestIdx = (y - 1) * w + x - 1;
}
if (x < w - 1 && y > 0 && dist[(y - 1) * w + x + 1] + DIAG < d) {
d = dist[(y - 1) * w + x + 1] + DIAG;
bestIdx = (y - 1) * w + x + 1;
}
if (x > 0) d = Mathf.Min(d, dist[idx - 1] + 1f);
if (y > 0) d = Mathf.Min(d, dist[(y - 1) * w + x] + 1f);
if (x > 0 && y > 0) d = Mathf.Min(d, dist[(y - 1) * w + x - 1] + DIAG);
if (x < w - 1 && y > 0) d = Mathf.Min(d, dist[(y - 1) * w + x + 1] + DIAG);
dist[idx] = d;
if (bestIdx != idx) { crossIds[idx] = crossIds[bestIdx]; }
}
}
@@ -120,41 +86,24 @@ namespace eagle {
if (dist[idx] == 0f) continue;
float d = dist[idx];
int bestIdx = idx;
if (x < w - 1 && dist[idx + 1] + 1f < d) {
d = dist[idx + 1] + 1f;
bestIdx = idx + 1;
}
if (y < h - 1 && dist[(y + 1) * w + x] + 1f < d) {
d = dist[(y + 1) * w + x] + 1f;
bestIdx = (y + 1) * w + x;
}
if (x < w - 1 && y < h - 1 && dist[(y + 1) * w + x + 1] + DIAG < d) {
d = dist[(y + 1) * w + x + 1] + DIAG;
bestIdx = (y + 1) * w + x + 1;
}
if (x > 0 && y < h - 1 && dist[(y + 1) * w + x - 1] + DIAG < d) {
d = dist[(y + 1) * w + x - 1] + DIAG;
bestIdx = (y + 1) * w + x - 1;
}
if (x < w - 1) d = Mathf.Min(d, dist[idx + 1] + 1f);
if (y < h - 1) d = Mathf.Min(d, dist[(y + 1) * w + x] + 1f);
if (x < w - 1 && y < h - 1) d = Mathf.Min(d, dist[(y + 1) * w + x + 1] + DIAG);
if (x > 0 && y < h - 1) d = Mathf.Min(d, dist[(y + 1) * w + x - 1] + DIAG);
dist[idx] = d;
if (bestIdx != idx) { crossIds[idx] = crossIds[bestIdx]; }
}
}
// Pack into byte arrays
var distResult = new byte[size];
// Pack into byte array
var result = new byte[size];
for (int i = 0; i < size; i++) {
distResult[i] = (byte)Mathf.Min(Mathf.RoundToInt(dist[i]), 255);
result[i] = (byte)Mathf.Min(Mathf.RoundToInt(dist[i]), 255);
}
return (distResult, crossIds);
return result;
}
void OnDestroy() {
if (_distanceTexture != null) { Destroy(_distanceTexture); }
if (_crossProvinceTexture != null) { Destroy(_crossProvinceTexture); }
}
}
}
@@ -1,4 +1,3 @@
using System.Collections.Generic;
using UnityEngine;
namespace eagle {
@@ -6,25 +5,20 @@ namespace eagle {
/// Manages 256x1 lookup textures for per-province border highlighting.
/// Border color and width are set per province and uploaded each frame
/// when dirty, mirroring ProvinceColorManager's batched approach.
/// Also manages a highlight-group lookup so the shader can suppress
/// highlight borders between provinces in the same group (e.g. same faction).
/// </summary>
public class ProvinceBorderManager : MonoBehaviour {
[Header("Material")]
public Material provinceMapMaterial;
[Header("Default Borders")]
public float defaultBorderWidth = 0.5f;
public float defaultBorderWidth = 0.7f;
public Color defaultBorderColor = new Color(0.15f, 0.1f, 0.05f, 0.8f);
private Texture2D _borderColorLookup;
private Texture2D _borderWidthLookup;
private Texture2D _highlightGroupLookup;
private Color[] _borderColors;
private Color[] _borderWidths;
private Color[] _highlightGroups;
private bool _dirty;
private bool _groupDirty;
void Awake() {
_borderColorLookup = new Texture2D(256, 1, TextureFormat.RGBA32, false);
@@ -35,37 +29,28 @@ namespace eagle {
_borderWidthLookup.filterMode = FilterMode.Point;
_borderWidthLookup.wrapMode = TextureWrapMode.Clamp;
_highlightGroupLookup = new Texture2D(256, 1, TextureFormat.RGBA32, false);
_highlightGroupLookup.filterMode = FilterMode.Point;
_highlightGroupLookup.wrapMode = TextureWrapMode.Clamp;
_borderColors = new Color[256];
_borderWidths = new Color[256];
_highlightGroups = new Color[256];
for (int i = 0; i < 256; i++) {
_borderColors[i] = Color.clear;
_borderWidths[i] = Color.clear;
_highlightGroups[i] = Color.clear;
}
_borderColorLookup.SetPixels(_borderColors);
_borderColorLookup.Apply();
_borderWidthLookup.SetPixels(_borderWidths);
_borderWidthLookup.Apply();
_highlightGroupLookup.SetPixels(_highlightGroups);
_highlightGroupLookup.Apply();
provinceMapMaterial.SetTexture("_BorderColorLookup", _borderColorLookup);
provinceMapMaterial.SetTexture("_BorderWidthLookup", _borderWidthLookup);
provinceMapMaterial.SetTexture("_HighlightGroupLookup", _highlightGroupLookup);
provinceMapMaterial.SetFloat("_DefaultBorderWidth", defaultBorderWidth);
provinceMapMaterial.SetColor("_DefaultBorderColor", defaultBorderColor);
}
public void SetProvinceBorder(int provinceId, Color color, float widthScreenPx) {
public void SetProvinceBorder(int provinceId, Color color, float widthTexels) {
if (provinceId < 0 || provinceId > 255) return;
_borderColors[provinceId] = color;
_borderWidths[provinceId] = new Color(widthScreenPx / 255f, 0, 0, 0);
_borderWidths[provinceId] = new Color(widthTexels / 255f, 0, 0, 0);
_dirty = true;
}
@@ -76,20 +61,6 @@ namespace eagle {
_dirty = true;
}
public void SetHighlightGroup(IList<int> provinceIds) {
for (int i = 0; i < 256; i++) { _highlightGroups[i] = Color.clear; }
var groupColor = new Color(1f / 255f, 0, 0, 0);
foreach (var id in provinceIds) {
if (id >= 0 && id <= 255) { _highlightGroups[id] = groupColor; }
}
_groupDirty = true;
}
public void ClearHighlightGroup() {
for (int i = 0; i < 256; i++) { _highlightGroups[i] = Color.clear; }
_groupDirty = true;
}
void LateUpdate() {
if (_dirty) {
_borderColorLookup.SetPixels(_borderColors);
@@ -98,17 +69,11 @@ namespace eagle {
_borderWidthLookup.Apply();
_dirty = false;
}
if (_groupDirty) {
_highlightGroupLookup.SetPixels(_highlightGroups);
_highlightGroupLookup.Apply();
_groupDirty = false;
}
}
void OnDestroy() {
if (_borderColorLookup != null) Destroy(_borderColorLookup);
if (_borderWidthLookup != null) Destroy(_borderWidthLookup);
if (_highlightGroupLookup != null) Destroy(_highlightGroupLookup);
}
}
}
@@ -19,10 +19,6 @@ Shader "Eagle/ProvinceMap"
_DefaultBorderWidth ("Default Border Width", Float) = 0.35
_DefaultBorderColor ("Default Border Color", Color) = (0.15, 0.1, 0.05, 0.8)
// Same-faction border suppression
_CrossProvinceIDMap ("Cross Province ID Map", 2D) = "black" {}
_HighlightGroupLookup ("Highlight Group Lookup (256x1)", 2D) = "black" {}
// UI clipping support
_StencilComp ("Stencil Comparison", Float) = 8
_Stencil ("Stencil ID", Float) = 0
@@ -94,11 +90,8 @@ Shader "Eagle/ProvinceMap"
sampler2D _BorderDistanceMap;
sampler2D _BorderColorLookup;
sampler2D _BorderWidthLookup;
sampler2D _CrossProvinceIDMap;
sampler2D _HighlightGroupLookup;
float4 _ProvinceIDMap_ST;
float4 _OceanTex_ST;
float4 _BorderDistanceMap_TexelSize; // Unity auto-provides (1/w, 1/h, w, h)
fixed4 _OceanColor;
float4 _OceanScrollSpeed;
float _OceanWaveStrength;
@@ -156,38 +149,18 @@ Shader "Eagle/ProvinceMap"
// Distance to nearest province boundary (0 = border, 255 = deep interior)
float dist = tex2D(_BorderDistanceMap, i.uv).r * 255.0;
// Resolution-independent scaling: how many texels span one screen pixel
float texelsPerPixel = fwidth(i.uv.x) * _BorderDistanceMap_TexelSize.z;
// Convert screen-pixel widths to texel widths
float effectiveDefaultWidth = _DefaultBorderWidth * texelsPerPixel;
// Per-province highlight border (animated width from C#, in screen pixels)
float highlightWidthPx = tex2D(_BorderWidthLookup, float2(provinceId, 0.5)).r * 255.0;
float effectiveHighlightWidth = highlightWidthPx * texelsPerPixel;
// Per-province highlight border (animated width from C#)
float highlightWidth = tex2D(_BorderWidthLookup, float2(provinceId, 0.5)).r * 255.0;
fixed4 highlightColor = tex2D(_BorderColorLookup, float2(provinceId, 0.5));
// Check if the province across the nearest border is in the
// same highlight group (e.g. same faction). When both sides
// belong to the same group the highlight border is suppressed
// so the faction's territory looks like one contiguous region.
float crossProvId = tex2D(_CrossProvinceIDMap, i.uv).r;
float myGroup = tex2D(_HighlightGroupLookup, float2(provinceId, 0.5)).r;
float crossGroup = tex2D(_HighlightGroupLookup, float2(crossProvId, 0.5)).r;
bool sameGroup = myGroup > 0.001 && abs(myGroup - crossGroup) < 0.001;
// Scale anti-aliasing transitions
float defaultTransition = 0.5 * texelsPerPixel;
float highlightTransition = 1.5 * texelsPerPixel;
if (effectiveHighlightWidth > 0.5 && dist <= effectiveHighlightWidth && !sameGroup)
if (highlightWidth > 0.5 && dist <= highlightWidth)
{
float edge = 1.0 - saturate((dist - effectiveHighlightWidth + highlightTransition) / highlightTransition);
float edge = 1.0 - saturate((dist - highlightWidth + 1.5) / 1.5);
color = lerp(fillColor, highlightColor, highlightColor.a * edge);
}
else if (dist <= effectiveDefaultWidth)
else if (dist <= _DefaultBorderWidth)
{
float edge = 1.0 - saturate((dist - effectiveDefaultWidth + defaultTransition) / defaultTransition);
float edge = 1.0 - saturate((dist - _DefaultBorderWidth + 0.5) / 0.5);
color = lerp(fillColor, _DefaultBorderColor, _DefaultBorderColor.a * edge);
}
else
@@ -3,8 +3,8 @@
{
"id": 4,
"name": "Berkorszag",
"centroid_x": 1767.0,
"centroid_y": 1296.0,
"centroid_x": 1757.8,
"centroid_y": 1231.9,
"area": 247272,
"orientation": -20.1884829564446,
"principal_length": 873.5,
@@ -14,8 +14,8 @@
{
"id": 27,
"name": "Chapellia",
"centroid_x": 3077.0,
"centroid_y": 509.0,
"centroid_x": 3076.8,
"centroid_y": 508.9,
"area": 37546,
"orientation": -70.52505264723656,
"principal_length": 297.6,
@@ -25,8 +25,8 @@
{
"id": 28,
"name": "Kezonoria",
"centroid_x": 2215.0,
"centroid_y": 1251.0,
"centroid_x": 2204.7,
"centroid_y": 1263.1,
"area": 126885,
"orientation": -35.386786918677814,
"principal_length": 753.6,
@@ -36,7 +36,7 @@
{
"id": 29,
"name": "Mesh",
"centroid_x": 2832.0,
"centroid_x": 2832.3,
"centroid_y": 1017.0,
"area": 197788,
"orientation": -26.173956537897066,
@@ -47,8 +47,8 @@
{
"id": 36,
"name": "Pemeria",
"centroid_x": 2835.0,
"centroid_y": 1282.0,
"centroid_x": 2839.4,
"centroid_y": 1290.4,
"area": 36356,
"orientation": -44.16012147839107,
"principal_length": 457.1,
@@ -58,8 +58,8 @@
{
"id": 37,
"name": "Yuetia",
"centroid_x": 902.0,
"centroid_y": 1132.0,
"centroid_x": 896.1,
"centroid_y": 1227.1,
"area": 362211,
"orientation": 0.6747576339005641,
"principal_length": 1261.0,
@@ -69,8 +69,8 @@
{
"id": 39,
"name": "Kojaria",
"centroid_x": 3234.0,
"centroid_y": 678.0,
"centroid_x": 3233.1,
"centroid_y": 678.6,
"area": 37489,
"orientation": -25.04939634407932,
"principal_length": 301.9,
@@ -80,8 +80,8 @@
{
"id": 43,
"name": "Chia",
"centroid_x": 1177.0,
"centroid_y": 1499.0,
"centroid_x": 1134.5,
"centroid_y": 1519.3,
"area": 75627,
"orientation": -35.91298964206631,
"principal_length": 441.6,
@@ -91,8 +91,8 @@
{
"id": 18,
"name": "Alah",
"centroid_x": 3056.0,
"centroid_y": 1330.0,
"centroid_x": 3057.6,
"centroid_y": 1330.3,
"area": 35076,
"orientation": -25.515287772906877,
"principal_length": 285.6,
@@ -102,8 +102,8 @@
{
"id": 34,
"name": "Turton",
"centroid_x": 2598.0,
"centroid_y": 1500.0,
"centroid_x": 2586.7,
"centroid_y": 1506.5,
"area": 37458,
"orientation": -89.27834794069042,
"principal_length": 295.6,
@@ -113,8 +113,8 @@
{
"id": 41,
"name": "Parnia",
"centroid_x": 398.0,
"centroid_y": 963.0,
"centroid_x": 397.9,
"centroid_y": 962.3,
"area": 184733,
"orientation": 80.31028460303715,
"principal_length": 816.0,
@@ -124,8 +124,8 @@
{
"id": 7,
"name": "Oryslia",
"centroid_x": 2466.0,
"centroid_y": 1058.0,
"centroid_x": 2494.9,
"centroid_y": 1104.4,
"area": 108679,
"orientation": 53.601133897917535,
"principal_length": 673.2,
@@ -135,7 +135,7 @@
{
"id": 6,
"name": "Soria",
"centroid_x": 611.0,
"centroid_x": 611.2,
"centroid_y": 587.0,
"area": 37567,
"orientation": 73.04044050523646,
@@ -146,7 +146,7 @@
{
"id": 26,
"name": "Reigia",
"centroid_x": 1538.0,
"centroid_x": 1538.6,
"centroid_y": 824.0,
"area": 105738,
"orientation": -56.461232070203295,
@@ -157,8 +157,8 @@
{
"id": 21,
"name": "Sigkarl",
"centroid_x": 1776.0,
"centroid_y": 688.0,
"centroid_x": 1776.1,
"centroid_y": 688.7,
"area": 52475,
"orientation": -80.26596706568765,
"principal_length": 411.4,
@@ -168,8 +168,8 @@
{
"id": 42,
"name": "Grytrand",
"centroid_x": 1071.0,
"centroid_y": 373.0,
"centroid_x": 1064.1,
"centroid_y": 369.5,
"area": 37511,
"orientation": 61.866124085696136,
"principal_length": 315.1,
@@ -179,8 +179,8 @@
{
"id": 13,
"name": "Al Raala",
"centroid_x": 3165.0,
"centroid_y": 1142.0,
"centroid_x": 3164.8,
"centroid_y": 1142.9,
"area": 64624,
"orientation": 1.6109698686653782,
"principal_length": 417.0,
@@ -190,8 +190,8 @@
{
"id": 3,
"name": "Laufarvia",
"centroid_x": 770.0,
"centroid_y": 437.0,
"centroid_x": 770.1,
"centroid_y": 437.8,
"area": 37521,
"orientation": -42.64914861683819,
"principal_length": 253.6,
@@ -201,7 +201,7 @@
{
"id": 8,
"name": "Atfordia",
"centroid_x": 2474.0,
"centroid_x": 2473.6,
"centroid_y": 527.0,
"area": 78637,
"orientation": -15.123132625107454,
@@ -212,8 +212,8 @@
{
"id": 33,
"name": "Usvol",
"centroid_x": 939.0,
"centroid_y": 418.0,
"centroid_x": 939.2,
"centroid_y": 417.9,
"area": 37617,
"orientation": 72.87731176765703,
"principal_length": 329.4,
@@ -223,8 +223,8 @@
{
"id": 9,
"name": "Bolve",
"centroid_x": 1554.0,
"centroid_y": 568.0,
"centroid_x": 1554.3,
"centroid_y": 567.8,
"area": 37519,
"orientation": -28.254514479055615,
"principal_length": 297.1,
@@ -234,8 +234,8 @@
{
"id": 17,
"name": "Hofolen",
"centroid_x": 1192.0,
"centroid_y": 497.0,
"centroid_x": 1193.4,
"centroid_y": 496.5,
"area": 37530,
"orientation": -40.73421792639953,
"principal_length": 321.3,
@@ -245,8 +245,8 @@
{
"id": 15,
"name": "Pieksa",
"centroid_x": 3372.0,
"centroid_y": 753.0,
"centroid_x": 3373.6,
"centroid_y": 752.0,
"area": 37474,
"orientation": -42.30480488812397,
"principal_length": 302.7,
@@ -256,8 +256,8 @@
{
"id": 1,
"name": "Shumal",
"centroid_x": 2841.0,
"centroid_y": 1426.0,
"centroid_x": 2841.2,
"centroid_y": 1426.4,
"area": 35197,
"orientation": -26.08271079122629,
"principal_length": 286.9,
@@ -267,8 +267,8 @@
{
"id": 5,
"name": "Musland",
"centroid_x": 2776.0,
"centroid_y": 686.0,
"centroid_x": 2776.1,
"centroid_y": 685.8,
"area": 88353,
"orientation": -67.33176588364896,
"principal_length": 410.3,
@@ -278,8 +278,8 @@
{
"id": 19,
"name": "Hella",
"centroid_x": 810.0,
"centroid_y": 600.0,
"centroid_x": 809.4,
"centroid_y": 600.1,
"area": 37632,
"orientation": -29.964829271657674,
"principal_length": 292.4,
@@ -289,7 +289,7 @@
{
"id": 24,
"name": "Tatoros",
"centroid_x": 2453.0,
"centroid_x": 2452.8,
"centroid_y": 1508.0,
"area": 37547,
"orientation": 64.24404335610743,
@@ -300,8 +300,8 @@
{
"id": 10,
"name": "Vovimaria",
"centroid_x": 613.0,
"centroid_y": 807.0,
"centroid_x": 608.9,
"centroid_y": 818.4,
"area": 37474,
"orientation": 42.325246247463234,
"principal_length": 451.9,
@@ -311,8 +311,8 @@
{
"id": 14,
"name": "Onmaa",
"centroid_x": 3465.0,
"centroid_y": 876.0,
"centroid_x": 3464.3,
"centroid_y": 864.9,
"area": 48302,
"orientation": -33.08899725807026,
"principal_length": 465.4,
@@ -323,7 +323,7 @@
"id": 32,
"name": "Ingia",
"centroid_x": 445.0,
"centroid_y": 615.0,
"centroid_y": 614.2,
"area": 37475,
"orientation": 40.330942997303495,
"principal_length": 270.3,
@@ -333,8 +333,8 @@
{
"id": 11,
"name": "Garholtia",
"centroid_x": 768.0,
"centroid_y": 807.0,
"centroid_x": 768.3,
"centroid_y": 805.8,
"area": 37509,
"orientation": 73.25523522382795,
"principal_length": 275.4,
@@ -344,8 +344,8 @@
{
"id": 2,
"name": "Motcia",
"centroid_x": 2989.0,
"centroid_y": 674.0,
"centroid_x": 2989.1,
"centroid_y": 672.1,
"area": 37541,
"orientation": 71.01019565314077,
"principal_length": 340.1,
@@ -355,8 +355,8 @@
{
"id": 20,
"name": "Elekes",
"centroid_x": 2355.0,
"centroid_y": 1346.0,
"centroid_x": 2354.4,
"centroid_y": 1346.4,
"area": 37570,
"orientation": -4.693928802826447,
"principal_length": 300.8,
@@ -366,8 +366,8 @@
{
"id": 16,
"name": "Oscasland",
"centroid_x": 2033.0,
"centroid_y": 761.0,
"centroid_x": 2033.5,
"centroid_y": 740.9,
"area": 92595,
"orientation": -7.875650568974214,
"principal_length": 519.3,
@@ -377,8 +377,8 @@
{
"id": 30,
"name": "Tegrot",
"centroid_x": 1736.0,
"centroid_y": 1018.0,
"centroid_x": 1736.5,
"centroid_y": 1018.5,
"area": 122426,
"orientation": -20.247381662688625,
"principal_length": 568.2,
@@ -387,9 +387,9 @@
},
{
"id": 40,
"name": "West Faluria",
"centroid_x": 976.0,
"centroid_y": 746.0,
"name": "Faluria",
"centroid_x": 976.7,
"centroid_y": 744.3,
"area": 80344,
"orientation": -62.2520755175581,
"principal_length": 494.3,
@@ -398,9 +398,9 @@
},
{
"id": 25,
"name": "East Faluria",
"centroid_x": 1245.0,
"centroid_y": 836.0,
"name": "Fluria",
"centroid_x": 1245.7,
"centroid_y": 835.6,
"area": 155157,
"orientation": -66.31259041323624,
"principal_length": 561.7,
@@ -410,8 +410,8 @@
{
"id": 35,
"name": "Tumala",
"centroid_x": 3210.0,
"centroid_y": 1409.0,
"centroid_x": 3211.2,
"centroid_y": 1409.8,
"area": 25474,
"orientation": 33.16003147165417,
"principal_length": 283.2,
@@ -421,8 +421,8 @@
{
"id": 31,
"name": "Nikemi",
"centroid_x": 3200.0,
"centroid_y": 881.0,
"centroid_x": 3200.5,
"centroid_y": 882.0,
"area": 40737,
"orientation": 57.09128390939083,
"principal_length": 378.6,
@@ -432,8 +432,8 @@
{
"id": 22,
"name": "Chipingia",
"centroid_x": 2209.0,
"centroid_y": 588.0,
"centroid_x": 2206.4,
"centroid_y": 580.2,
"area": 40656,
"orientation": -48.11179717863961,
"principal_length": 286.5,
@@ -443,8 +443,8 @@
{
"id": 23,
"name": "Eivikia",
"centroid_x": 1360.0,
"centroid_y": 506.0,
"centroid_x": 1359.5,
"centroid_y": 506.6,
"area": 37555,
"orientation": -49.357592556477876,
"principal_length": 285.3,
@@ -454,8 +454,8 @@
{
"id": 38,
"name": "Wichel",
"centroid_x": 2395.0,
"centroid_y": 781.0,
"centroid_x": 2399.6,
"centroid_y": 792.4,
"area": 122281,
"orientation": -26.154786547398853,
"principal_length": 627.0,
@@ -465,8 +465,8 @@
{
"id": 12,
"name": "Pozia",
"centroid_x": 2960.0,
"centroid_y": 410.0,
"centroid_x": 2950.6,
"centroid_y": 338.6,
"area": 56289,
"orientation": -65.77001402153415,
"principal_length": 522.5,
@@ -474,4 +474,4 @@
"curvature": -9.3
}
]
}
}
@@ -3079,8 +3079,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 200, y: -274.30908}
m_SizeDelta: {x: 400, y: 90.64608}
m_AnchoredPosition: {x: 200, y: -284.7687}
m_SizeDelta: {x: 400, y: 94.102486}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &13687162
MonoBehaviour:
@@ -10491,8 +10491,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 300, y: -280.0067}
m_SizeDelta: {x: 400, y: 560.0134}
m_AnchoredPosition: {x: 300, y: -289.90192}
m_SizeDelta: {x: 400, y: 579.80383}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &73634731
MonoBehaviour:
@@ -11588,8 +11588,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 200, y: -69.16998}
m_SizeDelta: {x: 400, y: 138.33997}
m_AnchoredPosition: {x: 200, y: -71.807495}
m_SizeDelta: {x: 400, y: 143.61499}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &80678997
MonoBehaviour:
@@ -14523,8 +14523,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 250, y: -432.6577}
m_SizeDelta: {x: 500, y: 560.0134}
m_AnchoredPosition: {x: 250, y: -408.7557}
m_SizeDelta: {x: 500, y: 579.80383}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &104905777
MonoBehaviour:
@@ -17846,8 +17846,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 835, y: -540}
m_SizeDelta: {x: 1170, y: 1080}
m_AnchoredPosition: {x: 1140, y: -540}
m_SizeDelta: {x: 1880, y: 1080}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &120552947
MonoBehaviour:
@@ -24513,8 +24513,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 125, y: 0}
m_SizeDelta: {x: 250, y: 1080}
m_AnchoredPosition: {x: 100, y: 0}
m_SizeDelta: {x: 200, y: 1080}
m_Pivot: {x: 0.5, y: 1}
--- !u!114 &164094084
MonoBehaviour:
@@ -24531,7 +24531,7 @@ MonoBehaviour:
m_IgnoreLayout: 0
m_MinWidth: -1
m_MinHeight: -1
m_PreferredWidth: 250
m_PreferredWidth: 200
m_PreferredHeight: -1
m_FlexibleWidth: 0
m_FlexibleHeight: 1
@@ -25976,8 +25976,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 41.67, y: -9.311031}
m_SizeDelta: {x: 9.34, y: 18.622063}
m_AnchoredPosition: {x: 41.67, y: -9.856724}
m_SizeDelta: {x: 9.34, y: 19.713448}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &176965333
MonoBehaviour:
@@ -27960,8 +27960,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 200, y: -183.66301}
m_SizeDelta: {x: 400, y: 90.64608}
m_AnchoredPosition: {x: 200, y: -190.66623}
m_SizeDelta: {x: 400, y: 94.102486}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &197596693
MonoBehaviour:
@@ -40899,8 +40899,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 50, y: -83.485794}
m_SizeDelta: {x: 100, y: 14.320574}
m_AnchoredPosition: {x: 50, y: -86.66917}
m_SizeDelta: {x: 100, y: 14.866628}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &311690665
MonoBehaviour:
@@ -57080,8 +57080,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 50, y: -38.162754}
m_SizeDelta: {x: 80, y: 76.32551}
m_AnchoredPosition: {x: 50, y: -39.617928}
m_SizeDelta: {x: 80, y: 79.235855}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &434123775
MonoBehaviour:
@@ -62598,8 +62598,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 28.331665, y: -29.29331}
m_SizeDelta: {x: 34.99, y: 8.586619}
m_AnchoredPosition: {x: 28.331665, y: -29.457016}
m_SizeDelta: {x: 34.99, y: 8.914034}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &472645058
MonoBehaviour:
@@ -77779,8 +77779,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 28.331665, y: -29.29331}
m_SizeDelta: {x: 34.99, y: 8.586619}
m_AnchoredPosition: {x: 28.331665, y: -29.457016}
m_SizeDelta: {x: 34.99, y: 8.914034}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &563565458
MonoBehaviour:
@@ -77939,8 +77939,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 211.66833, y: -21.79331}
m_SizeDelta: {x: 56.66333, y: 33.58662}
m_AnchoredPosition: {x: 211.66833, y: -21.957018}
m_SizeDelta: {x: 56.66333, y: 33.914036}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &563674213
MonoBehaviour:
@@ -81317,8 +81317,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 50, y: -38.162754}
m_SizeDelta: {x: 80, y: 76.32551}
m_AnchoredPosition: {x: 50, y: -39.617928}
m_SizeDelta: {x: 80, y: 79.235855}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &569915235
MonoBehaviour:
@@ -81633,8 +81633,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 200, y: -24.79331}
m_SizeDelta: {x: 400, y: 43.58662}
m_AnchoredPosition: {x: 200, y: -24.957018}
m_SizeDelta: {x: 400, y: 43.914036}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &572500977
MonoBehaviour:
@@ -87274,8 +87274,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 21, y: -9.311031}
m_SizeDelta: {x: 22, y: 18.622063}
m_AnchoredPosition: {x: 21, y: -9.856724}
m_SizeDelta: {x: 22, y: 19.713448}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &616739380
MonoBehaviour:
@@ -91248,8 +91248,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 268.33167, y: -21.79331}
m_SizeDelta: {x: 56.66333, y: 33.58662}
m_AnchoredPosition: {x: 268.33167, y: -21.957018}
m_SizeDelta: {x: 56.66333, y: 33.914036}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &653005024
MonoBehaviour:
@@ -97122,8 +97122,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 200, y: -126.4141}
m_SizeDelta: {x: 400, y: 23.851719}
m_AnchoredPosition: {x: 200, y: -131.23439}
m_SizeDelta: {x: 400, y: 24.761206}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &675359729
MonoBehaviour:
@@ -103291,8 +103291,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 155.005, y: -21.79331}
m_SizeDelta: {x: 56.66333, y: 33.58662}
m_AnchoredPosition: {x: 155.005, y: -21.957018}
m_SizeDelta: {x: 56.66333, y: 33.914036}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &716946128
MonoBehaviour:
@@ -108957,8 +108957,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 250, y: -896.3322}
m_SizeDelta: {x: 500, y: 367.33557}
m_AnchoredPosition: {x: 250, y: -889.3288}
m_SizeDelta: {x: 500, y: 381.3424}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &752509117
MonoBehaviour:
@@ -109433,8 +109433,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 50, y: -38.162754}
m_SizeDelta: {x: 80, y: 76.32551}
m_AnchoredPosition: {x: 50, y: -39.617928}
m_SizeDelta: {x: 80, y: 79.235855}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &756711068
MonoBehaviour:
@@ -117498,8 +117498,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 324.995, y: -21.79331}
m_SizeDelta: {x: 56.66333, y: 33.58662}
m_AnchoredPosition: {x: 324.995, y: -21.957018}
m_SizeDelta: {x: 56.66333, y: 33.914036}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &828107775
MonoBehaviour:
@@ -118295,7 +118295,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 374.16333, y: -21.79331}
m_AnchoredPosition: {x: 374.16333, y: -21.957018}
m_SizeDelta: {x: 41.673332, y: 20}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &838043085
@@ -124625,8 +124625,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 50, y: -38.162754}
m_SizeDelta: {x: 80, y: 76.32551}
m_AnchoredPosition: {x: 50, y: -39.617928}
m_SizeDelta: {x: 80, y: 79.235855}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &875425552
MonoBehaviour:
@@ -127894,8 +127894,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 50, y: -83.485794}
m_SizeDelta: {x: 100, y: 14.320574}
m_AnchoredPosition: {x: 50, y: -86.66917}
m_SizeDelta: {x: 100, y: 14.866628}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &893106133
MonoBehaviour:
@@ -134073,7 +134073,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 1670, y: -540}
m_AnchoredPosition: {x: 2330, y: -540}
m_SizeDelta: {x: 500, y: 1080}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &921718188
@@ -140441,8 +140441,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 105, y: -452.49}
m_SizeDelta: {x: 200, y: 30.01}
m_AnchoredPosition: {x: 100, y: -487.495}
m_SizeDelta: {x: 190, y: 30.01}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &964809175
MonoBehaviour:
@@ -146820,8 +146820,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 50, y: -280.0067}
m_SizeDelta: {x: 100, y: 560.0134}
m_AnchoredPosition: {x: 50, y: -289.90192}
m_SizeDelta: {x: 100, y: 579.80383}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1010256813
MonoBehaviour:
@@ -151227,8 +151227,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 350, y: -45.32304}
m_SizeDelta: {x: 100, y: 90.64608}
m_AnchoredPosition: {x: 350, y: -47.051243}
m_SizeDelta: {x: 100, y: 94.102486}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1034585958
MonoBehaviour:
@@ -154428,8 +154428,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 200, y: -273.7928}
m_SizeDelta: {x: 400, y: 133.56963}
m_AnchoredPosition: {x: 200, y: -282.66937}
m_SizeDelta: {x: 400, y: 138.66275}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1062810388
MonoBehaviour:
@@ -158727,7 +158727,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 65.83667, y: -21.79331}
m_AnchoredPosition: {x: 65.83667, y: -21.957018}
m_SizeDelta: {x: 121.67333, y: 18}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1092485181
@@ -165981,8 +165981,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 371.83002, y: -14.311031}
m_SizeDelta: {x: 56.34, y: 18.622063}
m_AnchoredPosition: {x: 371.83002, y: -14.856724}
m_SizeDelta: {x: 56.34, y: 19.713448}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1154149037
MonoBehaviour:
@@ -168546,8 +168546,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 21, y: -9.311031}
m_SizeDelta: {x: 22, y: 18.622063}
m_AnchoredPosition: {x: 21, y: -9.856724}
m_SizeDelta: {x: 22, y: 19.713448}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1171242893
MonoBehaviour:
@@ -169302,8 +169302,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 200, y: -57.24412}
m_SizeDelta: {x: 120, y: 114.48824}
m_AnchoredPosition: {x: 200, y: -59.426895}
m_SizeDelta: {x: 120, y: 118.85379}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1173952401
MonoBehaviour:
@@ -171795,7 +171795,6 @@ MonoBehaviour:
mainFont: {fileID: 11400000, guid: ec2736df0379a45bfa7349b652fd07d6, type: 2}
mapArea: {fileID: 1854993665}
textures: []
bridgeScale: 22
labelOutlineColor: {r: 0, g: 0, b: 0, a: 1}
labelOutlineWidth: 0.2
labelBackingColor: {r: 1, g: 1, b: 0.7411765, a: 1}
@@ -180291,8 +180290,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 28.331665, y: -29.29331}
m_SizeDelta: {x: 34.99, y: 8.586619}
m_AnchoredPosition: {x: 28.331665, y: -29.457016}
m_SizeDelta: {x: 34.99, y: 8.914034}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1256975681
MonoBehaviour:
@@ -181549,8 +181548,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 50, y: -38.162754}
m_SizeDelta: {x: 80, y: 76.32551}
m_AnchoredPosition: {x: 50, y: -39.617928}
m_SizeDelta: {x: 80, y: 79.235855}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1264915347
MonoBehaviour:
@@ -182482,8 +182481,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 48.69, y: -9.311031}
m_SizeDelta: {x: 23.38, y: 18.622063}
m_AnchoredPosition: {x: 48.69, y: -9.856724}
m_SizeDelta: {x: 23.38, y: 19.713448}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1271230046
MonoBehaviour:
@@ -185652,8 +185651,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 250, y: -45.32304}
m_SizeDelta: {x: 100, y: 90.64608}
m_AnchoredPosition: {x: 250, y: -47.051243}
m_SizeDelta: {x: 100, y: 94.102486}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1285014549
MonoBehaviour:
@@ -188751,8 +188750,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 200, y: -354.88864}
m_SizeDelta: {x: 400, y: 28.622063}
m_AnchoredPosition: {x: 200, y: -366.85745}
m_SizeDelta: {x: 400, y: 29.713448}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1313226903
MonoBehaviour:
@@ -189165,8 +189164,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 5, y: -213.7425}
m_SizeDelta: {x: 0, y: 427.485}
m_AnchoredPosition: {x: 5, y: -251.25}
m_SizeDelta: {x: 0, y: 422.47998}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1315206790
MonoBehaviour:
@@ -191617,10 +191616,10 @@ RectTransform:
m_Children: []
m_Father: {fileID: 921718187}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 0, y: -19.081375}
m_SizeDelta: {x: 0, y: 38.16275}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 100, y: 100}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1338589763
MonoBehaviour:
@@ -191679,8 +191678,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 200, y: -128.2973}
m_SizeDelta: {x: 400, y: 157.42136}
m_AnchoredPosition: {x: 200, y: -131.62602}
m_SizeDelta: {x: 400, y: 163.42397}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1339265654
MonoBehaviour:
@@ -198097,8 +198096,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 136.64, y: -14.311031}
m_SizeDelta: {x: 273.28, y: 18.622063}
m_AnchoredPosition: {x: 136.64, y: -14.856724}
m_SizeDelta: {x: 273.28, y: 19.713448}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1384218058
MonoBehaviour:
@@ -209537,8 +209536,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 50, y: -83.485794}
m_SizeDelta: {x: 100, y: 14.320574}
m_AnchoredPosition: {x: 50, y: -86.66917}
m_SizeDelta: {x: 100, y: 14.866628}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1445542184
MonoBehaviour:
@@ -213833,8 +213832,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 50, y: -38.162754}
m_SizeDelta: {x: 80, y: 76.32551}
m_AnchoredPosition: {x: 50, y: -39.617928}
m_SizeDelta: {x: 80, y: 79.235855}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1476057067
MonoBehaviour:
@@ -219616,8 +219615,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 200, y: -69.16998}
m_SizeDelta: {x: 400, y: 138.33997}
m_AnchoredPosition: {x: 200, y: -71.807495}
m_SizeDelta: {x: 400, y: 143.61499}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1515258299
MonoBehaviour:
@@ -224394,8 +224393,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 50, y: -45.32304}
m_SizeDelta: {x: 100, y: 90.64608}
m_AnchoredPosition: {x: 50, y: -47.051243}
m_SizeDelta: {x: 100, y: 94.102486}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1534189738
MonoBehaviour:
@@ -229132,8 +229131,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 350, y: -45.32304}
m_SizeDelta: {x: 100, y: 90.64608}
m_AnchoredPosition: {x: 350, y: -47.051243}
m_SizeDelta: {x: 100, y: 94.102486}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1555765162
MonoBehaviour:
@@ -234044,8 +234043,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 50, y: -45.32304}
m_SizeDelta: {x: 100, y: 90.64608}
m_AnchoredPosition: {x: 50, y: -47.051243}
m_SizeDelta: {x: 100, y: 94.102486}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1594383387
MonoBehaviour:
@@ -236796,8 +236795,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 200, y: -343.48386}
m_SizeDelta: {x: 400, y: 47.703438}
m_AnchoredPosition: {x: 200, y: -356.58115}
m_SizeDelta: {x: 400, y: 49.52241}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1615139386
MonoBehaviour:
@@ -240920,8 +240919,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 250, y: -95.406876}
m_SizeDelta: {x: 500, y: 114.48825}
m_AnchoredPosition: {x: 250, y: -59.426895}
m_SizeDelta: {x: 500, y: 118.85379}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1645185338
MonoBehaviour:
@@ -241709,8 +241708,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 125, y: -507.5}
m_SizeDelta: {x: 240, y: 60.01}
m_AnchoredPosition: {x: 100, y: -527.505}
m_SizeDelta: {x: 190, y: 30.01}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1658453644
MonoBehaviour:
@@ -241772,7 +241771,7 @@ MonoBehaviour:
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_text: Round 1, Thunderstorm
m_text: Round 1
m_isRightToLeft: 0
m_fontAsset: {fileID: 11400000, guid: ec2736df0379a45bfa7349b652fd07d6, type: 2}
m_sharedMaterial: {fileID: 1026247389042360723, guid: ec2736df0379a45bfa7349b652fd07d6,
@@ -243466,8 +243465,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 50, y: -83.485794}
m_SizeDelta: {x: 100, y: 14.320574}
m_AnchoredPosition: {x: 50, y: -86.66917}
m_SizeDelta: {x: 100, y: 14.866628}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1675788065
MonoBehaviour:
@@ -247278,7 +247277,7 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::eagle.ProvinceBorderManager
provinceMapMaterial: {fileID: 2100000, guid: 2f6dd8226ea6b4be4936cb7658b59fb4, type: 2}
defaultBorderWidth: 0.5
defaultBorderWidth: 0.7
defaultBorderColor: {r: 0.15, g: 0.1, b: 0.05, a: 0.8}
--- !u!1 &1712004404
GameObject:
@@ -252552,8 +252551,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 308.47, y: -14.311031}
m_SizeDelta: {x: 70.38, y: 18.622063}
m_AnchoredPosition: {x: 308.47, y: -14.856724}
m_SizeDelta: {x: 70.38, y: 19.713448}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1754933203
MonoBehaviour:
@@ -255033,7 +255032,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 250, y: -712.6644}
m_AnchoredPosition: {x: 250, y: -698.6576}
m_SizeDelta: {x: 500, y: 0}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1776620757
@@ -258758,8 +258757,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 50, y: -83.485794}
m_SizeDelta: {x: 100, y: 14.320574}
m_AnchoredPosition: {x: 50, y: -86.66917}
m_SizeDelta: {x: 100, y: 14.866628}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1809783484
MonoBehaviour:
@@ -260702,8 +260701,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 50, y: -83.485794}
m_SizeDelta: {x: 100, y: 14.320574}
m_AnchoredPosition: {x: 50, y: -86.66917}
m_SizeDelta: {x: 100, y: 14.866628}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1822541216
MonoBehaviour:
@@ -263927,8 +263926,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 587.5, y: -537.5}
m_SizeDelta: {x: 1165, y: 1075}
m_AnchoredPosition: {x: 942.5, y: -537.5}
m_SizeDelta: {x: 1875, y: 1075}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1854993666
MonoBehaviour:
@@ -266725,8 +266724,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 150, y: -45.32304}
m_SizeDelta: {x: 100, y: 90.64608}
m_AnchoredPosition: {x: 150, y: -47.051243}
m_SizeDelta: {x: 100, y: 94.102486}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1875155214
MonoBehaviour:
@@ -269018,8 +269017,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 200, y: -24.79331}
m_SizeDelta: {x: 400, y: 49.58662}
m_AnchoredPosition: {x: 200, y: -24.957018}
m_SizeDelta: {x: 400, y: 49.914036}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1893805460
MonoBehaviour:
@@ -269361,8 +269360,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 50, y: -83.485794}
m_SizeDelta: {x: 100, y: 14.320574}
m_AnchoredPosition: {x: 50, y: -86.66917}
m_SizeDelta: {x: 100, y: 14.866628}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1895539477
MonoBehaviour:
@@ -270033,8 +270032,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 300, y: -183.66779}
m_SizeDelta: {x: 400, y: 367.33557}
m_AnchoredPosition: {x: 300, y: -190.6712}
m_SizeDelta: {x: 400, y: 381.3424}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1902923752
MonoBehaviour:
@@ -273147,8 +273146,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 105, y: -1029.995}
m_SizeDelta: {x: 200, y: 90.01}
m_AnchoredPosition: {x: 100, y: -1029.995}
m_SizeDelta: {x: 190, y: 90.01}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1927059661
MonoBehaviour:
@@ -276820,8 +276819,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 50, y: -38.162754}
m_SizeDelta: {x: 80, y: 76.32551}
m_AnchoredPosition: {x: 50, y: -39.617928}
m_SizeDelta: {x: 80, y: 79.235855}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1958376224
MonoBehaviour:
@@ -283011,8 +283010,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 50, y: -38.162754}
m_SizeDelta: {x: 80, y: 76.32551}
m_AnchoredPosition: {x: 50, y: -39.617928}
m_SizeDelta: {x: 80, y: 79.235855}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &2003668274
MonoBehaviour:
@@ -287651,8 +287650,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 5, y: -761.2475}
m_SizeDelta: {x: 0, y: 427.485}
m_AnchoredPosition: {x: 5, y: -763.75}
m_SizeDelta: {x: 0, y: 422.47998}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &2049862804
MonoBehaviour:
@@ -288822,8 +288821,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 50, y: -83.485794}
m_SizeDelta: {x: 100, y: 14.320574}
m_AnchoredPosition: {x: 50, y: -86.66917}
m_SizeDelta: {x: 100, y: 14.866628}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &2063183115
MonoBehaviour:
@@ -290085,8 +290084,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 28.331665, y: -29.29331}
m_SizeDelta: {x: 34.99, y: 8.586619}
m_AnchoredPosition: {x: 28.331665, y: -29.457016}
m_SizeDelta: {x: 34.99, y: 8.914034}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &2076969231
MonoBehaviour:
@@ -293744,8 +293743,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 250, y: -45.32304}
m_SizeDelta: {x: 100, y: 90.64608}
m_AnchoredPosition: {x: 250, y: -47.051243}
m_SizeDelta: {x: 100, y: 94.102486}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &2092022490
MonoBehaviour:
@@ -294732,8 +294731,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 200, y: -464.60657}
m_SizeDelta: {x: 400, y: 190.81375}
m_AnchoredPosition: {x: 200, y: -480.759}
m_SizeDelta: {x: 400, y: 198.08965}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &2100095381
MonoBehaviour:
@@ -297245,8 +297244,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 150, y: -45.32304}
m_SizeDelta: {x: 100, y: 90.64608}
m_AnchoredPosition: {x: 150, y: -47.051243}
m_SizeDelta: {x: 100, y: 94.102486}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &2107423179
MonoBehaviour:
@@ -17,8 +17,8 @@
{
"speakerName": "Old Marek the Learned",
"speakerImagePath": "fixed/old_marek_the_learned.png",
"dialogueText": "Let me take stock. We have <b>light infantry</b> and <b>longbowmen</b> \u2014 not much, but the longbows can strike from range and they're deadly against Tarn's armored <b>knights</b> and <b>heavy infantry</b>. And he's brought <b>dragoons</b> \u2014 fast cavalry, hard to pin down. We'll need every advantage we can get.",
"instructionText": "<sprite name=\"LightInfantry\"> <b>Light Infantry</b> \u2014 Fast, lightly armored\n<sprite name=\"Longbowmen\"> <b>Longbowmen</b> \u2014 Ranged attack, strong vs. armor\n<sprite name=\"HeavyInfantry\"> <b>Heavy Infantry</b> \u2014 Slow, heavily armored (enemy)\n<sprite name=\"HeavyCavalry\"> <b>Knights</b> \u2014 Mounted, powerful charge (enemy)\n<sprite name=\"LightCavalry\"> <b>Dragoons</b> \u2014 Fast cavalry, charge and reposition (enemy)",
"dialogueText": "Let me take stock. We have <b>light infantry</b> and <b>longbowmen</b> \u2014 not much, but the longbows can strike from range and they're deadly against Tarn's armored <b>knights</b> and <b>heavy infantry</b>. We'll need them.",
"instructionText": "<sprite name=\"LightInfantry\"> <b>Light Infantry</b> \u2014 Fast, lightly armored\n<sprite name=\"Longbowmen\"> <b>Longbowmen</b> \u2014 Ranged attack, strong vs. armor\n<sprite name=\"HeavyInfantry\"> <b>Heavy Infantry</b> \u2014 Slow, heavily armored (enemy)\n<sprite name=\"HeavyCavalry\"> <b>Knights</b> \u2014 Mounted, powerful charge (enemy)",
"highlightTarget": null,
"highlightProvince": null,
"completionEvent": null
@@ -138,49 +138,6 @@
"completionEvent": null
}
]
},
{
"id": "tutorial_reinforcements",
"trigger": "tutorial_reinforcements_arrived",
"panelPosition": "top",
"steps": [
{
"speakerName": "Old Marek the Learned",
"speakerImagePath": "fixed/old_marek_the_learned.png",
"dialogueText": "Ah \u2014 Ranil, Elena, Hedrick. I wasn't sure you'd come. Desperate times, it seems, make for unlikely alliances.",
"instructionText": null,
"highlightTarget": null,
"highlightProvince": null,
"completionEvent": null
},
{
"speakerName": "John Ranil",
"speakerImagePath": null,
"dialogueText": "I sat out as long as I could, Sadar. Tried to keep my people safe and stay out of it. But Tarn's forces pushed through my province last month and didn't much care who was in the way. So here I am \u2014 and I brought my tools. Point me at something that needs building or breaking.",
"instructionText": "<sprite name=\"Engineer\"> <b>Engineer</b> \u2014 Can repair or build bridges and castles, and fortify a position. While fortified, can bombard enemy-held castles from range.",
"highlightTarget": null,
"highlightProvince": null,
"completionEvent": null
},
{
"speakerName": "Elena Fyar",
"speakerImagePath": null,
"dialogueText": "I warned my father about Tarn years ago, and no one listened. Well \u2014 here we are. I've brought what's left of the Order with me. If your men are wounded, I can help with that. And if Tarn has brought anything... unnatural... I can help with that too.",
"instructionText": "<sprite name=\"Paladin\"> <b>Paladin</b> \u2014 Can unleash a Holy Wave that restores vigor to adjacent friendly units and destroys any undead among them.",
"highlightTarget": null,
"highlightProvince": null,
"completionEvent": null
},
{
"speakerName": "Hedrick the Hedge-merchant",
"speakerImagePath": null,
"dialogueText": "I was trying to sell shrubberies in Ingia when your man found me, Marek. Business has been terrible \u2014 turns out nobody buys decorative hedges during a civil war. But I know every back trail and hollow between here and the coast, and I know how to make myself scarce when I need to.",
"instructionText": "<sprite name=\"Ranger\"> <b>Ranger</b> \u2014 Can hide in forests and swamps, ambush enemies emerging from concealment, and scout to reveal hidden enemy positions.",
"highlightTarget": null,
"highlightProvince": null,
"completionEvent": null
}
]
}
]
}
@@ -7,7 +7,7 @@
{
"speakerName": "Old Marek the Learned",
"speakerImagePath": "fixed/old_marek_the_learned.png",
"dialogueText": "Sadar Rakon. I had hoped we would never see this day.\n\nYou were once Ikhaan Tarn's most trusted lieutenant \u2014 the right hand of the Shardok himself. But over the past year, Tarn's behavior grew ever more erratic. The paranoia, the inscrutable orders, those strange training exercises no one could explain... and King Bregos would hear none of it.\n\nSo you did what had to be done. You and a handful of others raised the banner of the Reclamation \u2014 not against the Crown, but against the man who wields its sword and can no longer be trusted with it.",
"dialogueText": "Sadar Rakon. I had hoped we would never see this day.\n\nYou were once Ikhaan Tarn's most trusted lieutenant \u2014 the right hand of the Shardok himself. But over the past year, Tarn's behavior grew ever more erratic. The cruelty, the paranoia... and King Bregos would hear none of it.\n\nSo you did what had to be done. You and a handful of others raised the banner of the Reclamation \u2014 not against the Crown, but against the madman who wields its sword.",
"instructionText": null,
"highlightTarget": null,
"completionEvent": null
@@ -66,9 +66,6 @@ public class HexGrid : MonoBehaviour {
public Texture[] textures;
[Header("3D Object Settings")]
public float bridgeScale = 22f;
[Header("Label Settings")]
public Color labelOutlineColor = Color.white;
public float labelOutlineWidth = 0.25f;
@@ -797,6 +794,7 @@ public class HexGrid : MonoBehaviour {
Vector2 cellCenter = cell.Geometry.AnchoredPosition;
GameObject newObject = Instantiate(original: prefab, parent: gridTransform);
newObject.transform.localPosition = new Vector3(x: cellCenter.x, y: cellCenter.y, z: -5f);
float bridgeScale = 15f;
newObject.transform.localScale =
new Vector3(x: bridgeScale, y: bridgeScale, z: bridgeScale);
// rotationDegrees is already the X rotation (0, 56, or 124)
@@ -33,7 +33,6 @@ public class ShardokGameModel {
private readonly Dictionary<HeroId, string> _heroNameTextIds;
private readonly List<HeroNameListener> _heroNameListeners = new();
private readonly Func<HeroId, (string nameTextId, string imagePath)?> _heroFallbackLookup;
public struct PlayerWithHostility {
public PlayerId playerId;
@@ -57,34 +56,6 @@ public class ShardokGameModel {
return textId;
}
/// <summary>
/// Looks up hero name text ID and headshot path, falling back to the eagle model
/// if the hero wasn't known at battle creation (e.g. mid-battle reinforcements).
/// Caches the result locally and registers a ClientTextProvider listener.
/// </summary>
private (string heroNameTextId, string headshotPath) LookupHero(HeroId hid) {
var nameTextId = GetHeroNameTextId(hid);
HeroImages.TryGetValue(hid, out var imagePath);
if (nameTextId == null && imagePath == null && _heroFallbackLookup != null) {
var fallback = _heroFallbackLookup(hid);
if (fallback.HasValue) {
nameTextId = fallback.Value.nameTextId;
imagePath = fallback.Value.imagePath;
if (nameTextId != null) {
_heroNameTextIds[hid] = nameTextId;
var listener = new HeroNameListener(nameTextId, this);
_heroNameListeners.Add(listener);
ClientTextProvider.Provider.AddListener(listener);
}
if (imagePath != null) { HeroImages[hid] = imagePath; }
}
}
return (nameTextId, imagePath);
}
public void CleanupListeners() {
foreach (var listener in _heroNameListeners) {
ClientTextProvider.Provider.RemoveListener(listener);
@@ -178,8 +149,7 @@ public class ShardokGameModel {
List<PlayerWithHostility> players,
Dictionary<HeroId, String> heroNameTextIds,
Dictionary<HeroId, String> heroImages,
Dictionary<BattalionId, String> battalionNames,
Func<HeroId, (string nameTextId, string imagePath)?> heroFallbackLookup = null) {
Dictionary<BattalionId, String> battalionNames) {
EagleGameId = eagleGameId;
ShardokGameId = shardokGameId;
History = new List<ActionResultView>(StartingHistoryCapacity);
@@ -196,7 +166,6 @@ public class ShardokGameModel {
_heroNameTextIds = heroNameTextIds;
HeroImages = heroImages;
BattalionNames = battalionNames;
_heroFallbackLookup = heroFallbackLookup;
UnitPlayers = new Dictionary<UnitId, PlayerId>();
PlayerTotals = players.Select(p => new PlayerTotals { PlayerId = p.playerId }).ToList();
@@ -453,7 +422,8 @@ public class ShardokGameModel {
string heroNameTextId = null;
string headshotPath = null;
if (changedUnit.AttachedHero?.EagleHeroId is HeroId hid) {
(heroNameTextId, headshotPath) = LookupHero(hid);
heroNameTextId = GetHeroNameTextId(hid);
HeroImages.TryGetValue(hid, out headshotPath);
}
string battalionName = null;
@@ -487,7 +457,8 @@ public class ShardokGameModel {
string heroNameTextId = null;
string headshotPath = null;
if (changedReserveUnit.AttachedHero?.EagleHeroId is HeroId hid) {
(heroNameTextId, headshotPath) = LookupHero(hid);
heroNameTextId = GetHeroNameTextId(hid);
HeroImages.TryGetValue(hid, out headshotPath);
}
string battalionName = null;
@@ -1,9 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
using eagle;
using eagle0;
using Shardok;
using UnityEngine;
using UnityEngine.UI;
@@ -30,11 +26,9 @@ namespace Eagle0.Tutorial {
private DialogueScript _activeScript;
private int _currentStepIndex;
private MapController _mapController;
private ShardokGameController _shardokController;
private GameObject _activeHighlight;
private readonly List<Image> _highlightEdges = new List<Image>();
private readonly HashSet<string> _completedScriptIds = new HashSet<string>();
private readonly Queue<string> _pendingTriggers = new Queue<string>();
/// <summary>
/// Whether a dialogue is currently being displayed.
@@ -62,14 +56,6 @@ namespace Eagle0.Tutorial {
_mapController = mapController;
}
/// <summary>
/// Set the Shardok controller reference for resolving hero headshots.
/// Called from TutorialManager.Initialize().
/// </summary>
public void SetShardokController(ShardokGameController shardokController) {
_shardokController = shardokController;
}
private void LoadAllScripts() {
var jsonFiles = Resources.LoadAll<TextAsset>("Dialogues");
foreach (var file in jsonFiles) {
@@ -90,21 +76,11 @@ namespace Eagle0.Tutorial {
/// <summary>
/// Fires a trigger. If any loaded dialogue script matches, plays the first one.
/// Only fires in tutorial games. If a dialogue is already active, queues the trigger.
/// Only fires in tutorial games.
/// </summary>
public void TriggerDialogue(string trigger) {
if (!TutorialManager.IsTutorialGame) return;
if (!_triggerMap.ContainsKey(trigger)) return;
if (_activeScript != null) {
_pendingTriggers.Enqueue(trigger);
return;
}
PlayTrigger(trigger);
}
private void PlayTrigger(string trigger) {
if (_activeScript != null) return;
if (!_triggerMap.TryGetValue(trigger, out var scripts)) return;
foreach (var script in scripts) {
@@ -131,16 +107,6 @@ namespace Eagle0.Tutorial {
var step = _activeScript.steps[_currentStepIndex];
// Resolve speaker info from game model when headshot not specified in JSON
if (string.IsNullOrEmpty(step.speakerImagePath) &&
!string.IsNullOrEmpty(step.speakerName)) {
var resolved = ResolveSpeaker(step.speakerName);
if (resolved.HasValue) {
step.speakerImagePath = resolved.Value.headshotPath;
step.speakerName = resolved.Value.displayName;
}
}
panelController.Show(step);
// Wire continue button
@@ -168,24 +134,6 @@ namespace Eagle0.Tutorial {
}
}
/// <summary>
/// Looks up a hero's display name and headshot path by name (case-insensitive)
/// from the active Shardok game model. Returns null if no match is found.
/// </summary>
private (string displayName, string headshotPath)? ResolveSpeaker(string speakerName) {
var model = _shardokController?.Model;
if (model == null) return null;
var unit = model.UnitsById.Values.Concat(model.ReserveUnitsById.Values)
.FirstOrDefault(
u => string.Equals(
u.Name,
speakerName,
StringComparison.OrdinalIgnoreCase));
if (unit == null) return null;
return (unit.Name, unit.HeadshotPath);
}
/// <summary>
/// Advances to the next dialogue step. Called by the continue button.
/// </summary>
@@ -221,13 +169,6 @@ namespace Eagle0.Tutorial {
ClearHighlight();
_mapController?.ClearProvinceHighlightProxy();
// Play next queued dialogue if any
while (_pendingTriggers.Count > 0) {
var next = _pendingTriggers.Dequeue();
PlayTrigger(next);
if (_activeScript != null) break;
}
}
// ========== Highlight as child of target ==========
@@ -68,8 +68,6 @@ namespace Eagle0.Tutorial {
ResourceFetcher.headshotFetcher.LoadIntoRawImage(
speakerHeadshot,
step.speakerImagePath);
} else {
speakerHeadshot.texture = null;
}
// Show/hide instruction section
@@ -554,11 +554,6 @@ namespace Eagle0.Tutorial {
// Combat tutorials
case ActionType.ChargeAttack: OnGameEvent("ability_charge_used", result); break;
// Reinforcement tutorial
case ActionType.TutorialReinforcementsArrived:
OnGameEvent("tutorial_reinforcements_arrived", result);
break;
// Terrain tutorials
case ActionType.FireDamage:
case ActionType.FireSpread: OnGameEvent("terrain_fire_encountered", result); break;
@@ -591,17 +586,14 @@ namespace Eagle0.Tutorial {
case CommandType.RaiseDeadCommand:
OnGameEvent("spell_raisedead_available");
break;
case CommandType.MoveCommand:
if (cmd.FollowUpCommandTypes.Contains(CommandType.ChargeCommand)) {
OnGameEvent("ability_charge_available");
}
break;
case CommandType.ChargeCommand: OnGameEvent("ability_charge_available"); break;
case CommandType.ArcheryCommand: OnGameEvent("archery_available"); break;
case CommandType.MeleeCommand: OnGameEvent("melee_available"); break;
case CommandType.StartFireCommand:
// Only trigger when the target cell has an enemy unit
var targetUnit = model.UnitAtCoords(cmd.Target);
if (targetUnit != null &&
targetUnit.HostilityStatus == Hostility.EnemyHostility) {
targetUnit.HostilityStatus != Hostility.SelfHostility) {
OnGameEvent("start_fire_available");
}
break;
@@ -120,7 +120,6 @@ namespace Eagle0.Tutorial {
public void Initialize(EagleGameController eagle, ShardokGameController shardok) {
_triggerRegistry?.Initialize(eagle, shardok);
DialogueManager?.SetMapController(eagle?.mapController);
DialogueManager?.SetShardokController(shardok);
Log($"TutorialManager initialized with controllers - Eagle: {eagle != null}, Shardok: {shardok != null}");
// Reset tutorial progress when entering a tutorial game so onboarding
@@ -6,29 +6,29 @@ EditorUserSettings:
serializedVersion: 4
m_ConfigSettings:
RecentlyUsedSceneGuid-0:
value: 540206545d510c590f595d2143770f45444f1b73757e72357c791836e7e5373a
flags: 0
RecentlyUsedSceneGuid-1:
value: 06525702540c0f595c57547545770c48104e487d7d787062787e4864b7b7606b
flags: 0
RecentlyUsedSceneGuid-2:
value: 5a000356000d085a555c0d7343770b4241151e2b7e7070647f2d4e35bae46339
flags: 0
RecentlyUsedSceneGuid-3:
value: 0507035e5c0558095e5a5974477b064047454b7e2d2a2534282f4d62b0b3606d
flags: 0
RecentlyUsedSceneGuid-4:
value: 0703555650055e0b58565d7a41770643441641797b7b7263757a4931b1e46c6a
flags: 0
RecentlyUsedSceneGuid-5:
value: 015207020753505e590c597411770a4215164d282a7e7633757f496be4b0316c
flags: 0
RecentlyUsedSceneGuid-6:
value: 5b02025f520c5a5f0b0d552640770c4946151a2e7d7a75697c7e4f66e1b2306c
flags: 0
RecentlyUsedSceneGuid-7:
RecentlyUsedSceneGuid-1:
value: 540206545d510c590f595d2143770f45444f1b73757e72357c791836e7e5373a
flags: 0
RecentlyUsedSceneGuid-2:
value: 06525702540c0f595c57547545770c48104e487d7d787062787e4864b7b7606b
flags: 0
RecentlyUsedSceneGuid-3:
value: 5a000356000d085a555c0d7343770b4241151e2b7e7070647f2d4e35bae46339
flags: 0
RecentlyUsedSceneGuid-4:
value: 0507035e5c0558095e5a5974477b064047454b7e2d2a2534282f4d62b0b3606d
flags: 0
RecentlyUsedSceneGuid-5:
value: 0703555650055e0b58565d7a41770643441641797b7b7263757a4931b1e46c6a
flags: 0
RecentlyUsedSceneGuid-6:
value: 51070104510d505f5c565e2314750e44134e4c7a2a2a71357c714d62b0e2643c
flags: 0
RecentlyUsedSceneGuid-7:
value: 015207020753505e590c597411770a4215164d282a7e7633757f496be4b0316c
flags: 0
RecentlyUsedScenePath-0:
value: 224247031146467c0c0309321c22465e0319113e35
flags: 0
@@ -2,12 +2,9 @@ load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
go_library(
name = "admin_server_lib",
srcs = [
"admin_server.go",
"map_editor.go",
],
srcs = ["admin_server.go"],
embedsrcs = glob([
"static/**",
"static/*",
"templates/*.html",
]),
importpath = "github.com/nolen777/eagle0/src/main/go/net/eagle0/admin_server",
@@ -14,7 +14,6 @@ import (
"io"
"io/fs"
"log"
"net"
"net/http"
"os"
"path/filepath"
@@ -36,7 +35,7 @@ import (
//go:embed templates/*.html
var templatesFS embed.FS
//go:embed all:static
//go:embed static/*
var staticFS embed.FS
// Build info variables - set via ldflags
@@ -58,15 +57,12 @@ func getBuildInfo() BuildInfo {
}
}
var eagleContainerName string
var (
eagleAddr = flag.String("eagle-addr", "localhost:40032", "Eagle gRPC server address")
authAddr = flag.String("auth-addr", "localhost:40033", "Auth gRPC server address")
authTLS = flag.Bool("auth-tls", false, "Use TLS for auth server connection (required for remote auth servers)")
jfrSidecarAddr = flag.String("jfr-sidecar-addr", "localhost:8081", "JFR sidecar HTTP address")
httpPort = flag.Int("http-port", 8080, "HTTP server port")
mapsFlag = flag.String("maps-dir", "", "Directory containing .e0mj map files (enables map editor)")
gameAdminClient gameadminpb.GameAdminClient
adminClient adminpb.AdminClient
authClient authpb.AuthClient
@@ -179,10 +175,6 @@ func main() {
log.Fatalf("Failed to parse llm_stats template: %v", err)
}
// Extract container name from eagle-addr (e.g. "eagle-blue:40032" -> "eagle-blue")
eagleContainerName = strings.Split(*eagleAddr, ":")[0]
log.Printf("Eagle container name: %s", eagleContainerName)
// Log configuration at startup
log.Printf("Configuration: eagle-addr=%s, auth-addr=%s, auth-tls=%v", *eagleAddr, *authAddr, *authTLS)
@@ -226,12 +218,6 @@ func main() {
}
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticContent))))
// Map editor (optional, requires --maps-dir flag)
if *mapsFlag != "" {
initMapEditor(*mapsFlag)
log.Printf("Map editor enabled, serving maps from %s", *mapsFlag)
}
// Set up HTTP routes
// All admin routes require authentication except login/logout/health
// accounts.eagle0.net routes for user self-service
@@ -264,7 +250,6 @@ func main() {
http.HandleFunc("/jfr/start", requireAuth(handleJfrStart))
http.HandleFunc("/jfr/stop", requireAuth(handleJfrStop))
http.HandleFunc("/jfr/download", requireAuth(handleJfrDownload))
http.HandleFunc("/server/restart", requireAuth(handleEagleRestart))
http.HandleFunc("/health", handleHealth)
http.HandleFunc("/health/status", handleHealthStatus)
http.HandleFunc("/notify-update", handleNotifyUpdate)
@@ -370,58 +355,6 @@ func jfrDisabled() bool {
return *jfrSidecarAddr == ""
}
// dockerClient returns an HTTP client that communicates via the Docker Unix socket.
func dockerClient() *http.Client {
return &http.Client{
Transport: &http.Transport{
DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
return net.Dial("unix", "/var/run/docker.sock")
},
},
Timeout: 60 * time.Second,
}
}
// handleEagleRestart restarts the Eagle container via the Docker Engine API.
func handleEagleRestart(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
log.Printf("Eagle restart requested from %s", r.RemoteAddr)
client := dockerClient()
url := fmt.Sprintf("http://localhost/containers/%s/restart?t=30", eagleContainerName)
req, err := http.NewRequest(http.MethodPost, url, nil)
if err != nil {
log.Printf("Failed to create restart request: %v", err)
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, `<span class="error">Error: %s</span>`, template.HTMLEscapeString(err.Error()))
return
}
resp, err := client.Do(req)
if err != nil {
log.Printf("Failed to restart Eagle container: %v", err)
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, `<span class="error">Docker unavailable: %s</span>`, template.HTMLEscapeString(err.Error()))
return
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNoContent {
log.Printf("Eagle container %s restart initiated successfully", eagleContainerName)
w.Header().Set("Content-Type", "text/html")
fmt.Fprint(w, `<span class="success">Restarting...</span>`)
} else {
body, _ := io.ReadAll(resp.Body)
log.Printf("Docker API error restarting Eagle: status=%d body=%s", resp.StatusCode, body)
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, `<span class="error">Restart failed (HTTP %d)</span>`, resp.StatusCode)
}
}
// handleJfrStatus proxies a JFR status request to the JFR sidecar container
func handleJfrStatus(w http.ResponseWriter, r *http.Request) {
if jfrDisabled() {
@@ -1,428 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"html/template"
"log"
"net/http"
"os"
"path/filepath"
"strings"
)
// Map data structures matching the .e0mj JSON schema
type StructureIntegrity struct {
Integrity float64 `json:"integrity"`
}
type TerrainModifier struct {
Castle *StructureIntegrity `json:"castle,omitempty"`
Bridge *StructureIntegrity `json:"bridge,omitempty"`
}
type TerrainTile struct {
Type string `json:"type"`
Modifier *TerrainModifier `json:"modifier,omitempty"`
}
type HexPosition struct {
Row *int `json:"row,omitempty"`
Column *int `json:"column,omitempty"`
}
type StartingPositions struct {
Positions []HexPosition `json:"positions,omitempty"`
}
type WeatherData struct {
AverageTemperature *int `json:"averageTemperature,omitempty"`
SunChance *int `json:"sunChance,omitempty"`
CloudsChance *int `json:"cloudsChance,omitempty"`
RainChance *int `json:"rainChance,omitempty"`
SnowChance *int `json:"snowChance,omitempty"`
ThunderstormChance *int `json:"thunderstormChance,omitempty"`
BlizzardChance *int `json:"blizzardChance,omitempty"`
MaxWindMph *int `json:"maxWindMph,omitempty"`
}
type MapFile struct {
RowCount int `json:"rowCount"`
ColumnCount int `json:"columnCount"`
Terrain []TerrainTile `json:"terrain"`
DefenderStartingPositions *StartingPositions `json:"defenderStartingPositions"`
AttackerStartingPositions []StartingPositions `json:"attackerStartingPositions"`
MonthlyWeather []WeatherData `json:"monthlyWeather"`
}
// Template and page data
var (
mapsDir string
mapsTemplate *template.Template
mapEditorTemplate *template.Template
)
type MapsPageData struct {
Title string
BuildInfo BuildInfo
Maps []string
}
type MapEditorPageData struct {
Title string
BuildInfo BuildInfo
MapName string
}
func initMapEditor(dir string) {
mapsDir = dir
var err error
mapsTemplate, err = template.ParseFS(templatesFS, "templates/layout.html", "templates/maps.html")
if err != nil {
log.Fatalf("Failed to parse maps template: %v", err)
}
mapEditorTemplate, err = template.ParseFS(templatesFS, "templates/layout.html", "templates/map_editor.html")
if err != nil {
log.Fatalf("Failed to parse map_editor template: %v", err)
}
http.HandleFunc("/maps", requireAuth(handleMapsPage))
http.HandleFunc("/maps/", requireAuth(handleMapEditorRoutes))
http.HandleFunc("/api/maps", requireAuth(handleMapsAPI))
http.HandleFunc("/api/maps/", requireAuth(handleMapAPI))
}
// Validation
var validTerrainTypes = map[string]bool{
"PLAINS": true,
"FOREST": true,
"HILL": true,
"MOUNTAIN": true,
"RIVER": true,
"SWAMP": true,
"STILL_WATER": true,
}
func validateMap(m *MapFile) error {
expectedTiles := m.RowCount * m.ColumnCount
if len(m.Terrain) != expectedTiles {
return fmt.Errorf("expected %d terrain tiles (%dx%d), got %d", expectedTiles, m.RowCount, m.ColumnCount, len(m.Terrain))
}
for i, tile := range m.Terrain {
if !validTerrainTypes[tile.Type] {
return fmt.Errorf("tile %d: invalid terrain type %q", i, tile.Type)
}
if tile.Modifier != nil {
if tile.Modifier.Castle != nil && tile.Modifier.Castle.Integrity <= 0 {
return fmt.Errorf("tile %d: castle integrity must be > 0", i)
}
if tile.Modifier.Bridge != nil && tile.Modifier.Bridge.Integrity <= 0 {
return fmt.Errorf("tile %d: bridge integrity must be > 0", i)
}
}
}
if m.DefenderStartingPositions != nil {
if len(m.DefenderStartingPositions.Positions) != 10 {
return fmt.Errorf("defender must have exactly 10 starting positions, got %d", len(m.DefenderStartingPositions.Positions))
}
for i, pos := range m.DefenderStartingPositions.Positions {
if err := validatePosition(pos, m.RowCount, m.ColumnCount, "defender", i); err != nil {
return err
}
}
}
if len(m.AttackerStartingPositions) != 8 {
return fmt.Errorf("must have exactly 8 attacker groups, got %d", len(m.AttackerStartingPositions))
}
for g, group := range m.AttackerStartingPositions {
if len(group.Positions) != 0 && len(group.Positions) != 10 {
return fmt.Errorf("attacker group %d must have 0 or 10 positions, got %d", g, len(group.Positions))
}
for i, pos := range group.Positions {
if err := validatePosition(pos, m.RowCount, m.ColumnCount, fmt.Sprintf("attacker group %d", g), i); err != nil {
return err
}
}
}
if len(m.MonthlyWeather) != 12 {
return fmt.Errorf("must have exactly 12 monthly weather entries, got %d", len(m.MonthlyWeather))
}
for i, w := range m.MonthlyWeather {
sum := intVal(w.SunChance) + intVal(w.CloudsChance) + intVal(w.RainChance) +
intVal(w.SnowChance) + intVal(w.ThunderstormChance) + intVal(w.BlizzardChance)
if sum != 100 {
return fmt.Errorf("month %d: weather chances must sum to 100, got %d", i+1, sum)
}
}
return nil
}
func validatePosition(pos HexPosition, rows, cols int, label string, index int) error {
if pos.Row != nil && (*pos.Row < 0 || *pos.Row >= rows) {
return fmt.Errorf("%s position %d: row %d out of bounds (0-%d)", label, index, *pos.Row, rows-1)
}
if pos.Column != nil && (*pos.Column < 0 || *pos.Column >= cols) {
return fmt.Errorf("%s position %d: column %d out of bounds (0-%d)", label, index, *pos.Column, cols-1)
}
return nil
}
func intVal(p *int) int {
if p == nil {
return 0
}
return *p
}
// File I/O
func listMapFiles() ([]string, error) {
entries, err := os.ReadDir(mapsDir)
if err != nil {
return nil, err
}
var names []string
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".e0mj") {
names = append(names, strings.TrimSuffix(e.Name(), ".e0mj"))
}
}
return names, nil
}
func readMapFile(name string) (*MapFile, error) {
data, err := os.ReadFile(mapFilePath(name))
if err != nil {
return nil, err
}
var m MapFile
if err := json.Unmarshal(data, &m); err != nil {
return nil, err
}
return &m, nil
}
func writeMapFile(name string, m *MapFile) error {
data, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
}
data = append(data, '\n')
return os.WriteFile(mapFilePath(name), data, 0644)
}
func mapFilePath(name string) string {
return filepath.Join(mapsDir, name+".e0mj")
}
func sanitizeMapName(name string) string {
// Only allow alphanumeric, underscore, hyphen, and spaces
var b strings.Builder
for _, r := range name {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' || r == ' ' {
b.WriteRune(r)
}
}
return b.String()
}
func isValidMapName(name string) bool {
// Reject empty, path traversal, and names with path separators
if name == "" || strings.Contains(name, "/") || strings.Contains(name, "\\") || strings.Contains(name, "..") {
return false
}
return sanitizeMapName(name) == name
}
// HTTP handlers
func handleMapsPage(w http.ResponseWriter, r *http.Request) {
maps, err := listMapFiles()
if err != nil {
http.Error(w, "Failed to list maps: "+err.Error(), http.StatusInternalServerError)
return
}
data := MapsPageData{
Title: "Maps",
BuildInfo: getBuildInfo(),
Maps: maps,
}
if err := mapsTemplate.Execute(w, data); err != nil {
log.Printf("Error rendering maps page: %v", err)
}
}
func handleMapEditorRoutes(w http.ResponseWriter, r *http.Request) {
name := strings.TrimPrefix(r.URL.Path, "/maps/")
if name == "" {
http.Redirect(w, r, "/maps", http.StatusFound)
return
}
if !isValidMapName(name) {
http.Error(w, "Invalid map name", http.StatusBadRequest)
return
}
// Check map exists
if _, err := os.Stat(mapFilePath(name)); os.IsNotExist(err) {
http.Error(w, "Map not found", http.StatusNotFound)
return
}
data := MapEditorPageData{
Title: "Edit: " + name,
BuildInfo: getBuildInfo(),
MapName: name,
}
if err := mapEditorTemplate.Execute(w, data); err != nil {
log.Printf("Error rendering map editor page: %v", err)
}
}
func handleMapsAPI(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Create new blank map
var req struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
name := sanitizeMapName(req.Name)
if name == "" {
http.Error(w, "Invalid map name", http.StatusBadRequest)
return
}
if _, err := os.Stat(mapFilePath(name)); err == nil {
http.Error(w, "Map already exists", http.StatusConflict)
return
}
m := newBlankMap()
if err := writeMapFile(name, m); err != nil {
http.Error(w, "Failed to create map: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"name": name})
}
func handleMapAPI(w http.ResponseWriter, r *http.Request) {
name := strings.TrimPrefix(r.URL.Path, "/api/maps/")
if name == "" {
http.Error(w, "Map name required", http.StatusBadRequest)
return
}
if !isValidMapName(name) {
http.Error(w, "Invalid map name", http.StatusBadRequest)
return
}
switch r.Method {
case http.MethodGet:
m, err := readMapFile(name)
if err != nil {
if os.IsNotExist(err) {
http.Error(w, "Map not found", http.StatusNotFound)
} else {
http.Error(w, "Failed to read map: "+err.Error(), http.StatusInternalServerError)
}
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(m)
case http.MethodPut:
var m MapFile
if err := json.NewDecoder(r.Body).Decode(&m); err != nil {
http.Error(w, "Invalid JSON: "+err.Error(), http.StatusBadRequest)
return
}
if err := validateMap(&m); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
return
}
if err := writeMapFile(name, &m); err != nil {
http.Error(w, "Failed to save map: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func newBlankMap() *MapFile {
rows := 12
cols := 14
terrain := make([]TerrainTile, rows*cols)
for i := range terrain {
terrain[i] = TerrainTile{Type: "PLAINS"}
}
defPositions := make([]HexPosition, 10)
for i := range defPositions {
r := i
c := 0
defPositions[i] = HexPosition{Row: &r, Column: &c}
}
attackerGroups := make([]StartingPositions, 8)
// First attacker group gets 10 positions, rest empty
atkPositions := make([]HexPosition, 10)
for i := range atkPositions {
r := i
c := cols - 1
atkPositions[i] = HexPosition{Row: &r, Column: &c}
}
attackerGroups[0] = StartingPositions{Positions: atkPositions}
weather := make([]WeatherData, 12)
for i := range weather {
sun := 40
clouds := 30
rain := 20
thunder := 10
wind := 25
temp := 60
weather[i] = WeatherData{
AverageTemperature: &temp,
SunChance: &sun,
CloudsChance: &clouds,
RainChance: &rain,
ThunderstormChance: &thunder,
MaxWindMph: &wind,
}
}
return &MapFile{
RowCount: rows,
ColumnCount: cols,
Terrain: terrain,
DefenderStartingPositions: &StartingPositions{Positions: defPositions},
AttackerStartingPositions: attackerGroups,
MonthlyWeather: weather,
}
}
@@ -1,169 +0,0 @@
.editor-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
flex-wrap: wrap;
gap: 0.5rem;
}
.editor-breadcrumb {
font-size: 1.1rem;
}
.editor-actions {
display: flex;
gap: 0.5rem;
align-items: center;
}
.editor-actions button {
margin: 0;
padding: 0.4rem 0.8rem;
}
#save-status {
font-size: 0.85rem;
min-width: 80px;
}
.editor-layout {
display: flex;
gap: 1rem;
align-items: flex-start;
}
.editor-sidebar {
width: 220px;
flex-shrink: 0;
}
.editor-sidebar details {
margin-bottom: 0.5rem;
}
.editor-sidebar summary {
font-weight: bold;
cursor: pointer;
padding: 0.3rem 0;
}
.tool-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
}
.tool-btn {
padding: 0.3rem 0.2rem;
font-size: 0.75rem;
border: 2px solid transparent;
border-radius: 4px;
cursor: pointer;
text-align: center;
background: var(--pico-secondary-background);
color: var(--pico-secondary-inverse);
line-height: 1.2;
}
.tool-btn:hover {
opacity: 0.85;
}
.tool-btn.active {
border-color: var(--pico-primary);
box-shadow: 0 0 0 2px var(--pico-primary);
}
.startpos-tools {
display: flex;
flex-direction: column;
gap: 4px;
}
.startpos-btn {
padding: 0.3rem 0.4rem;
font-size: 0.75rem;
border: 2px solid transparent;
border-radius: 4px;
cursor: pointer;
text-align: left;
line-height: 1.2;
}
.startpos-btn:hover {
opacity: 0.85;
}
.startpos-btn.active {
border-color: var(--pico-primary);
box-shadow: 0 0 0 2px var(--pico-primary);
}
.modifier-integrity {
margin-top: 0.5rem;
}
.modifier-integrity input {
padding: 0.3rem;
margin: 0;
}
.modifier-integrity label {
font-size: 0.8rem;
margin: 0;
}
.tool-hint {
font-size: 0.75rem;
color: var(--pico-muted-color);
margin: 0.3rem 0 0;
}
.editor-canvas-container {
flex: 1;
overflow: auto;
border: 1px solid var(--pico-muted-border-color);
border-radius: 4px;
background: #f8f9fa;
min-height: 500px;
}
#hex-canvas {
display: block;
}
/* Weather table */
.weather-table-container {
overflow-x: auto;
margin-top: 0.5rem;
}
.weather-table {
font-size: 0.7rem;
width: 100%;
}
.weather-table th,
.weather-table td {
padding: 2px 3px;
text-align: center;
}
.weather-table input {
width: 40px;
padding: 2px;
margin: 0;
font-size: 0.7rem;
text-align: center;
}
.weather-sum-ok {
color: green;
font-weight: bold;
}
.weather-sum-bad {
color: red;
font-weight: bold;
}
/* Map list page */
.map-create-section {
margin-bottom: 1rem;
}
.map-create-section fieldset {
margin: 0;
}
.map-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.5rem;
}
.map-card {
margin: 0;
padding: 0.8rem 1rem;
}
.map-card a {
text-decoration: none;
font-weight: 500;
}
@@ -1,887 +0,0 @@
// Map Editor - Hex grid canvas editor for .e0mj files
(function () {
"use strict";
// ── Constants ──────────────────────────────────────────────────────────
const HEX_SIZE = 30;
const HEX_W = HEX_SIZE * Math.sqrt(3); // pointy-top hex width
const HEX_H = HEX_SIZE * 2; // pointy-top hex height
const PADDING = 20;
const TERRAIN_COLORS = {
PLAINS: "#c8e6a0",
HILL: "#a0b070",
FOREST: "#2d8f2d",
MOUNTAIN: "#8a8a8a",
SWAMP: "#6b8e6b",
STILL_WATER: "#4a90d9",
RIVER: "#2070c0",
};
const TERRAIN_LABELS = {
PLAINS: "Plains",
HILL: "Hill",
FOREST: "Forest",
MOUNTAIN: "Mtn",
SWAMP: "Swamp",
STILL_WATER: "Water",
RIVER: "River",
};
const MODIFIER_TYPES = [
{ key: "castle", label: "Castle" },
{ key: "bridge", label: "Bridge" },
{ key: "none", label: "Clear Mod" },
];
const STARTPOS_COLORS = [
"#2563eb", // defender - blue
"#dc2626", // atk 0 - red
"#ea580c", // atk 1 - orange
"#ca8a04", // atk 2 - yellow
"#16a34a", // atk 3 - green
"#0891b2", // atk 4 - cyan
"#7c3aed", // atk 5 - purple
"#db2777", // atk 6 - pink
"#78716c", // atk 7 - gray
];
const MONTH_NAMES = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
// ── Tile images ───────────────────────────────────────────────────────
const TILE_NAMES = {
PLAINS: "plains",
HILL: "hill",
FOREST: "forest",
MOUNTAIN: "mountain",
SWAMP: "swamp",
STILL_WATER: "water",
RIVER: "river",
CASTLE: "castle",
};
const TERRAIN_IMAGES = {};
let imagesLoaded = false;
function preloadImages(callback) {
const keys = Object.keys(TILE_NAMES);
let loaded = 0;
keys.forEach(function (key) {
const img = new Image();
img.onload = function () {
TERRAIN_IMAGES[key] = img;
loaded++;
if (loaded === keys.length) {
imagesLoaded = true;
callback();
}
};
img.onerror = function () {
loaded++;
if (loaded === keys.length) {
imagesLoaded = true;
callback();
}
};
img.src = "/static/tiles/" + TILE_NAMES[key] + ".png";
});
}
// ── State ──────────────────────────────────────────────────────────────
let mapData = null;
let selectedTool = "terrain"; // 'terrain' | 'modifier' | 'startpos'
let selectedTerrain = "PLAINS";
let selectedModifier = "castle";
let modifierIntegrity = 100;
let selectedStartPosGroup = -1; // -1 = defender, 0-7 = attacker
let hoveredTile = -1;
let isDirty = false;
let isPainting = false;
let undoStack = [];
let redoStack = [];
const canvas = document.getElementById("hex-canvas");
const ctx = canvas.getContext("2d");
// ── Hex geometry (pointy-top, odd-row offset) ─────────────────────────
function hexCenter(row, col) {
const x =
PADDING + HEX_W / 2 + col * HEX_W + (row % 2 === 1 ? HEX_W / 2 : 0);
const y = PADDING + HEX_SIZE + row * HEX_SIZE * 1.5;
return { x, y };
}
function hexCorners(cx, cy) {
const corners = [];
for (let i = 0; i < 6; i++) {
const angle = (Math.PI / 180) * (30 + 60 * i);
corners.push({
x: cx + HEX_SIZE * Math.cos(angle),
y: cy + HEX_SIZE * Math.sin(angle),
});
}
return corners;
}
function pointInHex(px, py, cx, cy) {
// Simple distance check using hex geometry
const dx = Math.abs(px - cx);
const dy = Math.abs(py - cy);
if (dx > HEX_W / 2 || dy > HEX_SIZE) return false;
// More precise check using hex boundary
return HEX_SIZE * HEX_W / 2 - HEX_SIZE * dx - HEX_W / 2 * dy >= 0;
}
function tileAtPixel(px, py) {
if (!mapData) return -1;
for (let row = 0; row < mapData.rowCount; row++) {
for (let col = 0; col < mapData.columnCount; col++) {
const c = hexCenter(row, col);
if (pointInHex(px, py, c.x, c.y)) {
return row * mapData.columnCount + col;
}
}
}
return -1;
}
// ── Drawing ────────────────────────────────────────────────────────────
function draw() {
if (!mapData) return;
const w =
PADDING * 2 +
HEX_W * mapData.columnCount +
HEX_W / 2;
const h = PADDING * 2 + HEX_SIZE * 1.5 * (mapData.rowCount - 1) + HEX_H;
canvas.width = w;
canvas.height = h;
ctx.clearRect(0, 0, w, h);
// Draw all hexes
for (let row = 0; row < mapData.rowCount; row++) {
for (let col = 0; col < mapData.columnCount; col++) {
const idx = row * mapData.columnCount + col;
const tile = mapData.terrain[idx];
const center = hexCenter(row, col);
const corners = hexCorners(center.x, center.y);
// Clip to hex and draw tile image
ctx.save();
ctx.beginPath();
ctx.moveTo(corners[0].x, corners[0].y);
for (let i = 1; i < 6; i++) ctx.lineTo(corners[i].x, corners[i].y);
ctx.closePath();
ctx.clip();
const tileKey = (tile.modifier && tile.modifier.castle) ? "CASTLE" : tile.type;
const img = TERRAIN_IMAGES[tileKey];
if (img) {
// Tile images contain flat-top hexes with 3D depth below.
// Crop to just the hex body and scale to fill our pointy-top hex.
var srcHexH = Math.sqrt(3) * img.width / 2;
var sy = img.height / 2 - srcHexH / 2;
var scale = HEX_H / srcHexH;
var dw = img.width * scale;
ctx.drawImage(img, 0, sy, img.width, srcHexH,
center.x - dw / 2, center.y - HEX_SIZE, dw, HEX_H);
} else {
ctx.fillStyle = TERRAIN_COLORS[tile.type] || "#cccccc";
ctx.fill();
}
ctx.restore();
// Hover highlight
if (idx === hoveredTile) {
ctx.beginPath();
ctx.moveTo(corners[0].x, corners[0].y);
for (let i = 1; i < 6; i++) ctx.lineTo(corners[i].x, corners[i].y);
ctx.closePath();
ctx.fillStyle = "rgba(255,255,255,0.3)";
ctx.fill();
}
// Border
ctx.beginPath();
ctx.moveTo(corners[0].x, corners[0].y);
for (let i = 1; i < 6; i++) ctx.lineTo(corners[i].x, corners[i].y);
ctx.closePath();
ctx.strokeStyle = "#555";
ctx.lineWidth = 0.5;
ctx.stroke();
// Modifier icon
drawModifier(center, tile.modifier);
// Coordinate label
ctx.fillStyle = "#333";
ctx.font = "9px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "bottom";
ctx.fillText(row + "," + col, center.x, center.y + HEX_SIZE - 2);
}
}
// Draw starting positions on top
drawStartingPositions();
}
function drawModifier(center, modifier) {
if (!modifier) return;
const s = HEX_SIZE * 0.25;
if (modifier.castle) {
// Castle rendered as tile image; no overlay needed
} else if (modifier.bridge) {
// Bridge: horizontal line with endcaps
ctx.strokeStyle = "#8B4513";
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(center.x - s * 1.5, center.y - 4);
ctx.lineTo(center.x + s * 1.5, center.y - 4);
ctx.stroke();
ctx.lineWidth = 1;
}
}
function drawStartingPositions() {
if (!mapData) return;
const r = HEX_SIZE * 0.3;
// Defender positions
if (mapData.defenderStartingPositions && mapData.defenderStartingPositions.positions) {
mapData.defenderStartingPositions.positions.forEach(function (pos, i) {
drawPosMarker(pos, i, STARTPOS_COLORS[0], r);
});
}
// Attacker positions
if (mapData.attackerStartingPositions) {
mapData.attackerStartingPositions.forEach(function (group, g) {
if (group.positions) {
group.positions.forEach(function (pos, i) {
drawPosMarker(pos, i, STARTPOS_COLORS[g + 1], r);
});
}
});
}
}
function drawPosMarker(pos, index, color, r) {
const row = pos.row != null ? pos.row : 0;
const col = pos.column != null ? pos.column : 0;
const center = hexCenter(row, col);
// Offset slightly so multiple markers on same hex don't fully overlap
const ox = (index % 3 - 1) * r * 0.6;
const oy = (Math.floor(index / 3) - 1) * r * 0.6;
const cx = center.x + ox;
const cy = center.y + oy;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
ctx.strokeStyle = "#fff";
ctx.lineWidth = 1.5;
ctx.stroke();
ctx.fillStyle = "#fff";
ctx.font = "bold 8px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(String(index + 1), cx, cy);
}
// ── Editing ────────────────────────────────────────────────────────────
function applyTool(tileIdx) {
if (tileIdx < 0 || !mapData) return;
const row = Math.floor(tileIdx / mapData.columnCount);
const col = tileIdx % mapData.columnCount;
if (selectedTool === "terrain") {
const old = mapData.terrain[tileIdx].type;
if (old === selectedTerrain) return;
pushUndo({ type: "terrain", idx: tileIdx, old: old });
mapData.terrain[tileIdx].type = selectedTerrain;
markDirty();
} else if (selectedTool === "modifier") {
const oldMod = mapData.terrain[tileIdx].modifier
? JSON.parse(JSON.stringify(mapData.terrain[tileIdx].modifier))
: null;
if (selectedModifier === "none") {
if (!oldMod) return;
pushUndo({ type: "modifier", idx: tileIdx, old: oldMod });
mapData.terrain[tileIdx].modifier = undefined;
} else {
var newMod = {};
newMod[selectedModifier] = { integrity: modifierIntegrity };
pushUndo({ type: "modifier", idx: tileIdx, old: oldMod });
mapData.terrain[tileIdx].modifier = newMod;
}
markDirty();
} else if (selectedTool === "startpos") {
toggleStartPos(row, col);
}
draw();
}
function toggleStartPos(row, col) {
var group;
if (selectedStartPosGroup === -1) {
if (!mapData.defenderStartingPositions) {
mapData.defenderStartingPositions = { positions: [] };
}
group = mapData.defenderStartingPositions;
} else {
group = mapData.attackerStartingPositions[selectedStartPosGroup];
if (!group.positions) {
group.positions = [];
}
}
// Check if position already exists
var existing = -1;
for (var i = 0; i < group.positions.length; i++) {
var p = group.positions[i];
var pr = p.row != null ? p.row : 0;
var pc = p.column != null ? p.column : 0;
if (pr === row && pc === col) {
existing = i;
break;
}
}
var label =
selectedStartPosGroup === -1
? "defender"
: "attacker " + selectedStartPosGroup;
if (existing >= 0) {
pushUndo({
type: "startpos_remove",
group: selectedStartPosGroup,
posIndex: existing,
old: JSON.parse(JSON.stringify(group.positions[existing])),
});
group.positions.splice(existing, 1);
markDirty();
} else if (group.positions.length < 10) {
pushUndo({
type: "startpos_add",
group: selectedStartPosGroup,
posIndex: group.positions.length,
});
group.positions.push({ row: row, column: col });
markDirty();
}
updateStartPosHint();
}
// ── Undo / Redo ────────────────────────────────────────────────────────
function pushUndo(delta) {
undoStack.push(delta);
redoStack = [];
updateUndoButtons();
}
function editorUndoAction(delta, isRedo) {
if (delta.type === "terrain") {
var cur = mapData.terrain[delta.idx].type;
mapData.terrain[delta.idx].type = delta.old;
delta.old = cur;
} else if (delta.type === "modifier") {
var curMod = mapData.terrain[delta.idx].modifier
? JSON.parse(JSON.stringify(mapData.terrain[delta.idx].modifier))
: null;
mapData.terrain[delta.idx].modifier = delta.old || undefined;
delta.old = curMod;
} else if (delta.type === "startpos_add") {
var g1 = getStartPosGroup(delta.group);
delta.old = JSON.parse(
JSON.stringify(g1.positions[g1.positions.length - 1])
);
g1.positions.splice(g1.positions.length - 1, 1);
delta.type = "startpos_remove";
} else if (delta.type === "startpos_remove") {
var g2 = getStartPosGroup(delta.group);
g2.positions.splice(delta.posIndex, 0, delta.old);
delta.type = "startpos_add";
}
}
function getStartPosGroup(groupIdx) {
if (groupIdx === -1) return mapData.defenderStartingPositions;
return mapData.attackerStartingPositions[groupIdx];
}
window.editorUndo = function () {
if (undoStack.length === 0) return;
var delta = undoStack.pop();
editorUndoAction(delta, false);
redoStack.push(delta);
markDirty();
updateUndoButtons();
updateStartPosHint();
draw();
};
window.editorRedo = function () {
if (redoStack.length === 0) return;
var delta = redoStack.pop();
editorUndoAction(delta, true);
undoStack.push(delta);
markDirty();
updateUndoButtons();
updateStartPosHint();
draw();
};
function updateUndoButtons() {
document.getElementById("btn-undo").disabled = undoStack.length === 0;
document.getElementById("btn-redo").disabled = redoStack.length === 0;
}
// ── Dirty tracking ────────────────────────────────────────────────────
function markDirty() {
isDirty = true;
document.getElementById("save-status").textContent = "Unsaved changes";
document.getElementById("save-status").style.color = "#ca8a04";
}
window.addEventListener("beforeunload", function (e) {
if (isDirty) {
e.preventDefault();
e.returnValue = "";
}
});
// ── Save ───────────────────────────────────────────────────────────────
window.editorDownload = function () {
if (!mapData) return;
var json = JSON.stringify(mapData, null, 2) + "\n";
var blob = new Blob([json], { type: "application/json" });
var a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = MAP_NAME + ".e0mj";
a.click();
URL.revokeObjectURL(a.href);
};
window.editorSave = function () {
if (!mapData) return;
document.getElementById("save-status").textContent = "Saving...";
document.getElementById("save-status").style.color = "";
fetch("/api/maps/" + MAP_NAME, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(mapData),
})
.then(function (r) {
return r.json().then(function (body) {
return { ok: r.ok, body: body };
});
})
.then(function (result) {
if (result.ok) {
isDirty = false;
document.getElementById("save-status").textContent = "Saved";
document.getElementById("save-status").style.color = "green";
} else {
document.getElementById("save-status").textContent =
"Error: " + (result.body.error || "unknown");
document.getElementById("save-status").style.color = "red";
}
})
.catch(function (err) {
document.getElementById("save-status").textContent =
"Error: " + err.message;
document.getElementById("save-status").style.color = "red";
});
};
// ── Canvas interaction ─────────────────────────────────────────────────
canvas.addEventListener("mousedown", function (e) {
var rect = canvas.getBoundingClientRect();
var tile = tileAtPixel(e.clientX - rect.left, e.clientY - rect.top);
if (tile >= 0) {
isPainting = true;
applyTool(tile);
}
});
canvas.addEventListener("mousemove", function (e) {
var rect = canvas.getBoundingClientRect();
var tile = tileAtPixel(e.clientX - rect.left, e.clientY - rect.top);
if (tile !== hoveredTile) {
hoveredTile = tile;
draw();
}
if (isPainting && tile >= 0 && selectedTool !== "startpos") {
applyTool(tile);
}
});
canvas.addEventListener("mouseup", function () {
isPainting = false;
});
canvas.addEventListener("mouseleave", function () {
isPainting = false;
if (hoveredTile !== -1) {
hoveredTile = -1;
draw();
}
});
// Keyboard shortcuts
document.addEventListener("keydown", function (e) {
if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") return;
if ((e.ctrlKey || e.metaKey) && e.key === "z") {
e.preventDefault();
if (e.shiftKey) {
editorRedo();
} else {
editorUndo();
}
} else if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
editorSave();
}
});
// ── Build sidebar tools ────────────────────────────────────────────────
function buildTerrainTools() {
var container = document.getElementById("terrain-tools");
Object.keys(TERRAIN_COLORS).forEach(function (key) {
var btn = document.createElement("div");
btn.className = "tool-btn" + (key === selectedTerrain ? " active" : "");
btn.style.backgroundColor = TERRAIN_COLORS[key];
btn.style.color =
key === "FOREST" || key === "MOUNTAIN" || key === "RIVER" || key === "STILL_WATER"
? "#fff"
: "#333";
btn.textContent = TERRAIN_LABELS[key];
btn.dataset.terrain = key;
btn.addEventListener("click", function () {
selectedTool = "terrain";
selectedTerrain = key;
refreshToolSelection();
});
container.appendChild(btn);
});
}
function buildModifierTools() {
var container = document.getElementById("modifier-tools");
MODIFIER_TYPES.forEach(function (mod) {
var btn = document.createElement("div");
btn.className = "tool-btn";
btn.textContent = mod.label;
btn.dataset.modifier = mod.key;
btn.addEventListener("click", function () {
selectedTool = "modifier";
selectedModifier = mod.key;
refreshToolSelection();
});
container.appendChild(btn);
});
document
.getElementById("modifier-integrity")
.addEventListener("change", function () {
modifierIntegrity = Math.max(1, parseInt(this.value) || 100);
});
}
function buildStartPosTools() {
var container = document.getElementById("startpos-tools");
// Defender button
var defBtn = document.createElement("div");
defBtn.className = "startpos-btn";
defBtn.style.backgroundColor = STARTPOS_COLORS[0];
defBtn.style.color = "#fff";
defBtn.textContent = "Defender (0/10)";
defBtn.dataset.group = "-1";
defBtn.addEventListener("click", function () {
selectedTool = "startpos";
selectedStartPosGroup = -1;
refreshToolSelection();
updateStartPosHint();
});
container.appendChild(defBtn);
// Attacker buttons
for (var i = 0; i < 8; i++) {
(function (idx) {
var btn = document.createElement("div");
btn.className = "startpos-btn";
btn.style.backgroundColor = STARTPOS_COLORS[idx + 1];
btn.style.color = "#fff";
btn.textContent = "Attacker " + idx + " (0/10)";
btn.dataset.group = String(idx);
btn.addEventListener("click", function () {
selectedTool = "startpos";
selectedStartPosGroup = idx;
refreshToolSelection();
updateStartPosHint();
});
container.appendChild(btn);
})(i);
}
}
function updateStartPosCounts() {
if (!mapData) return;
var btns = document.querySelectorAll(".startpos-btn");
btns.forEach(function (btn) {
var g = parseInt(btn.dataset.group);
var count = 0;
if (g === -1) {
count =
mapData.defenderStartingPositions &&
mapData.defenderStartingPositions.positions
? mapData.defenderStartingPositions.positions.length
: 0;
btn.textContent = "Defender (" + count + "/10)";
} else {
var group = mapData.attackerStartingPositions[g];
count = group && group.positions ? group.positions.length : 0;
btn.textContent = "Attacker " + g + " (" + count + "/10)";
}
});
}
function updateStartPosHint() {
updateStartPosCounts();
var hint = document.getElementById("startpos-hint");
if (selectedTool !== "startpos") {
hint.textContent = "";
return;
}
hint.textContent = "Click hex to add/remove position";
}
function refreshToolSelection() {
// Terrain buttons
document.querySelectorAll("#terrain-tools .tool-btn").forEach(function (b) {
b.classList.toggle(
"active",
selectedTool === "terrain" && b.dataset.terrain === selectedTerrain
);
});
// Modifier buttons
document
.querySelectorAll("#modifier-tools .tool-btn")
.forEach(function (b) {
b.classList.toggle(
"active",
selectedTool === "modifier" && b.dataset.modifier === selectedModifier
);
});
// Integrity input visibility
document.getElementById("integrity-section").style.display =
selectedTool === "modifier" && selectedModifier !== "none"
? "block"
: "none";
// Startpos buttons
document.querySelectorAll(".startpos-btn").forEach(function (b) {
b.classList.toggle(
"active",
selectedTool === "startpos" &&
parseInt(b.dataset.group) === selectedStartPosGroup
);
});
}
// ── Weather table ──────────────────────────────────────────────────────
function buildWeatherTable() {
var tbody = document.getElementById("weather-body");
tbody.innerHTML = "";
if (!mapData || !mapData.monthlyWeather) return;
mapData.monthlyWeather.forEach(function (w, mi) {
var tr = document.createElement("tr");
// Month name
var tdMonth = document.createElement("td");
tdMonth.textContent = MONTH_NAMES[mi];
tr.appendChild(tdMonth);
var fields = [
"sunChance",
"cloudsChance",
"rainChance",
"snowChance",
"thunderstormChance",
"blizzardChance",
];
fields.forEach(function (field) {
var td = document.createElement("td");
var input = document.createElement("input");
input.type = "number";
input.min = "0";
input.max = "100";
input.value = w[field] != null ? w[field] : 0;
input.dataset.month = mi;
input.dataset.field = field;
input.addEventListener("change", function () {
var val = parseInt(this.value) || 0;
if (val < 0) val = 0;
if (val > 100) val = 100;
this.value = val;
pushUndo({
type: "weather",
month: mi,
field: field,
old: mapData.monthlyWeather[mi][field],
});
mapData.monthlyWeather[mi][field] = val > 0 ? val : undefined;
markDirty();
updateWeatherSums();
});
td.appendChild(input);
tr.appendChild(td);
});
// Sum column
var tdSum = document.createElement("td");
tdSum.className = "weather-sum";
tdSum.dataset.month = mi;
tr.appendChild(tdSum);
// Temperature
var tdTemp = document.createElement("td");
var tempInput = document.createElement("input");
tempInput.type = "number";
tempInput.value = w.averageTemperature != null ? w.averageTemperature : 0;
tempInput.dataset.month = mi;
tempInput.dataset.field = "averageTemperature";
tempInput.addEventListener("change", function () {
var val = parseInt(this.value) || 0;
pushUndo({
type: "weather",
month: mi,
field: "averageTemperature",
old: mapData.monthlyWeather[mi].averageTemperature,
});
mapData.monthlyWeather[mi].averageTemperature =
val !== 0 ? val : undefined;
markDirty();
});
tdTemp.appendChild(tempInput);
tr.appendChild(tdTemp);
// Wind
var tdWind = document.createElement("td");
var windInput = document.createElement("input");
windInput.type = "number";
windInput.min = "0";
windInput.value = w.maxWindMph != null ? w.maxWindMph : 0;
windInput.dataset.month = mi;
windInput.dataset.field = "maxWindMph";
windInput.addEventListener("change", function () {
var val = parseInt(this.value) || 0;
if (val < 0) val = 0;
pushUndo({
type: "weather",
month: mi,
field: "maxWindMph",
old: mapData.monthlyWeather[mi].maxWindMph,
});
mapData.monthlyWeather[mi].maxWindMph = val > 0 ? val : undefined;
markDirty();
});
tdWind.appendChild(windInput);
tr.appendChild(tdWind);
tbody.appendChild(tr);
});
updateWeatherSums();
}
function updateWeatherSums() {
if (!mapData) return;
mapData.monthlyWeather.forEach(function (w, mi) {
var sum =
(w.sunChance || 0) +
(w.cloudsChance || 0) +
(w.rainChance || 0) +
(w.snowChance || 0) +
(w.thunderstormChance || 0) +
(w.blizzardChance || 0);
var td = document.querySelector(
'.weather-sum[data-month="' + mi + '"]'
);
if (td) {
td.textContent = sum;
td.className =
"weather-sum " + (sum === 100 ? "weather-sum-ok" : "weather-sum-bad");
}
});
}
// ── Init ───────────────────────────────────────────────────────────────
function loadMap() {
fetch("/api/maps/" + MAP_NAME)
.then(function (r) {
if (!r.ok) throw new Error("Failed to load map");
return r.json();
})
.then(function (data) {
mapData = data;
// Ensure attacker groups have positions arrays
if (mapData.attackerStartingPositions) {
mapData.attackerStartingPositions.forEach(function (g) {
if (!g.positions) g.positions = [];
});
}
buildWeatherTable();
updateStartPosCounts();
preloadImages(draw);
})
.catch(function (err) {
alert("Failed to load map: " + err.message);
});
}
buildTerrainTools();
buildModifierTools();
buildStartPosTools();
refreshToolSelection();
loadMap();
})();
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -16,7 +16,6 @@
<li><a href="/accounts">Accounts</a></li>
<li><a href="/settings">Settings</a></li>
<li><a href="/llm-stats">LLM Stats</a></li>
<li><a href="/maps">Maps</a></li>
<li><a href="/whats-new">What's New</a></li>
<li id="health-status" hx-get="/health/status" hx-trigger="load, every 10s" hx-swap="innerHTML">
<span class="health-loading">Health: ...</span>
@@ -24,10 +23,6 @@
<li id="jfr-controls" hx-get="/jfr/status" hx-trigger="load" hx-swap="innerHTML">
<span class="jfr-loading">JFR: ...</span>
</li>
<li>
<button onclick="confirmEagleRestart()" class="btn-small secondary outline">Restart Eagle</button>
<span id="eagle-restart-result"></span>
</li>
<li><a href="/logout" class="logout-link">Logout</a></li>
</ul>
</nav>
@@ -35,18 +30,6 @@
{{template "content" .}}
</main>
<!-- Eagle Restart Dialog -->
<dialog id="eagle-restart-dialog">
<article style="max-width: 400px;">
<h3>Restart Eagle Server</h3>
<p>This will restart the active Eagle instance. Players may be briefly disconnected.</p>
<footer style="display: flex; gap: 0.5rem; justify-content: flex-end;">
<button class="secondary outline" onclick="document.getElementById('eagle-restart-dialog').close()">Cancel</button>
<button onclick="doEagleRestart()">Restart</button>
</footer>
</article>
</dialog>
<!-- JFR Stop Dialog -->
<dialog id="jfr-stop-dialog">
<article style="max-width: 400px;">
@@ -124,16 +107,6 @@
function jfrStopCancel() {
document.getElementById('jfr-stop-dialog').close();
}
function confirmEagleRestart() {
document.getElementById('eagle-restart-dialog').showModal();
}
function doEagleRestart() {
document.getElementById('eagle-restart-dialog').close();
document.getElementById('eagle-restart-result').innerHTML = 'Restarting...';
htmx.ajax('POST', '/server/restart', {target: '#eagle-restart-result', swap: 'innerHTML'});
}
</script>
</body>
</html>
@@ -1,72 +0,0 @@
{{define "content"}}
<link rel="stylesheet" href="/static/map_editor.css">
<div class="editor-header">
<div class="editor-breadcrumb">
<a href="/maps">Maps</a> &rsaquo; <strong>{{.MapName}}</strong>
</div>
<div class="editor-actions">
<button id="btn-undo" onclick="editorUndo()" disabled>Undo</button>
<button id="btn-redo" onclick="editorRedo()" disabled>Redo</button>
<button id="btn-save" onclick="editorSave()" class="contrast">Save</button>
<button id="btn-download" onclick="editorDownload()" class="secondary outline">Download</button>
<span id="save-status"></span>
</div>
</div>
<div class="editor-layout">
<aside class="editor-sidebar">
<details open>
<summary>Terrain</summary>
<div class="tool-grid" id="terrain-tools"></div>
</details>
<details open>
<summary>Modifier</summary>
<div class="tool-grid" id="modifier-tools"></div>
<div class="modifier-integrity" id="integrity-section" style="display:none;">
<label>Integrity
<input type="number" id="modifier-integrity" value="100" min="1" step="1">
</label>
</div>
</details>
<details open>
<summary>Starting Positions</summary>
<div class="startpos-tools" id="startpos-tools"></div>
<p class="tool-hint" id="startpos-hint"></p>
</details>
<details>
<summary>Weather</summary>
<div class="weather-table-container">
<table class="weather-table" id="weather-table">
<thead>
<tr>
<th>Month</th>
<th>Sun</th>
<th>Cloud</th>
<th>Rain</th>
<th>Snow</th>
<th>Storm</th>
<th>Bliz</th>
<th>Sum</th>
<th>Temp</th>
<th>Wind</th>
</tr>
</thead>
<tbody id="weather-body"></tbody>
</table>
</div>
</details>
</aside>
<div class="editor-canvas-container">
<canvas id="hex-canvas"></canvas>
</div>
</div>
<script>
const MAP_NAME = "{{.MapName}}";
</script>
<script src="/static/map_editor.js"></script>
{{end}}
@@ -1,45 +0,0 @@
{{define "content"}}
<div class="page-header">
<h1>Maps</h1>
<span>{{len .Maps}} map{{if ne (len .Maps) 1}}s{{end}}</span>
</div>
<div class="map-create-section">
<form id="create-map-form" onsubmit="createMap(event)">
<fieldset role="group">
<input type="text" id="new-map-name" placeholder="New map name..." required>
<button type="submit">Create Map</button>
</fieldset>
</form>
</div>
<div class="map-list">
{{range .Maps}}
<article class="map-card">
<a href="/maps/{{.}}">{{.}}</a>
</article>
{{else}}
<p>No maps found. Create one or check the --maps-dir flag.</p>
{{end}}
</div>
<script>
function createMap(e) {
e.preventDefault();
const name = document.getElementById('new-map-name').value.trim();
if (!name) return;
fetch('/api/maps', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: name})
}).then(r => {
if (r.ok) return r.json();
return r.text().then(t => { throw new Error(t); });
}).then(data => {
window.location.href = '/maps/' + data.name;
}).catch(err => {
alert('Failed to create map: ' + err.message);
});
}
</script>
{{end}}
@@ -1,44 +1,44 @@
id name neighbors neighborPositions hexMapName neighborNames castleCount
1 Shumal 18.36 7.5 Shumal Alah.Pemeria 3
2 Motcia 12.31.27.29.5.39 5.2.4.1.7.0 Motcia Pozia.Nikemi.Chapellia.Mesh.Musland.Kojaria 3
3 Laufarvia 40.6.19.33 3.7.1.4 Laufarvia West Faluria.Soria.Hella.Usvol 3
4 Berkorszag 28.37.26.30.16.25.38 2.0.6.5.4.7.3 Berkorszag Kezonoria.Yuetia.Reigia.Tegrot.Oscasland.East Faluria.Wichel 3
3 Laufarvia 40.6.19.33 3.7.1.4 Laufarvia Faluria.Soria.Hella.Usvol 3
4 Berkorszag 28.37.26.30.16.25.38 2.0.6.5.4.7.3 Berkorszag Kezonoria.Yuetia.Reigia.Tegrot.Oscasland.Fluria.Wichel 3
5 Musland 38.8.2.12.29 7.6.3.4.1 Musland Wichel.Atfordia.Motcia.Pozia.Mesh 3
6 Soria 32.10.11.19.3 7.1.2.3.4 Soria Ingia.Vovimaria.Garholtia.Hella.Laufarvia 2
7 Oryslia 29.36.28.34.38 3.2.7.1.5 Oryslia Mesh.Pemeria.Kezonoria.Turton.Wichel 3
8 Atfordia 5.38.22.16 3.1.7.0 Atfordia Musland.Wichel.Chipingia.Oscasland 3
9 Bolve 26.25.23 1.0.7 Bolve Reigia.East Faluria.Eivikia 3
9 Bolve 26.25.23 1.0.7 Bolve Reigia.Fluria.Eivikia 3
10 Vovimaria 41.11.6.32.37 7.3.4.5.1 Vovimaria Parnia.Garholtia.Soria.Ingia.Yuetia 5
11 Garholtia 37.40.6.10.19 1.3.6.7.5 Garholtia Yuetia.West Faluria.Soria.Vovimaria.Hella 4
11 Garholtia 37.40.6.10.19 1.3.6.7.5 Garholtia Yuetia.Faluria.Soria.Vovimaria.Hella 4
12 Pozia 5.2.27 0.1.2 Pozia Musland.Motcia.Chapellia 3
13 Al Raala 31.18.29.36 4.1.6.7 AlRaala Nikemi.Alah.Mesh.Pemeria 3
14 Onmaa 31.15 7.6 Onmaa Nikemi.Pieksa 3
15 Pieksa 31.14.39 7.2.5 Pieksa Nikemi.Onmaa.Kojaria 3
16 Oscasland 21.8.38.22.4.30 7.3.2.4.1.0 Oscasland Sigkarl.Atfordia.Wichel.Chipingia.Berkorszag.Tegrot 4
17 Hofolen 42.40.23.25 6.7.3.1 Hofolen Grytrand.West Faluria.Eivikia.East Faluria 3
17 Hofolen 42.40.23.25 6.7.3.1 Hofolen Grytrand.Faluria.Eivikia.Fluria 3
18 Alah 13.35.1.36 5.3.0.6 Alah Al Raala.Tumala.Shumal.Pemeria 3
19 Hella 11.3.40.6.33 1.5.3.7.2 Hella Garholtia.Laufarvia.West Faluria.Soria.Usvol 3
19 Hella 11.3.40.6.33 1.5.3.7.2 Hella Garholtia.Laufarvia.Faluria.Soria.Usvol 3
20 Elekes 24.34.28 1.3.5 Elekes Tatoros.Turton.Kezonoria 3
21 Sigkarl 16.30.26 3.1.7 Sigkarl Oscasland.Tegrot.Reigia 3
22 Chipingia 16.8 7.3 Chipingia Oscasland.Atfordia 3
23 Eivikia 17.25.9 7.1.3 Eivikia Hofolen.East Faluria.Bolve 2
23 Eivikia 17.25.9 7.1.3 Eivikia Hofolen.Fluria.Bolve 2
24 Tatoros 20.34 5.3 Tatoros Elekes.Turton 3
25 East Faluria 9.40.23.26.4.37.17 4.7.5.3.2.1.6 East_Faluria Bolve.West Faluria.Eivikia.Reigia.Berkorszag.Yuetia.Hofolen 5
26 Reigia 25.21.9.4.30 7.4.5.1.2 Reigia East Faluria.Sigkarl.Bolve.Berkorszag.Tegrot 4
25 Fluria 9.40.23.26.4.37.17 4.7.5.3.2.1.6 Fluria Bolve.Faluria.Eivikia.Reigia.Berkorszag.Yuetia.Hofolen 5
26 Reigia 25.21.9.4.30 7.4.5.1.2 Reigia Fluria.Sigkarl.Bolve.Berkorszag.Tegrot 4
27 Chapellia 2.12.39 7.5.4 Chapellia Motcia.Pozia.Kojaria 4
28 Kezonoria 20.4.7.34.38 1.7.3.2.5 Kezonoria Elekes.Berkorszag.Oryslia.Turton.Wichel 3
29 Mesh 13.2.5.7.36.31.38 2.4.5.7.1.3.6 Mesh Al Raala.Motcia.Musland.Oryslia.Pemeria.Nikemi.Wichel 7
30 Tegrot 16.21.4.26 4.5.1.7 Tegrot Oscasland.Sigkarl.Berkorszag.Reigia 4
31 Nikemi 15.39.13.2.29.14 3.4.1.7.0.2 Nikemi Pieksa.Kojaria.Al Raala.Motcia.Mesh.Onmaa 4
32 Ingia 6.10.41 3.1.7 Ingia Soria.Vovimaria.Parnia 3
33 Usvol 40.3.19.42 1.3.5.4 Usvol West Faluria.Laufarvia.Hella.Grytrand 2
33 Usvol 40.3.19.42 1.3.5.4 Usvol Faluria.Laufarvia.Hella.Grytrand 2
34 Turton 36.24.7.28.20 3.0.4.5.7 Turton Pemeria.Tatoros.Oryslia.Kezonoria.Elekes 3
35 Tumala 18 7 Tumala Alah 3
36 Pemeria 7.34.1.18.13.29 6.0.1.2.3.5 Pemeria Oryslia.Turton.Shumal.Alah.Al Raala.Mesh 4
37 Yuetia 10.25.40.41.11.4.43 7.4.5.0.6.3.1 Yuetia Vovimaria.East Faluria.West Faluria.Parnia.Garholtia.Berkorszag.Chia 4
37 Yuetia 10.25.40.41.11.4.43 7.4.5.0.6.3.1 Yuetia Vovimaria.Fluria.Faluria.Parnia.Garholtia.Berkorszag.Chia 4
38 Wichel 29.4.7.28.16.5.8 2.7.1.0.6.3.5 Wichel Mesh.Berkorszag.Oryslia.Kezonoria.Oscasland.Musland.Atfordia 6
39 Kojaria 15.31.27.2 2.0.1.3 Kojaria Pieksa.Nikemi.Chapellia.Motcia 4
40 West Faluria 37.25.17.42.3.33.19.11 1.2.3.4.6.5.7.0 West_Faluria Yuetia.East Faluria.Hofolen.Grytrand.Laufarvia.Usvol.Hella.Garholtia 3
40 Faluria 37.25.17.42.3.33.19.11 1.2.3.4.6.5.7.0 Faluria Yuetia.Fluria.Hofolen.Grytrand.Laufarvia.Usvol.Hella.Garholtia 3
41 Parnia 32.37.10 4.2.3 Parnia Ingia.Yuetia.Vovimaria 3
42 Grytrand 40.17.33 1.2.3 Grytrand West Faluria.Hofolen.Usvol 2
42 Grytrand 40.17.33 1.2.3 Grytrand Faluria.Hofolen.Usvol 2
43 Chia 37 5 Chia Yuetia 3
1 id name neighbors neighborPositions hexMapName neighborNames castleCount
2 1 Shumal 18.36 7.5 Shumal Alah.Pemeria 3
3 2 Motcia 12.31.27.29.5.39 5.2.4.1.7.0 Motcia Pozia.Nikemi.Chapellia.Mesh.Musland.Kojaria 3
4 3 Laufarvia 40.6.19.33 3.7.1.4 Laufarvia West Faluria.Soria.Hella.Usvol Faluria.Soria.Hella.Usvol 3
5 4 Berkorszag 28.37.26.30.16.25.38 2.0.6.5.4.7.3 Berkorszag Kezonoria.Yuetia.Reigia.Tegrot.Oscasland.East Faluria.Wichel Kezonoria.Yuetia.Reigia.Tegrot.Oscasland.Fluria.Wichel 3
6 5 Musland 38.8.2.12.29 7.6.3.4.1 Musland Wichel.Atfordia.Motcia.Pozia.Mesh 3
7 6 Soria 32.10.11.19.3 7.1.2.3.4 Soria Ingia.Vovimaria.Garholtia.Hella.Laufarvia 2
8 7 Oryslia 29.36.28.34.38 3.2.7.1.5 Oryslia Mesh.Pemeria.Kezonoria.Turton.Wichel 3
9 8 Atfordia 5.38.22.16 3.1.7.0 Atfordia Musland.Wichel.Chipingia.Oscasland 3
10 9 Bolve 26.25.23 1.0.7 Bolve Reigia.East Faluria.Eivikia Reigia.Fluria.Eivikia 3
11 10 Vovimaria 41.11.6.32.37 7.3.4.5.1 Vovimaria Parnia.Garholtia.Soria.Ingia.Yuetia 5
12 11 Garholtia 37.40.6.10.19 1.3.6.7.5 Garholtia Yuetia.West Faluria.Soria.Vovimaria.Hella Yuetia.Faluria.Soria.Vovimaria.Hella 4
13 12 Pozia 5.2.27 0.1.2 Pozia Musland.Motcia.Chapellia 3
14 13 Al Raala 31.18.29.36 4.1.6.7 AlRaala Nikemi.Alah.Mesh.Pemeria 3
15 14 Onmaa 31.15 7.6 Onmaa Nikemi.Pieksa 3
16 15 Pieksa 31.14.39 7.2.5 Pieksa Nikemi.Onmaa.Kojaria 3
17 16 Oscasland 21.8.38.22.4.30 7.3.2.4.1.0 Oscasland Sigkarl.Atfordia.Wichel.Chipingia.Berkorszag.Tegrot 4
18 17 Hofolen 42.40.23.25 6.7.3.1 Hofolen Grytrand.West Faluria.Eivikia.East Faluria Grytrand.Faluria.Eivikia.Fluria 3
19 18 Alah 13.35.1.36 5.3.0.6 Alah Al Raala.Tumala.Shumal.Pemeria 3
20 19 Hella 11.3.40.6.33 1.5.3.7.2 Hella Garholtia.Laufarvia.West Faluria.Soria.Usvol Garholtia.Laufarvia.Faluria.Soria.Usvol 3
21 20 Elekes 24.34.28 1.3.5 Elekes Tatoros.Turton.Kezonoria 3
22 21 Sigkarl 16.30.26 3.1.7 Sigkarl Oscasland.Tegrot.Reigia 3
23 22 Chipingia 16.8 7.3 Chipingia Oscasland.Atfordia 3
24 23 Eivikia 17.25.9 7.1.3 Eivikia Hofolen.East Faluria.Bolve Hofolen.Fluria.Bolve 2
25 24 Tatoros 20.34 5.3 Tatoros Elekes.Turton 3
26 25 East Faluria Fluria 9.40.23.26.4.37.17 4.7.5.3.2.1.6 East_Faluria Fluria Bolve.West Faluria.Eivikia.Reigia.Berkorszag.Yuetia.Hofolen Bolve.Faluria.Eivikia.Reigia.Berkorszag.Yuetia.Hofolen 5
27 26 Reigia 25.21.9.4.30 7.4.5.1.2 Reigia East Faluria.Sigkarl.Bolve.Berkorszag.Tegrot Fluria.Sigkarl.Bolve.Berkorszag.Tegrot 4
28 27 Chapellia 2.12.39 7.5.4 Chapellia Motcia.Pozia.Kojaria 4
29 28 Kezonoria 20.4.7.34.38 1.7.3.2.5 Kezonoria Elekes.Berkorszag.Oryslia.Turton.Wichel 3
30 29 Mesh 13.2.5.7.36.31.38 2.4.5.7.1.3.6 Mesh Al Raala.Motcia.Musland.Oryslia.Pemeria.Nikemi.Wichel 7
31 30 Tegrot 16.21.4.26 4.5.1.7 Tegrot Oscasland.Sigkarl.Berkorszag.Reigia 4
32 31 Nikemi 15.39.13.2.29.14 3.4.1.7.0.2 Nikemi Pieksa.Kojaria.Al Raala.Motcia.Mesh.Onmaa 4
33 32 Ingia 6.10.41 3.1.7 Ingia Soria.Vovimaria.Parnia 3
34 33 Usvol 40.3.19.42 1.3.5.4 Usvol West Faluria.Laufarvia.Hella.Grytrand Faluria.Laufarvia.Hella.Grytrand 2
35 34 Turton 36.24.7.28.20 3.0.4.5.7 Turton Pemeria.Tatoros.Oryslia.Kezonoria.Elekes 3
36 35 Tumala 18 7 Tumala Alah 3
37 36 Pemeria 7.34.1.18.13.29 6.0.1.2.3.5 Pemeria Oryslia.Turton.Shumal.Alah.Al Raala.Mesh 4
38 37 Yuetia 10.25.40.41.11.4.43 7.4.5.0.6.3.1 Yuetia Vovimaria.East Faluria.West Faluria.Parnia.Garholtia.Berkorszag.Chia Vovimaria.Fluria.Faluria.Parnia.Garholtia.Berkorszag.Chia 4
39 38 Wichel 29.4.7.28.16.5.8 2.7.1.0.6.3.5 Wichel Mesh.Berkorszag.Oryslia.Kezonoria.Oscasland.Musland.Atfordia 6
40 39 Kojaria 15.31.27.2 2.0.1.3 Kojaria Pieksa.Nikemi.Chapellia.Motcia 4
41 40 West Faluria Faluria 37.25.17.42.3.33.19.11 1.2.3.4.6.5.7.0 West_Faluria Faluria Yuetia.East Faluria.Hofolen.Grytrand.Laufarvia.Usvol.Hella.Garholtia Yuetia.Fluria.Hofolen.Grytrand.Laufarvia.Usvol.Hella.Garholtia 3
42 41 Parnia 32.37.10 4.2.3 Parnia Ingia.Yuetia.Vovimaria 3
43 42 Grytrand 40.17.33 1.2.3 Grytrand West Faluria.Hofolen.Usvol Faluria.Hofolen.Usvol 2
44 43 Chia 37 5 Chia Yuetia 3
@@ -144,12 +144,11 @@
"gold": 50,
"food": 2000,
"orders": "ENTRUST",
"rulingPlayerHeroNames": ["Ikhaan Tarn", "Tall Edgtheow", "Waylaid Julius", "Silvio the Smooth", "Aldric the Overlooked"],
"rulingPlayerHeroNames": ["Ikhaan Tarn", "Tall Edgtheow", "Waylaid Julius", "Aldric the Overlooked"],
"battalions": [
{ "type": "HEAVY_CAVALRY", "size": 592, "training": 80, "armament": 80, "name": "Doomriders" },
{ "type": "HEAVY_INFANTRY", "size": 507, "training": 80, "armament": 80, "name": "The Shardok's Guard" },
{ "type": "LONGBOWMEN", "size": 291, "training": 80, "armament": 80, "name": "Bowmen of Nikemi" },
{ "type": "LIGHT_CAVALRY", "size": 450, "training": 80, "armament": 80, "name": "Swift Sabres" }
{ "type": "HEAVY_CAVALRY", "size": 592, "training": 80, "armament": 80, "name": "Heavy Cavalry" },
{ "type": "HEAVY_INFANTRY", "size": 507, "training": 80, "armament": 80, "name": "Heavy Infantry" },
{ "type": "LONGBOWMEN", "size": 291, "training": 80, "armament": 80, "name": "Longbowmen" }
]
}
],
@@ -83,21 +83,10 @@ case class ResolveBattleAction(
battleResolution: BattleResolution
): ChangedProvinceC = {
val defenderProvince = startingGameState.provinces(battle.defenderProvince)
// Reinforcement units that survived the battle and should be added to the province
val survivingReinforcementUnits = (for {
rsp <- battleResolution.resolvedPlayers
if defenderProvince.rulingFactionId.contains(rsp.eagleFid)
rse <- rsp.units
if battle.reinforcementHeroIds.contains(rse.hero.id)
if rse.status != UnitStatus.Fled && rse.status != UnitStatus.Outlawed
} yield rse).toVector
ChangedProvinceC(
provinceId = battle.defenderProvince,
newUnaffiliatedHeroes = newOutlaws(battleResolution),
removedHostileArmyFactionIds = battle.players.filterNot(_.isDefender).map(_.eagleFid),
newRulingFactionHeroIds = survivingReinforcementUnits.map(_.hero.id),
removedRulingFactionHeroIds = (for {
rsp <- battleResolution.resolvedPlayers
if defenderProvince.rulingFactionId.contains(rsp.eagleFid)
@@ -109,7 +98,6 @@ case class ResolveBattleAction(
out = rsp
) || rse.status == UnitStatus.Outlawed
} yield rse.hero.id).toVector,
newBattalionIds = survivingReinforcementUnits.flatMap(_.optionalBattalionId),
removedBattalionIds = (for {
rsp <- battleResolution.resolvedPlayers
if defenderProvince.rulingFactionId.contains(rsp.eagleFid)
@@ -37,9 +37,9 @@ object MapDescription {
|Eivikia, southwest of Bolve
|Hofolen, west of Eivikia
|Grytrand, southwest of Hofolen
|West Faluria, northwest of Grytrand and south of Yuetia
|Usvol, with a coast on the south but otherwise surrounded by West Faluria
|Laufarvia, southwest of West Faluria
|Faluria, northwest of Grytrand and south of Yuetia
|Usvol, with a coast on the south but otherwise surrounded by Faluria
|Laufarvia, southwest of Faluria
|Soria, northwest of Laufarvia
|Ingia, west of Soria and southeast of Parnia
|
@@ -48,9 +48,9 @@ object MapDescription {
|
|IN THE INTERIOR
|Vovimaria in the west, bordering Parnia, Yuetia, Garholtia, Soria, and Ingia
|Garholtia, east of Vovimaria and also bordering Yuetia, West Faluria, Hella, and Soria
|Hella, south of Garholtia and also bordering West Faluria, Laufarvia, and Soria
|East Faluria, northeast of West Faluria and also bordering Yuetia, Berkorszag, Reigia, Bolve, Eivikia, and Hofolen
|Garholtia, east of Vovimaria and also bordering Yuetia, Faluria, Hella, and Soria
|Hella, south of Garholtia and also bordering Faluria, Laufarvia, and Soria
|Fluria, northeast of Faluria and also bordering Yuetia, Berkorszag, Reigia, Bolve, Eivikia, and Hofolen
|Tegrot, northeast of Reigia and also bordering Berkorszag, Oscasland, and Sigkarl
|Wichel, north of Atfordia and also bordering Berkorszag, Kezonoria, Oryslia, Mesh, Musland, and Oscasland
|Oryslia, northeast of Wichel and also bordering Kezonoria, Turton, Pemeria and Mesh
@@ -222,22 +222,11 @@ class LlmResolver(updateReceiver: LlmUpdateQueuingProxy, isGameValid: GameId =>
.flatMap(p => providerCallers.get(p).toVector.flatten.map(c => (p, c)))
}
/** Select a caller with capacity, preferring the primary provider */
/** Select a caller with capacity, preferring healthy providers */
private def selectCaller: Option[(String, ExternalTextGenerationCaller)] = {
val primary = LlmProvider.stringValue.toLowerCase
val available = getHealthyCallers
// First, try to find a caller from the primary provider with capacity
val primaryWithCapacity = available.filter {
case (p, c) => p == primary && c.getInProgressCount < c.maxConcurrentStreams
}.minByOption { case (_, c) => c.getInProgressCount }
// Only fall back to other providers if primary has no capacity
val selected = primaryWithCapacity.orElse {
available.filter { case (_, c) => c.getInProgressCount < c.maxConcurrentStreams }.minByOption {
case (_, c) => c.getInProgressCount
}
}
val available = getHealthyCallers
val withCapacity = available.filter { case (_, c) => c.getInProgressCount < c.maxConcurrentStreams }
val selected = withCapacity.minByOption { case (_, c) => c.getInProgressCount }
if selected.isEmpty && available.nonEmpty then {
val capacityInfo = available
@@ -370,21 +370,6 @@ object PersistedHistory {
persister.delete(key)
}
/** Delete .e0s files for battles not in the valid set (cleanup after rewind). */
private def deleteOrphanedShardokFiles(
persister: Persister,
validBattleIds: Set[String]
): Unit =
persister.existingKeys
.filter(_.endsWith(persistence.shardokExtension))
.foreach { key =>
val shardokId = key.dropRight(persistence.shardokExtension.length)
if !validBattleIds.contains(shardokId) then {
println(s"[Rewind] Deleting orphaned shardok battle file: $key")
val _ = persister.delete(key)
}
}
/** Load any orphaned individual action results (for crash recovery). */
private def loadIndividualResults(
persister: Persister,
@@ -697,9 +682,8 @@ final case class PersistedHistory(
)
if targetActionCount == count then this
else if targetActionCount == 0 then {
else if targetActionCount == 0 then
// Reset to initial state
PersistedHistory.deleteOrphanedShardokFiles(persister, Set.empty)
PersistedHistory(
startingState = GameStateProto(
runStatus = RunStatus.RUN_STATUS_RUNNING,
@@ -712,33 +696,23 @@ final case class PersistedHistory(
persistedCount = 0,
persister = persister
)
} else if targetActionCount > persistedCount then {
else if targetActionCount > persistedCount then
// Target is within recent history, just truncate recentHistory
val truncatedHistory = recentHistory.take(targetActionCount - persistedCount)
val validBattleIds = truncatedHistory.last.scalaGameState.outstandingBattles.map(_.shardokGameId).toSet
PersistedHistory.deleteOrphanedShardokFiles(persister, validBattleIds)
copy(
recentHistory = truncatedHistory,
shardokHistory = Vector()
recentHistory = recentHistory.take(targetActionCount - persistedCount),
shardokHistory = Vector() // Clear Shardok battles since they may be invalid
)
} else {
else {
// Target is within persisted history - need to reload from disk
val targetStartingIndex = (targetActionCount / resultsPerSaveFile) * resultsPerSaveFile
val pgs = PartialGameUtils.partialGames(persister, targetStartingIndex, persistedCount)
val newStartingState = pgs.head.startingState.get
val allResults = pgs.flatMap(_.results)
val resultsToKeep = allResults.take(targetActionCount - targetStartingIndex)
val newRecentHistory = PersistedHistory.formAwrs(newStartingState, resultsToKeep)
val finalState =
if newRecentHistory.nonEmpty then newRecentHistory.last.scalaGameState
else GameStateConverter.fromProto(newStartingState)
val validBattleIds = finalState.outstandingBattles.map(_.shardokGameId).toSet
PersistedHistory.deleteOrphanedShardokFiles(persister, validBattleIds)
copy(
startingState = newStartingState,
recentHistory = newRecentHistory,
recentHistory = PersistedHistory.formAwrs(newStartingState, resultsToKeep),
shardokHistory = Vector(),
persistedCount = targetStartingIndex
)
@@ -168,7 +168,6 @@ scala_library(
deps = [
":game_parameters_utils",
":great_person",
":loaded_hero_conversion",
":start_date",
":start_game_action_result_utils",
"//src/main/protobuf/net/eagle0/common:tutorial_battle_config_scala_proto",
@@ -185,12 +184,10 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/proto_converters/province:order_type",
"//src/main/scala/net/eagle0/eagle/model/state:army",
"//src/main/scala/net/eagle0/eagle/model/state:battalion_type",
"//src/main/scala/net/eagle0/eagle/model/state:battalion_type_id",
"//src/main/scala/net/eagle0/eagle/model/state:combat_unit",
"//src/main/scala/net/eagle0/eagle/model/state:game_type",
"//src/main/scala/net/eagle0/eagle/model/state:round_phase",
"//src/main/scala/net/eagle0/eagle/model/state:supplies",
"//src/main/scala/net/eagle0/eagle/model/state/battalion/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/chronicle_entry",
"//src/main/scala/net/eagle0/eagle/model/state/faction/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/hero",
@@ -11,7 +11,6 @@ import net.eagle0.eagle.model.proto_converters.hero.ProfessionConverter
import net.eagle0.eagle.model.proto_converters.province.ProvinceOrderTypeConverter
import net.eagle0.eagle.model.proto_converters.BattalionConverter
import net.eagle0.eagle.model.state.*
import net.eagle0.eagle.model.state.battalion.concrete.BattalionC
import net.eagle0.eagle.model.state.chronicle_entry.ChronicleEntry
import net.eagle0.eagle.model.state.faction.concrete.FactionC
import net.eagle0.eagle.model.state.hero.concrete.HeroC
@@ -243,39 +242,6 @@ object TutorialGameCreation {
)
)
// Create reinforcement heroes and battalions for the tutorial
// Use heroIds/battalionIds starting at 100+ to avoid conflicts with regular game entities
val hedrickHero = findHero("Hedrick the Hedge-Merchant")
val johnRanilHero = findHero("John Ranil")
val elenaFyarHero = findHero("Elena Fyar")
// Define reinforcement data: (LoadedHero, heroId, battalionId, battalionTypeId, size, training, armament)
val reinforcementData = Vector(
(hedrickHero, 100, 100, BattalionTypeId.Longbowmen, 600, 75.0, 75.0),
(johnRanilHero, 101, 101, BattalionTypeId.HeavyCavalry, 500, 80.0, 80.0),
(elenaFyarHero, 102, 102, BattalionTypeId.HeavyInfantry, 600, 75.0, 75.0)
)
// Create HeroC objects for reinforcement heroes (assigned to player faction but not to any province)
val reinforcementHeroes = reinforcementData.map {
case (loadedHero, heroId, _, _, _, _, _) =>
LoadedHeroConversion
.toHeroC(loadedHero.withFactionId(playerFid))
.copy(id = heroId)
}
// Create BattalionC objects for reinforcement battalions
val reinforcementBattalions = reinforcementData.map {
case (_, _, battalionId, battalionTypeId, size, training, armament) =>
BattalionC(
id = battalionId,
typeId = battalionTypeId,
size = size,
training = training,
armament = armament
)
}
val actionResult = withBattleSetup.copy(
newBattalionTypes = battalionTypes,
newChronicleEntry = Some(
@@ -283,10 +249,7 @@ object TutorialGameCreation {
date = StartDate.startDate,
generatedTextId = "initial_chronicle_entry"
)
),
// Add reinforcement heroes and battalions to the game state
newHeroes = withBattleSetup.newHeroes ++ reinforcementHeroes,
newBattalions = withBattleSetup.newBattalions ++ reinforcementBattalions
)
)
val heroGenerator = HeroGenerator(
@@ -309,28 +272,45 @@ object TutorialGameCreation {
results = Vector(actionResult)
)
// Create CommonUnit objects for Shardok (uses the same data as the Eagle state objects above)
val reinforcementUnits = reinforcementData.map {
case (loadedHero, heroId, battalionId, battalionTypeId, size, training, armament) =>
val commonBattalionType = battalionTypeId match {
case BattalionTypeId.Longbowmen => CommonBattalionTypeId.LONGBOWMEN
case BattalionTypeId.HeavyCavalry => CommonBattalionTypeId.HEAVY_CAVALRY
case BattalionTypeId.HeavyInfantry => CommonBattalionTypeId.HEAVY_INFANTRY
case BattalionTypeId.LightCavalry => CommonBattalionTypeId.LIGHT_CAVALRY
case BattalionTypeId.LightInfantry => CommonBattalionTypeId.LIGHT_INFANTRY
case _ => CommonBattalionTypeId.LIGHT_INFANTRY
}
createReinforcementUnit(
loadedHero = loadedHero,
heroId = heroId,
battalionId = battalionId,
factionId = playerFid,
battalionType = commonBattalionType,
battalionSize = size,
training = training,
armament = armament
)
}
// Create reinforcement units for the player
// Use heroIds starting after existing heroes (100+ to avoid conflicts)
// Use battalionIds starting after existing battalions (100+ to avoid conflicts)
val hedrickHero = findHero("Hedrick the Hedge-Merchant")
val johnRanilHero = findHero("John Ranil")
val elenaFyarHero = findHero("Elena Fyar")
val reinforcementUnits = Seq(
createReinforcementUnit(
loadedHero = hedrickHero,
heroId = 100,
battalionId = 100,
factionId = playerFid,
battalionType = CommonBattalionTypeId.LONGBOWMEN,
battalionSize = 600,
training = 75.0,
armament = 75.0
),
createReinforcementUnit(
loadedHero = johnRanilHero,
heroId = 101,
battalionId = 101,
factionId = playerFid,
battalionType = CommonBattalionTypeId.HEAVY_CAVALRY,
battalionSize = 500,
training = 80.0,
armament = 80.0
),
createReinforcementUnit(
loadedHero = elenaFyarHero,
heroId = 102,
battalionId = 102,
factionId = playerFid,
battalionType = CommonBattalionTypeId.HEAVY_INFANTRY,
battalionSize = 600,
training = 75.0,
armament = 75.0
)
)
val tutorialConfig = TutorialBattleConfig(
enabled = true,
@@ -231,12 +231,12 @@ class RuntimeValidatorTest extends AnyFlatSpec with Matchers {
}
it should "throw if the price index is negative" in {
val province = ProvinceC(id = 17, priceIndex = -0.4, name = "East Faluria")
val province = ProvinceC(id = 17, priceIndex = -0.4, name = "Fluria")
val ex = the[EagleValidationException] thrownBy
RuntimeValidator.validate(province, RoundPhase.PlayerCommands)
ex.getMessage shouldBe s"validation failed: Invalid price index -0.4 in province 17 (East Faluria)"
ex.getMessage shouldBe s"validation failed: Invalid price index -0.4 in province 17 (Fluria)"
}
it should "not throw if the province id is valid and the priceIndex is valid" in {
+18 -115
View File
@@ -9,7 +9,6 @@ Usage:
python generate_map.py --analyze # Analyze current map, show province sizes
python generate_map.py --generate # Generate new equalized map
python generate_map.py --visualize # Generate visualization of current vs new
python generate_map.py --update-centroids # Update label positions in centroids.json
"""
import argparse
@@ -90,39 +89,6 @@ def load_province_data() -> dict[int, dict]:
return provinces
def find_label_position(mask: np.ndarray) -> tuple[float, float]:
"""Find the best label position for a province.
Finds all pixels with at least 70% of the province's maximum possible
clearance from edges, then picks the one closest to the geometric centroid.
This guarantees good distance from borders/coastlines while keeping labels
as visually centered as possible.
Returns (x, y) in pixel coordinates.
"""
# Geometric centroid (visual center of mass)
y_coords, x_coords = np.where(mask)
mean_x = np.mean(x_coords)
mean_y = np.mean(y_coords)
# Distance from nearest edge for every province pixel
dist = ndimage.distance_transform_edt(mask)
max_clearance = np.max(dist)
# Require at least 70% of the best possible clearance
candidates = dist >= max_clearance * 0.7
if np.any(candidates):
cand_ys, cand_xs = np.where(candidates)
dists_to_centroid = (cand_xs - mean_x)**2 + (cand_ys - mean_y)**2
best = np.argmin(dists_to_centroid)
return float(cand_xs[best]), float(cand_ys[best])
# Fallback: pole of inaccessibility
max_idx = np.unravel_index(np.argmax(dist), dist.shape)
return float(max_idx[1]), float(max_idx[0])
def analyze_provinces(raw_gray: np.ndarray, province_data: dict) -> list[Province]:
"""Calculate centroid and area for each province."""
provinces = []
@@ -136,7 +102,10 @@ def analyze_provinces(raw_gray: np.ndarray, province_data: dict) -> list[Provinc
print(f"Warning: Province {province_id} ({data['name']}) has no pixels")
continue
centroid_x, centroid_y = find_label_position(mask)
# Calculate centroid (center of mass)
y_coords, x_coords = np.where(mask)
centroid_x = np.mean(x_coords)
centroid_y = np.mean(y_coords)
provinces.append(Province(
id=province_id,
@@ -561,85 +530,26 @@ def generate_visualization(
def save_centroids(provinces: list[Province], output_path: Path):
"""Save province centroids to JSON for Unity label positioning.
Preserves hand-tuned fields (orientation, principal_length, etc.)
from existing centroids.json if present.
"""
# Load existing data to preserve hand-tuned fields
existing_by_id = {}
for candidate in [output_path, UNITY_ASSETS / "centroids.json"]:
if candidate.exists():
with open(candidate, 'r') as f:
existing_by_id = {p['id']: p for p in json.load(f)['provinces']}
break
province_list = []
for p in provinces:
entry = {
"id": int(p.id),
"name": p.name,
"centroid_x": round(float(p.centroid[0]), 1),
"centroid_y": round(float(p.centroid[1]), 1),
"area": int(p.area)
}
# Preserve hand-tuned fields from existing file
if p.id in existing_by_id:
for field in ("orientation", "principal_length", "perpendicular_width", "curvature"):
if field in existing_by_id[p.id]:
entry[field] = existing_by_id[p.id][field]
province_list.append(entry)
data = {"provinces": province_list}
"""Save province centroids to JSON for Unity label positioning."""
data = {
"provinces": [
{
"id": int(p.id),
"name": p.name,
"centroid_x": float(p.centroid[0]),
"centroid_y": float(p.centroid[1]),
"area": int(p.area)
}
for p in provinces
]
}
with open(output_path, 'w') as f:
json.dump(data, f, indent=2)
f.write('\n')
print(f"Saved centroids to {output_path}")
def update_centroids(raw_gray: np.ndarray, province_data: dict):
"""Update centroids.json with pole-of-inaccessibility positions.
Only updates centroid_x and centroid_y, preserving all hand-tuned fields
(orientation, principal_length, perpendicular_width, curvature).
"""
centroids_path = UNITY_ASSETS / "centroids.json"
with open(centroids_path, 'r') as f:
data = json.load(f)
existing_by_id = {p['id']: p for p in data['provinces']}
updated = 0
for province_id, pdata in province_data.items():
mask = raw_gray == province_id
area = np.sum(mask)
if area == 0:
print(f"Warning: Province {province_id} ({pdata['name']}) has no pixels")
continue
lx, ly = find_label_position(mask)
new_x = round(lx, 1)
new_y = round(ly, 1)
if province_id in existing_by_id:
entry = existing_by_id[province_id]
old_x, old_y = entry['centroid_x'], entry['centroid_y']
if old_x != new_x or old_y != new_y:
print(f" {pdata['name']:20s}: ({old_x}, {old_y}) -> ({new_x}, {new_y})")
entry['centroid_x'] = new_x
entry['centroid_y'] = new_y
updated += 1
with open(centroids_path, 'w') as f:
json.dump(data, f, indent=2)
f.write('\n')
print(f"Updated {updated} centroids in {centroids_path}")
def save_raw_gray(province_map: np.ndarray, output_path: Path, compress: bool = True):
"""Save province map as raw bytes (optionally gzip compressed)."""
# Flip for Unity (bottom-left origin)
@@ -662,8 +572,6 @@ def main():
parser.add_argument('--analyze', action='store_true', help='Analyze current map')
parser.add_argument('--generate', action='store_true', help='Generate new equalized map')
parser.add_argument('--visualize', action='store_true', help='Generate visualization')
parser.add_argument('--update-centroids', action='store_true',
help='Update centroid positions in centroids.json (preserves hand-tuned fields)')
parser.add_argument('--target-ratio', type=float, default=0.5,
help='Target minimum province size as ratio of average (default: 0.5)')
parser.add_argument('--output-dir', type=Path, default=SCRIPT_DIR / 'output',
@@ -671,7 +579,7 @@ def main():
args = parser.parse_args()
if not any([args.analyze, args.generate, args.visualize, args.update_centroids]):
if not any([args.analyze, args.generate, args.visualize]):
args.analyze = True # Default to analyze
print("Loading province data...")
@@ -682,11 +590,6 @@ def main():
raw_gray = load_raw_gray()
print(f"Map dimensions: {raw_gray.shape[1]} x {raw_gray.shape[0]}")
if args.update_centroids:
print("Updating centroids with pole-of-inaccessibility positions...")
update_centroids(raw_gray, province_data)
return
print("Analyzing provinces...")
provinces = analyze_provinces(raw_gray, province_data)