Skip to content

Commit 4aaeeb6

Browse files
committed
improve Mayors Seal GUI
1 parent 64a2ef2 commit 4aaeeb6

17 files changed

Lines changed: 1731 additions & 470 deletions
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# Mayor's Seal GUI rebuilt on the CUI framework with town statistics
2+
3+
- Time: `2026-07-28 20:24:14 +0800`
4+
- Author: `Kimi-K3 coding agent`
5+
- Status: `completed`
6+
- Scope: `item/townmanager`, `content/town` (data), lang files
7+
8+
## Completed
9+
10+
- Rewrote `item/townmanager/TownManagerScreen` from a vanilla `Screen` (with
11+
legacy `chorda.client.widget` buttons and todo-ridden placeholder modes) to a
12+
Chorda CUI `PrimaryLayer`, visually matching town building GUIs:
13+
`townworkerblock.png` 176x222 frame + left-side `TabImageButtonElement` tabs.
14+
- Four tabs (`TownManagerTab` subclasses, opened client-only via
15+
`CUIScreenWrapper.open`, no container menu):
16+
- Town Overview (`TownOverviewTab`, reuses `tabs/TownInfoPanel`): town name,
17+
population, building/workable counts, average health/mental,
18+
homeless/unemployed counts, day-over-day deltas from history.
19+
- Residents (`TownResidentsTab` + `TownResidentsPanel`): scrollable resident
20+
list + detail (attributes, education, house/work assignment with localized
21+
building names, work proficiencies). Mirrors `TownWorkforcePanel` layout.
22+
- Town Buildings (`TownBuildingsTab` + `TownBuildingsPanel`): building list
23+
(unworkable shown red) + detail (type, coordinates, workable state with
24+
failure reasons, resident capacity via `ITownResidentBuilding`).
25+
- Statistics (`TownStatisticsTab` + `TownStatisticsPanel`): three line
26+
charts (population auto-scaled; avg health/mental fixed 0-100) with latest
27+
value + delta, middle reference line, scale labels, collecting hint when
28+
history has fewer than 2 entries.
29+
- Added `content/town/TownHistoryEntry` (record + Codec): daily snapshot
30+
(day, population, avgHealth, avgMental, buildings). `TeamTownData` now keeps
31+
up to 30 entries (`MAX_HISTORY_ENTRIES`), recorded at the end of
32+
`tickMorning` (same-day settlements overwrite), persisted through the
33+
existing CODEC field `history` and synced to clients by the existing
34+
per-tick `TeamTownDataS2CPacket` full sync. `TeamTown#getHistory` exposes it.
35+
- `TownManagerClientHelper.openScreen()` now opens via `CUIScreenWrapper`.
36+
- Added `gui.frostedheart.town_manager.*` keys (zh_cn + en_us), including
37+
per-building-type names under `...town_manager.building.*` with
38+
`translatableWithFallback` fallback to the class simple name.
39+
40+
## Decisions
41+
42+
- History rides the existing full-data sync instead of a new packet: 30 small
43+
entries are negligible and no sync cadence changes were needed.
44+
- Item GUI stays client-only (no Menu/NetworkHooks) because the seal is a
45+
read-only observer; this matches how the old screen and EditUtils work.
46+
- Panels read fresh data through `Supplier<TeamTown>/Supplier<TeamTownData>`
47+
every render, so the GUI follows sync updates live; selection is normalized
48+
by UUID/BlockPos when entries disappear.
49+
- Old `town_manage_screen.png` texture is now unreferenced but left in the
50+
asset tree untouched.
51+
52+
## Validation
53+
54+
- `JAVA_HOME='C:\Program Files\Java\jdk-17' ./gradlew build --offline` passed.
55+
NOTE: system JAVA_HOME points to JDK 11 and makes Gradle worker daemons
56+
crash with `GradleWorkerMain` ClassNotFoundException; always set JDK 17.
57+
- Both lang JSON files parse; `git diff --check` clean.
58+
- No references to the removed old screen API remain.
59+
60+
## Remaining
61+
62+
- Not run in game: verify tab hit areas, scrollbar feel, chart readability,
63+
and text widths at common GUI scales.
64+
- Statistics need two daily settlements before charts appear (by design).
65+
- Pre-existing issue noticed but untouched: `TeamTownData` codec constructor
66+
ignores the decoded `labour`/`maxLabour` (assigns 0), so labour values reset
67+
on save reload.
68+
69+
## Follow-up: HouseBuilding resident count fix (20:51)
70+
71+
`TownBuildingsPanel` initially used `ITownResidentBuilding.getResidentsID()` to
72+
display resident counts. HouseBuilding's CODEC does not serialize
73+
`residentsUUID` (only `maxResident`; the `HouseMenu` works around this by
74+
filtering `Resident.housePos`). This caused houses to always show 0/X on the
75+
client. Fixed by counting residents in the position-based way:
76+
77+
```
78+
boolean isHouse = !(building instanceof ITownResidentWorkBuilding);
79+
for (Resident r : town.getAllResidents())
80+
if (pos.equals(isHouse ? r.getHousePos() : r.getWorkPos()))
81+
count++;
82+
```

src/main/java/com/teammoeg/frostedheart/content/town/TeamTown.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,16 @@ public TeamTownResourceHolder getResourceHolder() {
221221
return data.resources;
222222
}
223223

224+
/**
225+
* Get the daily snapshot history of the town, newest entry last.
226+
* Used by information GUIs such as the Mayor's Seal.
227+
*
228+
* @return unmodifiable view is not guaranteed; treat as read-only
229+
*/
230+
public List<TownHistoryEntry> getHistory() {
231+
return data.getHistory();
232+
}
233+
224234
//@Override
225235
public Optional<TeamTownData> getTownData() {
226236
return Optional.of(data);

src/main/java/com/teammoeg/frostedheart/content/town/TeamTownData.java

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,10 @@ public class TeamTownData implements SpecialData{
9090
.fieldOf("labour").forGetter(o -> o.labour),
9191

9292
CodecUtil.defaultSupply(CodecUtil.catchingCodec(Codec.INT), () -> 0)
93-
.fieldOf("maxLabour").forGetter(o -> o.maxLabour)
93+
.fieldOf("maxLabour").forGetter(o -> o.maxLabour),
94+
95+
CodecUtil.defaultSupply(CodecUtil.catchingCodec(TownHistoryEntry.CODEC.listOf()), ArrayList::new)
96+
.fieldOf("history").forGetter(o -> o.history)
9497

9598
)
9699

@@ -121,14 +124,25 @@ public class TeamTownData implements SpecialData{
121124
int labour=0;
122125
@Getter
123126
int maxLabour=0;
127+
/**
128+
* 城镇每日快照历史,最新条目在末尾,最多保留 {@link #MAX_HISTORY_ENTRIES} 条。
129+
* 随存档持久化,并随城镇数据全量同步下发客户端。
130+
* <p>
131+
* Daily snapshot history of the town, newest entry last, capped at
132+
* {@link #MAX_HISTORY_ENTRIES} entries. Persisted with the save and synced
133+
* to clients with the full town data sync.
134+
*/
135+
@Getter
136+
List<TownHistoryEntry> history = new ArrayList<>();
124137

125138
@Getter
126139
private final DataSyncCache dataSyncCache = new DataSyncCache();
127140

128141

129142

130-
public TeamTownData(String name, TeamTownResourceHolder resources, Map<BlockPos, ITownBuilding> buildings, Map<UUID, Resident> residents, Map<TerrainResourceType, TerrainResourceData> terrainResource,int labour,int maxlabour) {
143+
public TeamTownData(String name, TeamTownResourceHolder resources, Map<BlockPos, ITownBuilding> buildings, Map<UUID, Resident> residents, Map<TerrainResourceType, TerrainResourceData> terrainResource,int labour,int maxlabour, List<TownHistoryEntry> history) {
131144
super();
145+
this.history = new ArrayList<>(history);
132146
this.name = name;
133147
this.resources = resources;
134148
// 在批量 put 之前绑定 attach/detach,使反序列化得到的建筑/居民也自动接上(或解除)dataSyncCache 监听器
@@ -216,6 +230,39 @@ public void tickMorning(ServerLevel world) {
216230
residents.values().forEach(Resident::resetDailyProficiencyGrowth);
217231
this.buildingsWork(world);
218232
this.recoverResources();
233+
this.recordDailySnapshot(world);
234+
}
235+
236+
/**
237+
* 历史快照的最大保留条数。
238+
* <p>
239+
* Maximum number of retained history entries.
240+
*/
241+
public static final int MAX_HISTORY_ENTRIES = 30;
242+
243+
/**
244+
* 在每日结算完成后记录一条城镇快照。同一天重复结算时覆盖当天条目,
245+
* 超过 {@link #MAX_HISTORY_ENTRIES} 条时丢弃最旧的记录。
246+
* <p>
247+
* Records a daily town snapshot after settlement. Repeated settlements on
248+
* the same day overwrite that day's entry; oldest entries are dropped once
249+
* {@link #MAX_HISTORY_ENTRIES} is exceeded.
250+
*
251+
* @param world 服务端世界 / server world instance
252+
*/
253+
void recordDailySnapshot(ServerLevel world) {
254+
long day = world.getDayTime() / 24000L;
255+
double avgHealth = residents.values().stream().mapToDouble(Resident::getHealth).average().orElse(0);
256+
double avgMental = residents.values().stream().mapToDouble(Resident::getMental).average().orElse(0);
257+
TownHistoryEntry entry = new TownHistoryEntry(day, residents.size(), avgHealth, avgMental, buildings.size());
258+
if (!history.isEmpty() && history.get(history.size() - 1).day() == day) {
259+
history.set(history.size() - 1, entry);
260+
} else {
261+
history.add(entry);
262+
}
263+
while (history.size() > MAX_HISTORY_ENTRIES) {
264+
history.remove(0);
265+
}
219266
}
220267

221268
/**
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/*
2+
* Copyright (c) 2026 TeamMoeg
3+
*
4+
* This file is part of Frosted Heart.
5+
*
6+
* Frosted Heart is free software: you can redistribute it and/or modify
7+
* it under the terms of the GNU General Public License as published by
8+
* the Free Software Foundation, version 3.
9+
*
10+
* Frosted Heart is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU General Public License
16+
* along with Frosted Heart. If not, see <https://www.gnu.org/licenses/>.
17+
*
18+
*/
19+
20+
package com.teammoeg.frostedheart.content.town;
21+
22+
import com.mojang.serialization.Codec;
23+
import com.mojang.serialization.codecs.RecordCodecBuilder;
24+
25+
/**
26+
* 城镇每日快照。每日城镇结算(tickMorning)后记录一条,
27+
* 用于镇长印章 GUI 中的数据统计折线图。
28+
* <p>
29+
* Daily snapshot of a town. Recorded after the daily town settlement
30+
* (tickMorning), used by the Mayor's Seal GUI to draw statistic charts.
31+
*
32+
* @param day 记录时的世界天数 / the world day when recorded
33+
* @param population 居民数量 / resident count
34+
* @param avgHealth 居民平均生命 / average resident health (0-100)
35+
* @param avgMental 居民平均精神 / average resident mental (0-100)
36+
* @param buildings 城镇建筑数量 / town building count
37+
*/
38+
public record TownHistoryEntry(long day, int population, double avgHealth, double avgMental, int buildings) {
39+
40+
public static final Codec<TownHistoryEntry> CODEC = RecordCodecBuilder.create(t -> t.group(
41+
Codec.LONG.fieldOf("day").forGetter(TownHistoryEntry::day),
42+
Codec.INT.fieldOf("population").forGetter(TownHistoryEntry::population),
43+
Codec.DOUBLE.fieldOf("avgHealth").forGetter(TownHistoryEntry::avgHealth),
44+
Codec.DOUBLE.fieldOf("avgMental").forGetter(TownHistoryEntry::avgMental),
45+
Codec.INT.fieldOf("buildings").forGetter(TownHistoryEntry::buildings)
46+
).apply(t, TownHistoryEntry::new));
47+
}

src/main/java/com/teammoeg/frostedheart/content/town/buildings/warehouse/WarehouseBuilding.java

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ public class WarehouseBuilding extends AbstractTownBuilding {
4343
BlockPos.CODEC.optionalFieldOf("pos",BlockPos.ZERO).forGetter(o -> o.pos),
4444
Codec.BOOL.optionalFieldOf("isStructureValid",false).forGetter(o -> o.isStructureValid()),
4545
OccupiedVolume.CODEC.optionalFieldOf("occupiedVolume",OccupiedVolume.EMPTY).forGetter(o -> o.getOccupiedVolume()),
46+
Codec.BOOL.optionalFieldOf("initialized", false).forGetter(o -> o.isInitialized()),
47+
Codec.BOOL.optionalFieldOf("occupiedAreaOverlapped", false).forGetter(o -> o.isOccupiedAreaOverlapped()),
4648
Codec.DOUBLE.optionalFieldOf("capacity",0D).forGetter(o -> o.getCapacity()),
4749
Codec.INT.optionalFieldOf("area",0).forGetter(o -> o.getArea()),
4850
Codec.INT.optionalFieldOf("volume",0).forGetter(o -> o.getVolume()),
@@ -77,15 +79,19 @@ public WarehouseBuilding(BlockPos pos) {
7779
* @param area the area
7880
* @param volume the volume
7981
*/
80-
public WarehouseBuilding(BlockPos pos, boolean isStructureValid, OccupiedVolume occupiedVolume, double capacity, int area, int volume,int decorationAmount) {
81-
this(pos, isStructureValid, occupiedVolume, capacity, area, volume, decorationAmount, List.of());
82+
public WarehouseBuilding(BlockPos pos, boolean isStructureValid, OccupiedVolume occupiedVolume, boolean initialized,
83+
boolean occupiedAreaOverlapped, double capacity, int area, int volume, int decorationAmount) {
84+
this(pos, isStructureValid, occupiedVolume, initialized, occupiedAreaOverlapped, capacity, area, volume, decorationAmount, List.of());
8285
}
8386

84-
public WarehouseBuilding(BlockPos pos, boolean isStructureValid, OccupiedVolume occupiedVolume, double capacity,
87+
public WarehouseBuilding(BlockPos pos, boolean isStructureValid, OccupiedVolume occupiedVolume, boolean initialized,
88+
boolean occupiedAreaOverlapped, double capacity,
8589
int area, int volume, int decorationAmount, List<BlockPos> interfacePositions) {
8690
super(pos);
8791
this.setIsStructureValid(isStructureValid);
8892
this.setOccupiedVolume(occupiedVolume);
93+
this.setInitialized(initialized);
94+
this.setOccupiedAreaOverlapped(occupiedAreaOverlapped);
8995
this.setCapacity(capacity);
9096
this.setArea(area);
9197
this.setVolume(volume);

0 commit comments

Comments
 (0)