mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-10 02:21:33 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a190dbbc0d |
No files matched your search
@@ -198,5 +198,3 @@ new pushes, we add it again.
|
||||
|
||||
[Discord](https://discord.gg/playhorizon) for questions and ideas, [issues](https://github.com/horizon-rust/horizon/issues)
|
||||
for bugs. To take something on, `/claim` its issue ([above](#claim-before-you-start)).
|
||||
|
||||
How Horizon is run, and how people join the Team or lead a group: [GOVERNANCE.md](GOVERNANCE.md).
|
||||
-483
@@ -1,483 +0,0 @@
|
||||
# Horizon governance
|
||||
|
||||
> Adopted 2026-11-09 by the founder as his last decision as head of the project. It takes effect when he
|
||||
> announces that he is stepping down. From then on only the council changes it, by a 2/3 poll (section 15).
|
||||
|
||||
Horizon has no boss. A **council of equals** decides by polls. **Theme groups** do the work in their areas, each
|
||||
with a **lead**. The **Horizon Team** reviews and merges code. **Moderators** keep the Discord server and the
|
||||
official server safe. Every role of trust is earned with work the project can measure, and it ends when that work
|
||||
stops.
|
||||
|
||||
## Contents
|
||||
|
||||
1. [Principles](#1-principles)
|
||||
2. [The levels at a glance](#2-the-levels-at-a-glance)
|
||||
3. [Each level in detail](#3-each-level-in-detail)
|
||||
4. [The council and its polls](#4-the-council-and-its-polls)
|
||||
5. [The selection filter](#5-the-selection-filter)
|
||||
6. [Theme groups](#6-theme-groups)
|
||||
7. [Moderation and appeals](#7-moderation-and-appeals)
|
||||
8. [Conflicts and hard cases](#8-conflicts-and-hard-cases)
|
||||
9. [The age rule (18+)](#9-the-age-rule-18)
|
||||
10. [The agents](#10-the-agents)
|
||||
11. [Accounts and the handover path](#11-accounts-and-the-handover-path)
|
||||
12. [Money](#12-money)
|
||||
13. [Transparency](#13-transparency)
|
||||
14. [Rules that are never voted on](#14-rules-that-are-never-voted-on)
|
||||
15. [Changing this document](#15-changing-this-document)
|
||||
|
||||
## 1. Principles
|
||||
|
||||
- **Equals.** Every council member has one vote. Nobody, the founder included, stands above the others.
|
||||
- **The founder.** Ludovic keeps the title of **Founder**: his role stays at the top of the member list and he is
|
||||
credited as Horizon's founder everywhere. The title is symbolic. It carries no extra vote or power: on the council
|
||||
he counts exactly like every other member.
|
||||
- **Evidence, not popularity.** You get a role by doing the work first. Kira measures that work: merged pull
|
||||
requests, reviews, triage, support answers and days active. A vote then confirms it.
|
||||
- **Small, clear powers.** Each role can do a short, written list of things. Anything else goes to the council.
|
||||
- **Roles are on loan.** Every role has a 30-day probation and an inactivity rule. Stepping down for inactivity is
|
||||
not a sanction, and you can come back.
|
||||
- **Public by default.** Polls, results and rules are public. Only matters about a person stay private, and even then
|
||||
the result is published.
|
||||
- **18+ for roles of trust** (section 9).
|
||||
- **Mojang's and Microsoft's rules come first.** They are never put to a vote (section 14).
|
||||
|
||||
## 2. The levels at a glance
|
||||
|
||||
From the most open level to the most trusted. A person can hold several levels at once: a group lead can also be on
|
||||
the Horizon Team, for example.
|
||||
|
||||
| Level | What it can do | How you get in | How you leave | 18+ |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| **Member** | Play, chat, report bugs, suggest ideas, open issues and pull requests, pick group roles | Join the Discord server or the GitHub repo | Leave, or get banned | No |
|
||||
| **Contributor** (⛏️ Iron, 🥇 Gold, 💎 Diamond, 🟪 Netherite ranks) | Write in #contributors; `/claim` issues; nominate people for group lead (if active in that group) | One merged pull request in a public horizon-rust repo, linked with `/link-github` and `/verify-github`. Ranks come from merged pull requests: 10, 40, 100 and 200 | Contributor status and ranks stay | No |
|
||||
| **Group member** | Read and write in the group's channel; get the group's pings; take part in the group's advisory poll on its lead | Pick the group's role in Channels & Roles, or with `/groups` | Remove the role | No |
|
||||
| **🤝 Support** | Answer and close posts in #horizon-support | `/apply`; vote of the mods and the Team (as today) | Resign, 30 days inactive, or removed by the Chief of Mods or the council | No |
|
||||
| **🛡️ Moderator** | Time out, delete messages, manage threads, quarantine. No bans | `/apply`, then the selection filter; mods' vote; the council may object within 24 h | Resign, 30 days inactive, suspended by the Chief of Mods, removed by a council poll (simple majority) | **Yes** |
|
||||
| **🛡️ Chief of Mods** | A moderator's powers, plus bans; leads the mods; suspends a mod at once pending the council | Council poll (simple majority), from the mods with at least 30 days | Resign, 30 days inactive, council poll (simple majority) | **Yes** |
|
||||
| **🧭 Group lead** | Decides inside the group's topic (section 6); manages the group's channel; triage on the group's GitHub issues | Nomination, the filter, a 24 h public discussion with an advisory group poll, then a council poll (simple majority); 30 days probation | Resign, 30 days inactive (automatic), council poll (simple majority); confirmed again every 6 months | **Yes** |
|
||||
| **🌌 Horizon Team** | Review and merge pull requests (GitHub `horizon-team`, write access); `/nightly` and triage commands (trusted reviewers); #horizon-team | Nomination, the filter, a 24 h public discussion, then a council poll (2/3); 30 days probation | Resign, 60 days without a merge or review (automatic), council poll (2/3, without the person) | **Yes** |
|
||||
| **🏛️ Council** | Decides everything else by poll (section 4); bans; emergency actions by any 2 members | Nominated by a council member, the filter, a 24 h public discussion, then a council poll (2/3); 30 days probation | Resign, inactivity (section 5.5), council poll (2/3, without the person) | **Yes** |
|
||||
|
||||
**The account holder** isn't a level. The founder (Ludovic) still holds the project's accounts (GitHub organisation
|
||||
owner, Discord server owner, the Microsoft app approved by Mojang, the domains, Railway, money). He carries out the
|
||||
council's decisions on them and hands them over step by step (section 11). As a council member he has one vote,
|
||||
like everyone else.
|
||||
|
||||
## 3. Each level in detail
|
||||
|
||||
### Member
|
||||
|
||||
Anyone in the Discord server or on GitHub. Members follow the server rules and Horizon's
|
||||
[contributing guide](CONTRIBUTING.md). They can propose anything: an idea in #horizon-ideas, a bug in #horizon-bugs,
|
||||
an issue or a pull request on GitHub, or a request for a council poll (any council member can pick it up).
|
||||
|
||||
### Contributor
|
||||
|
||||
- **Discord:** the 🛠️ Contributor role writes in #contributors and in the threads of #polls-and-decisions. A rank
|
||||
role (⛏️ Iron at 10 merged pull requests, 🥇 Gold at 40, 💎 Diamond at 100, 🟪 Netherite at 200, counted over every
|
||||
public horizon-rust repo) shows how much has been merged. Ranks only go up.
|
||||
- **GitHub:** no repository role. Anyone can `/claim` and `/unclaim` their own issues.
|
||||
- **Alone:** their own work.
|
||||
|
||||
### Group member
|
||||
|
||||
Each theme group (section 6) has a role that anyone can pick and drop. It opens the group's channel and receives the
|
||||
group's pings. Picking a role grants no power. A group member counts as **active in the group** when Kira has seen at
|
||||
least one counted action in the group's area (section 5.1) in the last 60 days. Only active group members can
|
||||
nominate a lead or vote in the group's advisory poll.
|
||||
|
||||
### 🤝 Support
|
||||
|
||||
Unchanged: Support members help players in #horizon-support. They answer every post there and mark posts solved.
|
||||
The role carries no permission, so it has no age rule. They join through `/apply`, and the mods and the Team vote.
|
||||
|
||||
### 🛡️ Moderators and the Chief of Mods
|
||||
|
||||
- **Discord:** time out members, delete messages, manage threads and nicknames, read the audit log, quarantine
|
||||
through Kira, and see the staff channels. **Moderators don't ban.** Bans are done by council members and the Chief of
|
||||
Mods. Kira's automatic actions (the honeypot, clear scams, illegal images) stay as they are.
|
||||
- **Official server:** server operators (ops) are moderators in game. They need the 18+ rule and are confirmed the
|
||||
same way (section 6, Server and SMP).
|
||||
- **Alone:** a timeout, a deleted message, a quarantine, with a reason in the mod case.
|
||||
- **The Chief of Mods** is the first contact for moderation questions. They organise the mods, can ban, and can take a
|
||||
moderator's role away at once when there is a problem. The council confirms or reverses that within 72 hours.
|
||||
|
||||
### 🧭 Group leads
|
||||
|
||||
Section 6 says what a lead decides alone and what goes to the council.
|
||||
|
||||
- **Discord:** in their group's channel only, delete messages, manage and pin threads, and ping the group role. The
|
||||
🧭 lead role carries no server-wide permission.
|
||||
- **GitHub:** triage on the issues and pull requests that carry their group's label (`/label`, `/unlabel`, `/close`,
|
||||
`/reopen`, `/unclaim`), from a list of group leads that grants no repository role. Leads merge only if they are on
|
||||
the Horizon Team too.
|
||||
|
||||
### 🌌 Horizon Team
|
||||
|
||||
The Horizon Team **merges pull requests**. Its rules are in [the release flow](docs/release-flow.md): read the whole
|
||||
diff, never merge your own pull request, and use `needs-proof` for anything risky.
|
||||
|
||||
- **GitHub:** member of the `horizon-team` team (write access to the Horizon repos), on the trusted reviewers list
|
||||
(`/nightly` and triage commands).
|
||||
- **Discord:** the 🌌 Horizon Team role, #horizon-team, and writing in #contributors and the read-only channels.
|
||||
Moderation permissions belong to the moderators and the council: a Team member moderates only if they also hold
|
||||
one of those roles.
|
||||
- **During the 30-day probation:** they merge only ordinary pull requests. They don't merge `needs-proof` pull
|
||||
requests, don't approve files that CODEOWNERS guards, and don't use `/nightly`.
|
||||
|
||||
### 🏛️ Council
|
||||
|
||||
- **Discord:** the 🏛️ Council role: the moderators' permissions plus ban and mention @everyone; #council (private);
|
||||
starting polls in #polls-and-decisions.
|
||||
- **GitHub:** the `council` team. Over time it takes over the founder's CODEOWNERS review and his owner powers in the
|
||||
release tools (section 11).
|
||||
- **Alone (any one member):** start a poll; pick up a member's request for a poll; do anything their other roles
|
||||
allow.
|
||||
- **Any two members together:** an emergency action (section 4.6).
|
||||
- **Everything else** is decided by poll.
|
||||
|
||||
## 4. The council and its polls
|
||||
|
||||
### 4.1 Founding council
|
||||
|
||||
kacpherek, levzzz5154, rdbtCVS, Online5277, dezer-x and Ludovic: six members, one vote each. The founding members
|
||||
don't go through the filter or a probation. They do make the 18+ declaration (section 9). The council has at most
|
||||
**9** members. It adds a seat only when a 2/3 poll says it needs one.
|
||||
|
||||
### 4.2 How a poll works
|
||||
|
||||
| Rule | Value |
|
||||
| --- | --- |
|
||||
| Who starts one | Any council member, with `/poll` (Kira). Anyone else asks a council member to start it |
|
||||
| Where | #polls-and-decisions, in public. Polls about a person (removal, appeals, the age rule) run in #council, and only their result is public |
|
||||
| How long | 24 hours. It closes early only if every council member has voted |
|
||||
| Ballot | Yes, No or Abstain. You can change it until the poll closes |
|
||||
| Quorum | At least half of the council votes (Abstain counts). With 6 members, 3 votes |
|
||||
| Simple majority | More Yes than No |
|
||||
| Two-thirds | Yes is at least 2/3 of the Yes and No votes, **and at least 3 Yes votes** |
|
||||
| Tie | Nothing changes |
|
||||
| No quorum | Nothing changes. Kira reminds the members who haven't voted 12 hours and 2 hours before the end |
|
||||
| About yourself | You don't vote, and you don't count in the council's size for that poll |
|
||||
| Asking again | A poll that failed can't be asked again for 7 days, unless two members agree that new facts justify it |
|
||||
|
||||
### 4.3 Which threshold applies
|
||||
|
||||
| Simple majority | Two-thirds |
|
||||
| --- | --- |
|
||||
| Day-to-day project decisions: direction, priorities, settling disputes between groups, a public statement | Adding council members and Horizon Team members |
|
||||
| Group leads and co-leads: appointing, removing, confirming every 6 months | Removing a council member or a Team member (without the person concerned) |
|
||||
| Moderators, the Chief of Mods, Support: confirming, removing | Rules: this document, the server rules, the contributing policy, the licence |
|
||||
| Ending a probation early | Money (section 12) |
|
||||
| Confirming an emergency action | Account handover steps (section 11) |
|
||||
| Second-level moderation appeals | Waiving part of the selection filter (section 5.4) |
|
||||
| Bringing back someone who stepped down for inactivity, within 6 months | Changing the council's size |
|
||||
|
||||
**When in doubt, two-thirds.** Any council member can say a poll needs 2/3, and Kira then counts it at 2/3.
|
||||
|
||||
### 4.4 Discussion
|
||||
|
||||
Polls that add someone to the council, the Team or a lead post start with a **24-hour public discussion** (section
|
||||
5.3). Other polls are discussed in their own thread while they run. A member who wants more time votes No and says
|
||||
why. A failed poll can come back after 7 days.
|
||||
|
||||
### 4.5 Conflicts of interest
|
||||
|
||||
A member doesn't vote on a poll about themselves: their own role, their own removal, an appeal of their own
|
||||
moderation action, or money paid to them. They say so in the poll's thread and Kira leaves them out of the count.
|
||||
|
||||
### 4.6 Emergencies
|
||||
|
||||
For **security** (a leaked secret, a malicious pull request or mod, a hijacked account), **Mojang or Microsoft** (a
|
||||
request from them, a risk to the approved sign-in) or **legal** matters (a takedown, the law), **any two council
|
||||
members act at once**. They may:
|
||||
|
||||
- pause releases and the Nightly;
|
||||
- take a mod down from the registry;
|
||||
- remove someone's roles or access for now;
|
||||
- lock or hide channels and content;
|
||||
- ask the account holder to rotate a secret or a key.
|
||||
|
||||
They record it with `/emergency` in Kira, and both of them confirm it there. Kira opens a council poll at once
|
||||
(simple majority) to confirm it. If the poll doesn't pass, the action is undone where it can be. An emergency never
|
||||
adds members, changes rules or spends money.
|
||||
|
||||
### 4.7 Removing a council member
|
||||
|
||||
A 2/3 poll of the other members, in #council. The person concerned doesn't vote and doesn't count in the quorum.
|
||||
Only the result is published, never the reasons. Other ways to leave: resigning, inactivity (section 5.5), or a
|
||||
false age declaration (section 9).
|
||||
|
||||
### 4.8 When the council drops below a quorum
|
||||
|
||||
- **Fewer than 5 members:** Kira opens nominations for the council at once. The council fills seats within 30 days.
|
||||
- **Fewer than 3 members, or fewer than 3 who voted in the last 30 days:** an **interim council** forms. It counts the
|
||||
remaining council members, the Horizon Team and the group leads past probation. Its only task is adding council
|
||||
members, by its own 2/3 poll with a quorum of half. Any two of its members can take emergency actions. All other
|
||||
decisions wait. The Team keeps merging and the leads keep leading.
|
||||
- **Nobody left:** the Team and the group leads form the interim council in the same way.
|
||||
|
||||
## 5. The selection filter
|
||||
|
||||
Nobody becomes a lead, a Team member or a council member just because they are well liked or talk a lot. Kira
|
||||
measures the work, and only people above a floor can be nominated. The council then decides among them.
|
||||
|
||||
### 5.1 What Kira measures
|
||||
|
||||
| Signal | What counts | Source |
|
||||
| --- | --- | --- |
|
||||
| **Merged pull requests** | Pull requests merged in a public horizon-rust repo. For Modding, also in the mod API's fork (arismoko/horizon). Counted overall, and per group by label or by files touched | GitHub search, as the ranks are counted today |
|
||||
| **Reviews** | Others' pull requests you reviewed: approved, asked for changes, or left a review with comments. One per pull request | GitHub search `reviewed-by:` and Kira's GitHub webhook |
|
||||
| **Triage** | Triage commands run (`/label`, `/close`, duplicates), issues labelled or closed, bug posts routed on Discord | The triage workflow's comments and Kira's webhook |
|
||||
| **Support answers** | #horizon-support posts you answered that ended solved | Kira's support feature |
|
||||
| **Active days** | Distinct days with any of the above, or with messages in #contributors or a group's channel. Chat elsewhere doesn't count | Kira |
|
||||
| **Tenure** | Days since your first counted contribution (since you joined the server, for moderators) | GitHub, Discord |
|
||||
| **Clean record** | No timeout, quarantine or ban in the last 90 days | Kira's mod cases |
|
||||
| **Verified GitHub** | `/verify-github` done | Kira |
|
||||
| **18+ declaration** | Made (section 9) | Kira |
|
||||
|
||||
Kira turns these into an **evidence card**: the person's numbers, overall and in the group's area, for the last 60
|
||||
days and in total. Anyone can see their own card with `/evidence`. A nomination shows the card publicly.
|
||||
|
||||
### 5.2 The floors
|
||||
|
||||
A nomination is refused below these floors.
|
||||
|
||||
| Role | Floor |
|
||||
| --- | --- |
|
||||
| **Group lead** | Verified GitHub; tenure of 30 days or more; **in the group's area: 8 or more merged pull requests, or 20 or more reviews** (Translations: 5 or more merged translation pull requests; Server and SMP: 8 pull requests, or 30 days as an official server operator with activity); 15 or more active days in the last 60; clean record; 18+ |
|
||||
| **Moderator** | In the server for 30 days or more; Kira level 10 or more; 15 or more active days in the last 60; clean record; 18+ |
|
||||
| **Chief of Mods** | Moderator for 30 days or more, past probation; 18+ |
|
||||
| **Horizon Team** | 🥇 Gold rank (40 merged pull requests); 20 or more reviews of others' pull requests; tenure of 30 days or more; 20 or more active days in the last 60; clean record; verified GitHub; 18+ |
|
||||
| **Council** | Horizon Team member, group lead or Chief of Mods for 60 days or more, past probation; 20 or more active days in the last 60; clean record; 18+ |
|
||||
|
||||
The numbers are checked again once a year (section 15). They should stay reachable for people who really work on
|
||||
Horizon and out of reach for people who don't.
|
||||
|
||||
### 5.3 The path to a role
|
||||
|
||||
1. **Nomination.** Run `/nominate member: role: [group:]` in Kira.
|
||||
- A **group lead** can be nominated by an active member of that group or by a council member. A self-nomination
|
||||
needs a second from another active group member or a council member.
|
||||
- A **Team member** can be nominated by a council or Team member. Someone can also nominate themselves with
|
||||
`/apply`, if a council or Team member sponsors them.
|
||||
- A **council member** can only be nominated by a council member.
|
||||
- **Moderators** apply with `/apply`.
|
||||
2. **The filter.** Kira checks the floor and the 18+ declaration. Below the floor, Kira refuses and says which line
|
||||
is short.
|
||||
3. **Public discussion, 24 hours.** Kira posts the nomination and the evidence card in a thread of
|
||||
#polls-and-decisions. Contributors and the council can comment. For a lead, Kira also runs an **advisory poll** in
|
||||
the group's channel for the same 24 hours. Only active group members vote in it, and the council sees the result.
|
||||
4. **Council poll, 24 hours.** Simple majority for a lead, 2/3 for the Team and the council. For moderators, the mods'
|
||||
vote replaces this step, and any two council members can object within 24 hours; the council then decides by poll.
|
||||
5. **Probation, 30 days.** The role is held in full, except for the Team limits in section 3. At day 25, Kira asks the
|
||||
council in #council whether anything is wrong. Probation ends by itself at day 30, unless a council poll (simple
|
||||
majority) ended it earlier.
|
||||
|
||||
### 5.4 Exceptions
|
||||
|
||||
Some valuable people don't show up in the numbers, such as a designer for the website. The council can **waive the
|
||||
tenure or the area floors** by a 2/3 poll that says why. The age rule and the clean record are never waived.
|
||||
|
||||
### 5.5 Inactivity
|
||||
|
||||
Kira checks every day. It warns the person in a DM and in #council, then posts the step-down in
|
||||
#polls-and-decisions with neutral wording ("stepped down: inactive"), and removes the Discord role. The account
|
||||
holder or an org owner removes GitHub access when the Team role ends.
|
||||
|
||||
| Role | What counts as activity | Warning | Steps down |
|
||||
| --- | --- | --- | --- |
|
||||
| Group lead | A counted action in the group's area, or messages in the group's channel | Day 21 | Day 30 |
|
||||
| Horizon Team | A merge or a review | Day 45 | Day 60 |
|
||||
| Moderator, Chief of Mods | A moderation action, or messages in the staff channels | Day 21 | Day 30 |
|
||||
| Support | An answer in #horizon-support | Day 21 | Day 30 |
|
||||
| Council | A vote in a poll. At 30 days with no vote, while at least 2 polls were held | Day 23 | Day 30, or 45 days with no counted activity at all |
|
||||
|
||||
- **Away.** Anyone can announce an absence with `/away` for up to 45 days, once every 6 months. Away days don't
|
||||
count. A lead names someone from the group to cover for them.
|
||||
- **Coming back.** Within 6 months: a simple-majority poll, with no new probation. After 6 months: the normal path.
|
||||
|
||||
### 5.6 Terms
|
||||
|
||||
- **Group leads** are confirmed every 6 months by a simple-majority poll. If someone else in the group passes the
|
||||
floor and is nominated, both go on the same poll.
|
||||
- **Council and Team** have no fixed term. They stay as long as they are active and keep the council's trust. Removal
|
||||
is always possible (section 4.7).
|
||||
|
||||
## 6. Theme groups
|
||||
|
||||
### 6.1 The groups
|
||||
|
||||
| Group | Covers | GitHub | Discord |
|
||||
| --- | --- | --- | --- |
|
||||
| **Modding** | The mod API, native and content mods, mixins, the mod registry, `cargo horizon-mod` | The mod API lives in Ari's fork **arismoko/horizon**. Its owner runs that repo by their own rules; the council governs only what is Horizon's: horizon-rust/horizon-mods, horizon-mixins, horizon-native-mod-template, the registry. Label `mods` | The 🧩 MODDING category (#modding, #horizon-modding-team); roles 🧩 Modder (picked) and 🧩 Horizon Modding Team (the lead gives it) |
|
||||
| **Shaders and rendering** | The renderer, shaders, shader packs, OptiFine and Polytone pack formats | Label `rendering`; `crates/horizon-client-render` (gfx), `*.wgsl`, `horizon-shaders`, `horizon-shader-api`, `horizon-optifine`, `horizon-polytone` | #shaders-rendering |
|
||||
| **Performance** | Frame rate, memory, server tick, benchmarks | Label `perf`; `horizon-bench`, the benchmark tools | #performance |
|
||||
| **Launcher** | The launcher: installs, updates, instances, its pages | Label `launcher`; `launcher/` | #launcher |
|
||||
| **Server and SMP** | Dedicated server, hosting, the anticheat, multiplayer, and the official SMP | Labels `server`, `multiplayer`; `horizon-server*`, `horizon-anticheat`, `lan/`, `dedicated*`; horizon-rust/horizon-server | #server-smp (plus #join-server, #server-chat) |
|
||||
| **Translations** | The game's and the launcher's languages | Label `roadmap:languages`; `launcher/lang/`, `crates/horizon-client/assets/horizon/lang/` | #translations and the 🌍 language channels |
|
||||
| **Website** | playhorizon.dev | horizon-rust/horizon-site; label `website`. The site's design stays as it is unless the council decides otherwise | #website |
|
||||
| **Vanilla accuracy** | Parity with vanilla 26.3: harnesses, oracles, the port ledger | Labels `parity`, `parity-regression`, `full-parity`; `tools/test_*_parity.py`, the Java oracles | #vanilla-accuracy |
|
||||
| **UI** | The game's menus, HUD, settings, controller navigation, the design system | Labels `ui`, `accessibility`; `title/`, `settings_shell.rs`, `hud*`, `ui.rs`, `docs/design-system.md`, `horizon-theme`. The launcher's look is shared with Launcher | #ui |
|
||||
|
||||
A pull request can belong to two groups. Both leads can comment, and the Team merges as usual. If the leads disagree,
|
||||
the council decides.
|
||||
|
||||
### 6.2 Leads per group
|
||||
|
||||
- **One lead per group.** A group with 8 or more active members may also have **one co-lead**, chosen the same way.
|
||||
- A person leads **at most two groups**. Council members may lead a group, though a lead from outside the council is
|
||||
preferred: it spreads the work.
|
||||
|
||||
### 6.3 What a lead decides alone
|
||||
|
||||
Inside their group's topic, and within this document, the contributing guide, the vanilla-first rule, the layers and
|
||||
the design system:
|
||||
|
||||
- the group's priorities and the order of its roadmap items;
|
||||
- which issues are `good first issue`, and triage of the group's issues;
|
||||
- technical and design direction inside the area, when vanilla behaviour and the default look don't change;
|
||||
- accepting or declining an idea or a proposal in the area. Any two active group members or one council member can
|
||||
take that decision to the council;
|
||||
- how the group organises itself (threads, meetings, the channel's rules on top of the server's);
|
||||
- asking the Team for reviews, adding `needs-proof`, and nominating people.
|
||||
|
||||
### 6.4 What goes to the council
|
||||
|
||||
- the rules, money, accounts and secrets;
|
||||
- who is on the council, the Team or the mods;
|
||||
- anything that changes vanilla behaviour or turns a Horizon-only feature on by default;
|
||||
- breaking a public promise: the mod API, server compatibility, save and file formats;
|
||||
- disputes between groups;
|
||||
- anything involving Mojang, Microsoft or the law;
|
||||
- speaking for the whole project outside the group's topic.
|
||||
|
||||
### 6.5 A group without a lead
|
||||
|
||||
The work goes on. The Team reviews and merges, and Kira still routes the group's issues and questions to its
|
||||
channel. Until a lead is chosen, any council member may take a lead's decision for that group and says so in the
|
||||
channel. Kira pins a "lead wanted" note with the floor. **No lead is better than a lead who doesn't do the work:**
|
||||
nobody is appointed just to fill the seat.
|
||||
|
||||
## 7. Moderation and appeals
|
||||
|
||||
- **The server rules** are in #rules. Moderators enforce them, the Chief of Mods leads the mods, and the council can
|
||||
change the rules (2/3).
|
||||
- **Every action has a mod case**: who acted, what they did, and why. Kira keeps the cases.
|
||||
- **First appeal:** within 14 days, through #appeal (for a quarantine) or mod mail. A moderator who wasn't involved,
|
||||
or the Chief of Mods, answers within 72 hours.
|
||||
- **Second appeal:** for bans, timeouts over 24 hours and quarantines, a council poll in #council (simple majority).
|
||||
Members who were involved don't vote. The answer is final for 90 days.
|
||||
- **Not appealable here:** actions that Discord's rules or the law require. Those are reported where the law says.
|
||||
- **Removing a moderator:** the Chief of Mods can suspend a moderator at once, and the council decides within 72 hours
|
||||
(simple majority). The council removes the Chief of Mods the same way.
|
||||
- **Staff are moderated like everyone.** When a council member is involved, the council decides without them.
|
||||
|
||||
## 8. Conflicts and hard cases
|
||||
|
||||
| Case | What happens |
|
||||
| --- | --- |
|
||||
| Two groups disagree | The leads talk first. If they still disagree, the council decides (simple majority) |
|
||||
| A lead's decision is contested | Any two active group members or one council member can bring it to the council |
|
||||
| A Team member merges against the rules | Any council member can revert the merge. The council can remove the member (2/3) |
|
||||
| A council member breaks the rules | The council decides without them. Removal takes 2/3 (section 4.7) |
|
||||
| Someone holds a role and is under 18 | Section 9 |
|
||||
| The council can't reach quorum | Section 4.8 |
|
||||
| The founder and the council disagree about one of his own accounts | Until a step of section 11 hands it over, the account stays his. He carries out the council's decisions on project matters, or he says publicly that he won't |
|
||||
|
||||
## 9. The age rule (18+)
|
||||
|
||||
**Who must be 18 or older:** members of the council and the Horizon Team, trusted reviewers, group leads and
|
||||
co-leads, the Chief of Mods and the moderators, official server operators, and anyone with admin rights anywhere in
|
||||
the project: Discord (Administrator, Manage Roles, Manage Channels, ban), GitHub (write, admin, organisation),
|
||||
server panels, Railway, or Kira's admin side. The same goes for anyone who holds a project secret.
|
||||
|
||||
**Who doesn't have to be:** members, contributors (with any rank), group members and 🤝 Support. Contributors under
|
||||
18 are welcome and valued. They just can't hold the roles above.
|
||||
|
||||
**How it's checked, without collecting ID:**
|
||||
|
||||
- **A declaration.** Before taking such a role, and once at the start for everyone who holds one, the person presses
|
||||
a button in Kira: "I confirm that I am 18 or older. I understand that a false declaration removes all my roles of
|
||||
trust." Kira keeps only the Discord account, the date and the wording's version. It never stores a birth date, an
|
||||
ID, a photo, or any outside age-checking service's result.
|
||||
- **No declaration, no role.** At the start, current role holders have 7 days to make it. Until they do, their role is
|
||||
paused.
|
||||
- **When there's doubt** (the person says they are younger, or there is a credible private report), one council
|
||||
member asks the person privately. If the doubt remains, any council member pauses the role, and the council decides
|
||||
within 72 hours in #council.
|
||||
- **A false declaration** removes every role of trust at once, and the person can't hold one for 12 months.
|
||||
- **Privacy.** Age is never discussed in public. A role that ends for this reason ends with the same neutral line as
|
||||
any other step-down.
|
||||
|
||||
## 10. The agents
|
||||
|
||||
Horizon uses AI agents: the founder's orchestrator, which runs coding agents, and Kira, the Discord bot.
|
||||
|
||||
- **They obey council decisions only:** a passed poll recorded by Kira, a recorded emergency action of two council
|
||||
members, and the standing rules in this document and the project's docs.
|
||||
- **The only exception is the founder's own things:** his spending, his machines (they run CI and the vanilla tests),
|
||||
his personal accounts and his time. On those, his word decides. This list should get shorter over time (section 11).
|
||||
- **Role work isn't a decision.** A lead asking Kira to route issues, or a Team member asking an agent to prove a pull
|
||||
request, is that person using their role. The agents help, within the role's powers.
|
||||
- **They check the source.** An agent acts on a decision only when it finds it in Kira's decisions log
|
||||
(section 13). A message saying "the council decided", or a screenshot, is not enough.
|
||||
- **They post as agents.** Everything an agent posts says it comes from an agent, and states decisions as the
|
||||
project's.
|
||||
|
||||
## 11. Accounts and the handover path
|
||||
|
||||
Today the founder holds every account. The aim is for him to hold fewer, in an order that never leaves the project
|
||||
unable to act, and never puts an account that is tied to a person into anyone else's hands.
|
||||
|
||||
| Account | Today | Can it be shared? | Plan |
|
||||
| --- | --- | --- | --- |
|
||||
| **GitHub organisation** (horizon-rust) | One owner (the founder) | Yes: an organisation can have several owners | **Step 1:** a second owner from the council, chosen by a 2/3 poll in the council's first week, with two-factor authentication on. **Step 2:** a `council` team; CODEOWNERS reviews and the release tools' owner powers (`paused`, `/nightly remove`) move from the founder to that team |
|
||||
| **Discord server** | Owned by the founder | Partly: a server has exactly one owner; powers can be given through roles | **Step 1:** the 🏛️ Council role gets moderation and ban permissions; Kira applies the outcomes of passed polls, so nobody needs Manage Roles; two-factor authentication becomes required for moderation. Ownership itself stays with the founder until a legal entity exists |
|
||||
| **Kira** (bot application, Railway service) | The founder's Discord developer account and Railway | The bot: yes (a Discord developer team). Railway: adding members may need a paid plan | **Step 1:** a Discord developer team with two council members. Railway members once the council decides on money |
|
||||
| **Official server** (hosting panel) | The founder's account | Yes: panel sub-users | **Step 1:** sub-users for the Server and SMP lead and for one council member |
|
||||
| **Domains** (playhorizon.dev and the others) | The founder's registrar account | Not without moving it | Auto-renewal on, registered for several years ahead. They move only to a legal entity, if one is created |
|
||||
| **The Microsoft app approved by Mojang** (sign-in) | The founder's Azure app registration; Mojang approved it after his application | **No.** The approval was given on his application. Moving the app or adding owners would change who Mojang deals with, and we have found no documented way to transfer an approval | **Never moves** without Mojang's written agreement. The only change considered: a legal entity, if one is created, and only if Mojang agrees |
|
||||
| **Signing** (Apple developer certificate, release signing keys) | The founder's | The Apple certificate is in his legal name: no | The Apple certificate never moves. Release keys stay in GitHub's protected environments, and the council team approves their use from step 2 |
|
||||
| **Money** (sponsorship, donations) | Personal accounts of the founder | Through a fiscal host | Section 12 |
|
||||
| **The founder's machines, AI accounts and password vault** | His | No | His own things: they never move. The project should depend on them less over time |
|
||||
|
||||
**Order:** first the safety nets that remove a single point of failure (a second organisation owner, the bot's
|
||||
developer team, panel sub-users). Then the day-to-day powers (CODEOWNERS and release tools to the council team). Last,
|
||||
whatever needs a legal entity: domains, money, and with Mojang's agreement the sign-in app. Each step is a 2/3 poll,
|
||||
and the founder carries it out.
|
||||
|
||||
## 12. Money
|
||||
|
||||
*Placeholder: the options (Open Collective and others) are being researched.*
|
||||
|
||||
- The council approves every expense by a **2/3 poll**.
|
||||
- **Every income and every expense is public.**
|
||||
- Nobody votes on money paid to themselves.
|
||||
|
||||
## 13. Transparency
|
||||
|
||||
- **#polls-and-decisions:** every poll and every result, posted by Kira, and the 24-hour discussions of nominations.
|
||||
For polls that aren't about a person, each member's vote is shown once the poll closes.
|
||||
- **The decisions log:** Kira keeps an append-only record of every poll, emergency action, appointment and
|
||||
step-down, with its date and result. It is public and machine-readable, so the agents can check a decision there.
|
||||
- **This document** lives in horizon-rust/horizon as `GOVERNANCE.md`. A short summary is pinned in #rules and in
|
||||
#polls-and-decisions.
|
||||
- **Who holds which role:** the people page of playhorizon.dev's hub shows the council, the Team, the group leads,
|
||||
the Chief of Mods, the moderators and Support.
|
||||
- **#council** is private for discussion and for polls about people. Everything the council decides is published.
|
||||
|
||||
## 14. Rules that are never voted on
|
||||
|
||||
These are Horizon's conditions for existing, not choices. No poll can change them:
|
||||
|
||||
- Playing needs a **Microsoft account that owns Minecraft: Java Edition**. There is no play without one.
|
||||
- **No Mojang files** in Horizon's repos or downloads. Players' own copies stay on their own machines.
|
||||
- **No gambling**, in any form, including with in-game currency.
|
||||
- **Mojang's and Microsoft's terms and Usage Guidelines** apply to everything Horizon does.
|
||||
- **The law**, and the terms of service of Discord and GitHub.
|
||||
|
||||
## 15. Changing this document
|
||||
|
||||
A 2/3 council poll, then a pull request that a Team member merges. The council reviews the floors' numbers
|
||||
(section 5.2) once a year, or sooner if they turn out wrong.
|
||||
@@ -135,8 +135,6 @@ python3 tools/pr_ready.py
|
||||
|
||||
It runs rustfmt, clippy, the touched crates' tests, the Python tests, the public-tree check and the vanilla harnesses your change affects. Never commit game files, decompiled source or prepared resources.
|
||||
|
||||
How Horizon is run (the council, theme groups and their leads, the Horizon Team, moderation): [GOVERNANCE.md](GOVERNANCE.md).
|
||||
|
||||
## Project status
|
||||
|
||||
**Player-visible gaps: 0 open** of the 833 the [port ledger](tools/port_ledger.py) found, reading every vanilla 26.3 method against Horizon's code: vanilla behaviour a player would notice that Horizon doesn't have yet. By area: Animals [0](https://github.com/horizon-rust/horizon/issues/3512); Monsters and bosses [0](https://github.com/horizon-rust/horizon/issues/3513); Entities, projectiles, vehicles and decorations [0](https://github.com/horizon-rust/horizon/issues/3514); Items, enchantments and inventories [0](https://github.com/horizon-rust/horizon/issues/3515); Blocks, redstone and fluids [0](https://github.com/horizon-rust/horizon/issues/3516); World, world generation and saves [0](https://github.com/horizon-rust/horizon/issues/3517); Screens, HUD and client packets [0](https://github.com/horizon-rust/horizon/issues/3518); Entity and block rendering, models [0](https://github.com/horizon-rust/horizon/issues/3519); Server and players on the server [0](https://github.com/horizon-rust/horizon/issues/3520); Mob AI, villagers and raids [0](https://github.com/horizon-rust/horizon/issues/3521). This number goes down as gaps close (`tools/parity_gaps.py`); the table below says what the harnesses compare.
|
||||
|
||||
@@ -74,11 +74,70 @@ pub trait ResourceSource {
|
||||
vec!["minecraft".into()]
|
||||
}
|
||||
}
|
||||
/// The bundled file a language reads, and whether it drops `ß`: a regional variant reads its
|
||||
/// language's file (Swiss German writes "ss" for "ß", as vanilla's de_ch does).
|
||||
fn bundled_code(code: &str) -> (&str, bool) {
|
||||
match code {
|
||||
"ko" => ("ko_kr", false),
|
||||
"hr" => ("hr_hr", false),
|
||||
"de_at" => ("de_de", false),
|
||||
"de_ch" => ("de_de", true),
|
||||
"fr_ca" | "fr_ch" => ("fr_fr", false),
|
||||
"es_ar" | "es_cl" | "es_ec" | "es_mx" | "es_uy" | "es_ve" => ("es_es", false),
|
||||
code => (code, false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lang files crates above the engine ship (`horizon_hooks::lang`), each a list of
|
||||
/// (code, JSON) pairs. The game's entry installs them once, before any language loads.
|
||||
static CRATE_FILES: std::sync::OnceLock<&'static [&'static [(&'static str, &'static str)]]> =
|
||||
std::sync::OnceLock::new();
|
||||
|
||||
/// Installs the crates' lang files (`horizon_hooks::lang::engine::all()`). Only the first call
|
||||
/// counts.
|
||||
pub fn install_crate_files(files: &'static [&'static [(&'static str, &'static str)]]) {
|
||||
let _ = CRATE_FILES.set(files);
|
||||
}
|
||||
|
||||
/// The crates' entries for language `code` (through [`bundled_code`]'s variants) that the
|
||||
/// engine's own `horizon` file for it doesn't have: on a key both have, the engine wins.
|
||||
fn read_crate_files(files: &[&[(&str, &str)]], code: &str, map: &mut HashMap<String, String>) {
|
||||
if files.is_empty() {
|
||||
return;
|
||||
}
|
||||
let (file, swiss) = bundled_code(code);
|
||||
let mut own = HashMap::new();
|
||||
if let Some(bytes) = Bundled.read(&format!("assets/horizon/lang/{code}.json")) {
|
||||
read_lang_file(&bytes, &mut own);
|
||||
}
|
||||
for (c, json) in files.iter().flat_map(|f| f.iter()) {
|
||||
if *c != file {
|
||||
continue;
|
||||
}
|
||||
let mut theirs = HashMap::new();
|
||||
if swiss {
|
||||
read_lang_file(json.replace('ß', "ss").as_bytes(), &mut theirs);
|
||||
} else {
|
||||
read_lang_file(json.as_bytes(), &mut theirs);
|
||||
}
|
||||
for (key, value) in theirs {
|
||||
if !own.contains_key(&key) {
|
||||
map.insert(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Horizon's own strings, compiled in so they need no prepared resources.
|
||||
struct Bundled;
|
||||
impl ResourceSource for Bundled {
|
||||
fn read(&self, path: &str) -> Option<Vec<u8>> {
|
||||
let body = match path {
|
||||
let code = path
|
||||
.strip_prefix("assets/horizon/lang/")?
|
||||
.strip_suffix(".json")?;
|
||||
let (file, swiss) = bundled_code(code);
|
||||
let path = format!("assets/horizon/lang/{file}.json");
|
||||
let body = match path.as_str() {
|
||||
"assets/horizon/lang/en_us.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/en_us.json")
|
||||
}
|
||||
@@ -103,10 +162,10 @@ impl ResourceSource for Bundled {
|
||||
"assets/horizon/lang/tr_tr.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/tr_tr.json")
|
||||
}
|
||||
"assets/horizon/lang/ko_kr.json" | "assets/horizon/lang/ko.json" => {
|
||||
"assets/horizon/lang/ko_kr.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/ko_kr.json")
|
||||
}
|
||||
"assets/horizon/lang/hr_hr.json" | "assets/horizon/lang/hr.json" => {
|
||||
"assets/horizon/lang/hr_hr.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/hr_hr.json")
|
||||
}
|
||||
"assets/horizon/lang/pt_pt.json" => {
|
||||
@@ -331,30 +390,20 @@ impl ResourceSource for Bundled {
|
||||
"assets/horizon/lang/vec_it.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/vec_it.json")
|
||||
}
|
||||
"assets/horizon/lang/de_de.json" | "assets/horizon/lang/de_at.json" => {
|
||||
"assets/horizon/lang/de_de.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/de_de.json")
|
||||
}
|
||||
// Swiss German writes "ss" for "ß", as vanilla's de_ch does.
|
||||
"assets/horizon/lang/de_ch.json" => {
|
||||
let de = include_str!("../../horizon-client/assets/horizon/lang/de_de.json");
|
||||
return Some(de.replace('ß', "ss").into_bytes());
|
||||
}
|
||||
"assets/horizon/lang/fr_fr.json"
|
||||
| "assets/horizon/lang/fr_ca.json"
|
||||
| "assets/horizon/lang/fr_ch.json" => {
|
||||
"assets/horizon/lang/fr_fr.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/fr_fr.json")
|
||||
}
|
||||
"assets/horizon/lang/es_es.json"
|
||||
| "assets/horizon/lang/es_ar.json"
|
||||
| "assets/horizon/lang/es_cl.json"
|
||||
| "assets/horizon/lang/es_ec.json"
|
||||
| "assets/horizon/lang/es_mx.json"
|
||||
| "assets/horizon/lang/es_uy.json"
|
||||
| "assets/horizon/lang/es_ve.json" => {
|
||||
"assets/horizon/lang/es_es.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/es_es.json")
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
if swiss {
|
||||
return Some(body.replace('ß', "ss").into_bytes());
|
||||
}
|
||||
Some(body.as_bytes().to_vec())
|
||||
}
|
||||
fn namespaces(&self) -> Vec<String> {
|
||||
@@ -438,6 +487,16 @@ impl Language {
|
||||
/// from every namespace and every source in priority order (later sources
|
||||
/// override).
|
||||
pub fn load(sources: &[&dyn ResourceSource], code: &str) -> Self {
|
||||
let crate_files = CRATE_FILES.get().copied().unwrap_or_default();
|
||||
Self::load_with(sources, code, crate_files)
|
||||
}
|
||||
|
||||
/// [`Language::load`] with these crates' lang files.
|
||||
fn load_with(
|
||||
sources: &[&dyn ResourceSource],
|
||||
code: &str,
|
||||
crate_files: &[&[(&str, &str)]],
|
||||
) -> Self {
|
||||
let mut map = HashMap::new();
|
||||
let mut codes = vec![DEFAULT];
|
||||
if code != DEFAULT {
|
||||
@@ -453,12 +512,14 @@ impl Language {
|
||||
sources.iter().flat_map(|s| s.namespaces()).collect();
|
||||
for c in codes {
|
||||
for namespace in &namespaces {
|
||||
for source in sources {
|
||||
let Some(bytes) = source.read(&format!("assets/{namespace}/lang/{c}.json"))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
read_lang_file(&bytes, &mut map);
|
||||
for (i, source) in sources.iter().enumerate() {
|
||||
if let Some(bytes) = source.read(&format!("assets/{namespace}/lang/{c}.json")) {
|
||||
read_lang_file(&bytes, &mut map);
|
||||
}
|
||||
// The crates' text right after the engine's own (`Bundled`, the first source).
|
||||
if i == 0 && namespace == "horizon" {
|
||||
read_crate_files(crate_files, c, &mut map);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -688,6 +749,43 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_crates_text_loads_after_the_engines_and_before_packs() {
|
||||
static CRATE: &[(&str, &str)] = &[
|
||||
(
|
||||
"en_us",
|
||||
r#"{"test.crate.hello": "Hello", "horizon.osk.close": "Shut", "test.crate.packed": "Crate"}"#,
|
||||
),
|
||||
(
|
||||
"de_de",
|
||||
r#"{"test.crate.hello": "Grüß dich", "horizon.osk.close": "Zu"}"#,
|
||||
),
|
||||
("fr_fr", r#"{"test.crate.hello": "Bonjour"}"#),
|
||||
];
|
||||
let pack = Mem(HashMap::from([(
|
||||
"assets/horizon/lang/en_us.json".to_owned(),
|
||||
br#"{"test.crate.packed": "Pack"}"#.to_vec(),
|
||||
)]));
|
||||
let load = |code| Language::load_with(&[&pack], code, &[CRATE]);
|
||||
let en = load("en_us");
|
||||
assert_eq!(en.get("test.crate.hello"), Some("Hello"));
|
||||
// The engine's own file wins on a key both have; a pack wins over both.
|
||||
assert_eq!(en.get("horizon.osk.close"), Some("Close"));
|
||||
assert_eq!(en.get("test.crate.packed"), Some("Pack"));
|
||||
let de = load("de_de");
|
||||
assert_eq!(de.get("test.crate.hello"), Some("Grüß dich"));
|
||||
assert_eq!(de.get("horizon.osk.close"), Some("Schließen"));
|
||||
// Regional variants read their language's file, as the engine's do.
|
||||
assert_eq!(load("de_ch").get("test.crate.hello"), Some("Grüss dich"));
|
||||
assert_eq!(load("fr_ca").get("test.crate.hello"), Some("Bonjour"));
|
||||
// A language the crate lacks falls back to its English.
|
||||
assert_eq!(load("pl_pl").get("test.crate.hello"), Some("Hello"));
|
||||
// Without crate files, nothing changes.
|
||||
let plain = Language::load_with(&[&pack], "de_de", &[]);
|
||||
assert_eq!(plain.get("test.crate.hello"), None);
|
||||
assert_eq!(plain.len() + 1, de.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scoped_language_is_the_calling_threads_and_reports_what_it_lacks() {
|
||||
let empty = Mem(HashMap::new());
|
||||
@@ -739,6 +837,20 @@ mod tests {
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../horizon-client/assets/horizon/lang")
|
||||
}
|
||||
/// The crates whose own lang files the game loads (`crates/<name>/assets/lang`,
|
||||
/// registered through `horizon_hooks::lang`), with that folder.
|
||||
fn crate_lang_dirs() -> Vec<(String, std::path::PathBuf)> {
|
||||
let crates = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
|
||||
let mut dirs: Vec<_> = std::fs::read_dir(&crates)
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.map(|e| (e.file_name().into_string().unwrap(), e.path()))
|
||||
.map(|(name, path)| (name, path.join("assets/lang")))
|
||||
.filter(|(_, dir)| dir.join("en_us.json").is_file())
|
||||
.collect();
|
||||
dirs.sort();
|
||||
dirs
|
||||
}
|
||||
/// The keys of a flat bundled file in file order, duplicates included (a map
|
||||
/// would keep only the last of two equal keys). One `"key": "value"` per line.
|
||||
fn file_keys(body: &str) -> Vec<String> {
|
||||
@@ -776,11 +888,33 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn bundled_files_are_served_and_have_no_stale_or_repeated_keys() {
|
||||
let dir = bundled_dir();
|
||||
let engine = check_lang_dir(&bundled_dir(), true);
|
||||
assert!(
|
||||
engine.1 >= 7,
|
||||
"found {} bundled lang files in {}",
|
||||
engine.1,
|
||||
bundled_dir().display()
|
||||
);
|
||||
// A crate's files pass the same checks. Its keys are its own: on a key the engine's
|
||||
// files also have, the engine's text would win and hide the crate's.
|
||||
for (name, dir) in crate_lang_dirs() {
|
||||
let (en, _) = check_lang_dir(&dir, false);
|
||||
for key in en.keys() {
|
||||
assert!(
|
||||
!engine.0.contains_key(key),
|
||||
"{name}: {key} is also the engine's"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The checks every lang folder passes: flat maps, no repeated key, no key en_us lacks, the
|
||||
/// same placeholders as English, no empty text. Its en_us and how many files it has.
|
||||
fn check_lang_dir(dir: &std::path::Path, bundled: bool) -> (HashMap<String, String>, usize) {
|
||||
let en_body = std::fs::read_to_string(dir.join("en_us.json")).unwrap();
|
||||
let en: HashMap<String, String> = serde_json::from_str(&en_body).unwrap();
|
||||
let mut seen = 0;
|
||||
for entry in std::fs::read_dir(&dir).unwrap().flatten() {
|
||||
for entry in std::fs::read_dir(dir).unwrap().flatten() {
|
||||
let name = entry.file_name().into_string().unwrap();
|
||||
let Some(code) = name.strip_suffix(".json") else {
|
||||
continue;
|
||||
@@ -789,7 +923,7 @@ mod tests {
|
||||
// A file that Bundled::read does not name is never loaded.
|
||||
let path = format!("assets/horizon/lang/{name}");
|
||||
assert!(
|
||||
Bundled.read(&path).is_some(),
|
||||
!bundled || Bundled.read(&path).is_some(),
|
||||
"{name} is not served by Bundled"
|
||||
);
|
||||
let body = std::fs::read_to_string(entry.path()).unwrap();
|
||||
@@ -815,11 +949,7 @@ mod tests {
|
||||
assert!(!value.trim().is_empty(), "{code}: {key} is empty");
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
seen >= 7,
|
||||
"found {seen} bundled lang files in {}",
|
||||
dir.display()
|
||||
);
|
||||
(en, seen)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -834,10 +964,10 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
let en: HashMap<String, String> = serde_json::from_str(
|
||||
&std::fs::read_to_string(bundled_dir().join("en_us.json")).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let english = |dir: &std::path::Path| -> HashMap<String, String> {
|
||||
serde_json::from_str(&std::fs::read_to_string(dir.join("en_us.json")).unwrap()).unwrap()
|
||||
};
|
||||
let mut en = english(&bundled_dir());
|
||||
let crates = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
|
||||
let mut files = Vec::new();
|
||||
for krate in [
|
||||
@@ -847,6 +977,11 @@ mod tests {
|
||||
] {
|
||||
rust_files(&crates.join(krate).join("src"), &mut files);
|
||||
}
|
||||
// A crate that ships lang files names its keys in its own sources.
|
||||
for (name, dir) in crate_lang_dirs() {
|
||||
en.extend(english(&dir));
|
||||
rust_files(&crates.join(name).join("src"), &mut files);
|
||||
}
|
||||
assert!(files.len() > 100, "found only {} sources", files.len());
|
||||
let mut found = 0;
|
||||
for file in files {
|
||||
|
||||
@@ -171,7 +171,7 @@ impl OptionsUi {
|
||||
added::Row::Text { text, .. } => {
|
||||
out.extend(wrap(&text, 64).into_iter().map(Row::Note))
|
||||
}
|
||||
added::Row::Space(_) | added::Row::Field { .. } => continue,
|
||||
added::Row::Space(_) => continue,
|
||||
added::Row::Button {
|
||||
label,
|
||||
active,
|
||||
|
||||
@@ -27,6 +27,8 @@ pub fn main_with(mods: crate::mod_api::Mods) {
|
||||
// then the Horizon parts still in this crate; none registers after this point.
|
||||
game::player_hooks::register();
|
||||
horizon_hooks::engine::seal();
|
||||
// The text crates ship (`horizon_hooks::lang`), loaded with the game's own languages.
|
||||
horizon_client::lang::install_crate_files(horizon_hooks::lang::engine::all());
|
||||
horizon_client::spans::startup::init(std::time::Instant::now());
|
||||
horizon_client::spans::trace::start_from_env();
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
|
||||
@@ -7242,15 +7242,15 @@ impl Game<'_> {
|
||||
} else if m.alive() {
|
||||
m.body_rotation_tick();
|
||||
}
|
||||
// Squid.aiStep; WalkAnimationState.update from the horizontal distance.
|
||||
// Squid.aiStep follows LivingEntity's interpolation and head turn.
|
||||
horizon_client::mobs::aquatic::squid_client_step(m, &mut mob_fx);
|
||||
let d = (m.position.x - m.previous.x).hypot(m.position.z - m.previous.z) as f32;
|
||||
let (target, factor) = if m.species() == horizon_client::mobs::Species::Frog {
|
||||
(horizon_client::mobs::frog::walk_target(m, d), 0.4)
|
||||
} else if horizon_client::mobs::camel::is_camel(m.species()) {
|
||||
horizon_client::mobs::camel::walk_target(m, d)
|
||||
// WalkAnimationState.update from the horizontal distance moved.
|
||||
let (dx, dz) = (m.position.x - m.previous.x, m.position.z - m.previous.z);
|
||||
let (target, factor) = if horizon_client::mobs::camel::is_camel(m.species()) {
|
||||
// Camel.updateWalkAnimation.
|
||||
horizon_client::mobs::camel::walk_target(m, (dx * dx + dz * dz).sqrt() as f32)
|
||||
} else {
|
||||
((d * 4.0).min(1.0), 0.4)
|
||||
((((dx * dx + dz * dz).sqrt() * 4.0) as f32).min(1.0), 0.4)
|
||||
};
|
||||
m.limb_speed += (target - m.limb_speed) * factor;
|
||||
m.limb_position += m.limb_speed;
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
//! Recording a session's packets as `.mcpr` and playing them back in a `Game`: the `/studio record`
|
||||
//! and `/studio replay` commands (chat and `horizon-ctl`), the replay's clock and its world. This is
|
||||
//! platform (docs/layers.md, "Replays"); Studio's editor (`studio.rs`) builds on it.
|
||||
//! Replay playback's state in a `Game`. Recording a session's packets as `.mcpr` and playing them
|
||||
//! back is platform (docs/layers.md, "Replays"); Studio's editor (`studio.rs`) builds on it.
|
||||
|
||||
use super::{Game, PendingWorldEntry, Screen};
|
||||
use horizon_client::{
|
||||
Result,
|
||||
studio::{Metadata, ReplayDriver},
|
||||
text::Text,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
use super::Game;
|
||||
use horizon_client::studio::ReplayDriver;
|
||||
|
||||
/// The packet replay a `Game` plays, if any, and its playback clock.
|
||||
#[derive(Default)]
|
||||
@@ -48,808 +38,4 @@ impl Game<'_> {
|
||||
.player_alpha()
|
||||
.unwrap_or_else(|| self.clock.camera_fraction())
|
||||
}
|
||||
|
||||
/// In-game chat command handler: `/studio <record|replay> ...`.
|
||||
/// Returns true if the command was recognized and handled.
|
||||
pub(super) fn studio_command(&mut self, command: &str) -> bool {
|
||||
let Some(rest) = command.strip_prefix("studio") else {
|
||||
return false;
|
||||
};
|
||||
if !rest.is_empty() && !rest.starts_with(char::is_whitespace) {
|
||||
return false;
|
||||
}
|
||||
let words: Vec<&str> = rest.split_whitespace().collect();
|
||||
if words.is_empty() {
|
||||
self.system_message(Text::translate("horizon.studio.replay.usage", &[]));
|
||||
return true;
|
||||
}
|
||||
|
||||
match words[0] {
|
||||
"freecam" | "keyframe" | "path" if super::studio::EDITOR => {
|
||||
return self.studio_editor_command(&words);
|
||||
}
|
||||
"record" => match words.get(1).copied() {
|
||||
Some("start") => match self.start_studio_recording(words.get(2).copied()) {
|
||||
Ok(v) if v["ok"].as_bool() == Some(true) => {
|
||||
let path = v["path"].as_str().unwrap_or_default();
|
||||
self.system_message(Text::tr("horizon.studio.record.started", &[&path]));
|
||||
}
|
||||
Ok(v) => {
|
||||
let error = v["error"].as_str().unwrap_or_default();
|
||||
self.system_message(Text::tr("horizon.studio.record.error", &[&error]));
|
||||
}
|
||||
Err(error) => {
|
||||
self.system_message(Text::tr("horizon.studio.record.error", &[&error]));
|
||||
}
|
||||
},
|
||||
Some("stop") => match self.stop_studio_recording() {
|
||||
Ok(v) if v["ok"].as_bool() == Some(true) => {
|
||||
let path = v["path"].as_str().unwrap_or_default();
|
||||
self.system_message(Text::tr("horizon.studio.record.saved", &[&path]));
|
||||
}
|
||||
Ok(v) => {
|
||||
let error = v["error"].as_str().unwrap_or_default();
|
||||
self.system_message(Text::tr("horizon.studio.record.error", &[&error]));
|
||||
}
|
||||
Err(error) => {
|
||||
self.system_message(Text::tr("horizon.studio.record.error", &[&error]));
|
||||
}
|
||||
},
|
||||
Some("status") => {
|
||||
let status = self.studio_recording_status();
|
||||
if status["active"].as_bool() == Some(true) {
|
||||
let elapsed =
|
||||
(status["elapsed_ms"].as_u64().unwrap_or(0) / 1000).to_string();
|
||||
let packets = status["packet_count"].as_u64().unwrap_or(0).to_string();
|
||||
let size =
|
||||
(status["bytes_written"].as_u64().unwrap_or(0) / 1024).to_string();
|
||||
let path = status["path"].as_str().unwrap_or_default();
|
||||
self.system_message(Text::tr(
|
||||
"horizon.studio.record.status.active",
|
||||
&[&elapsed, &packets, &size, &path],
|
||||
));
|
||||
} else if let Some(error) = status["error"].as_str() {
|
||||
self.system_message(Text::tr("horizon.studio.record.error", &[&error]));
|
||||
} else {
|
||||
self.system_message(Text::translate(
|
||||
"horizon.studio.record.status.inactive",
|
||||
&[],
|
||||
));
|
||||
}
|
||||
}
|
||||
_ => self.system_message(Text::translate("horizon.studio.record.usage", &[])),
|
||||
},
|
||||
"replay" => match words.get(1).copied() {
|
||||
Some("open") => {
|
||||
let Some(path) = words.get(2) else {
|
||||
self.system_message(Text::translate(
|
||||
"horizon.studio.replay.open.usage",
|
||||
&[],
|
||||
));
|
||||
return true;
|
||||
};
|
||||
match self.open_studio_replay(path) {
|
||||
Ok(v) if v["ok"].as_bool() == Some(true) => {
|
||||
let duration_s = v["duration_ms"].as_f64().unwrap_or(0.0) / 1000.0;
|
||||
let packets = v["packet_count"].as_u64().unwrap_or(0);
|
||||
let duration = format!("{duration_s:.1}");
|
||||
let packets = packets.to_string();
|
||||
self.system_message(Text::tr(
|
||||
"horizon.studio.replay.opened",
|
||||
&[path, &duration, &packets],
|
||||
));
|
||||
}
|
||||
Ok(v) => {
|
||||
let err = v["error"].as_str().unwrap_or_default();
|
||||
self.system_message(Text::tr(
|
||||
"horizon.studio.replay.open_error",
|
||||
&[&err],
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
let error = e.to_string();
|
||||
self.system_message(Text::tr(
|
||||
"horizon.studio.replay.open_error",
|
||||
&[&error],
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some("pause") => {
|
||||
if let Some(r) = self.replay.driver.as_mut() {
|
||||
r.set_paused(true);
|
||||
self.system_message(Text::translate("horizon.studio.replay.paused", &[]));
|
||||
} else {
|
||||
self.system_message(Text::translate("horizon.studio.replay.inactive", &[]));
|
||||
}
|
||||
}
|
||||
Some("resume") => {
|
||||
if let Some(r) = self.replay.driver.as_mut() {
|
||||
r.set_paused(false);
|
||||
self.system_message(Text::translate("horizon.studio.replay.resumed", &[]));
|
||||
} else {
|
||||
self.system_message(Text::translate("horizon.studio.replay.inactive", &[]));
|
||||
}
|
||||
}
|
||||
Some("speed") => {
|
||||
let Some(spd_str) = words.get(2) else {
|
||||
self.system_message(Text::translate(
|
||||
"horizon.studio.replay.speed.usage",
|
||||
&[],
|
||||
));
|
||||
return true;
|
||||
};
|
||||
if let Ok(spd) = spd_str.parse::<f64>() {
|
||||
let new_speed = if let Some(r) = self.replay.driver.as_mut() {
|
||||
r.set_speed(spd);
|
||||
Some(r.speed())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(speed) = new_speed {
|
||||
let speed = format!("{speed:.2}");
|
||||
self.system_message(Text::tr(
|
||||
"horizon.studio.replay.speed.set",
|
||||
&[&speed],
|
||||
));
|
||||
} else {
|
||||
self.system_message(Text::translate(
|
||||
"horizon.studio.replay.inactive",
|
||||
&[],
|
||||
));
|
||||
}
|
||||
} else {
|
||||
self.system_message(Text::translate(
|
||||
"horizon.studio.replay.speed.invalid",
|
||||
&[],
|
||||
));
|
||||
}
|
||||
}
|
||||
Some("seek") => {
|
||||
let Some(ms_str) = words.get(2) else {
|
||||
self.system_message(Text::translate(
|
||||
"horizon.studio.replay.seek.usage",
|
||||
&[],
|
||||
));
|
||||
return true;
|
||||
};
|
||||
if let Ok(ms) = ms_str.parse::<u32>() {
|
||||
if self.replay.driver.is_some() {
|
||||
match self.seek_studio_replay(ms) {
|
||||
Ok(()) => {
|
||||
let actual_ms = self
|
||||
.replay
|
||||
.driver
|
||||
.as_ref()
|
||||
.map_or(ms, |replay| ms.min(replay.duration_ms()));
|
||||
let ms_text = actual_ms.to_string();
|
||||
let seconds = format!("{:.1}", actual_ms as f32 / 1000.0);
|
||||
self.system_message(Text::tr(
|
||||
"horizon.studio.replay.seeked",
|
||||
&[&ms_text, &seconds],
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
if e.kind() == std::io::ErrorKind::Unsupported {
|
||||
self.system_message(Text::translate(
|
||||
"horizon.studio.replay.seek.backward_requires_standalone",
|
||||
&[],
|
||||
));
|
||||
} else {
|
||||
let error = e.to_string();
|
||||
self.system_message(Text::tr(
|
||||
"horizon.studio.replay.seek.error",
|
||||
&[&error],
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.system_message(Text::translate(
|
||||
"horizon.studio.replay.inactive",
|
||||
&[],
|
||||
));
|
||||
}
|
||||
} else {
|
||||
self.system_message(Text::translate(
|
||||
"horizon.studio.replay.seek.invalid",
|
||||
&[],
|
||||
));
|
||||
}
|
||||
}
|
||||
Some("status") => {
|
||||
if let Some(r) = self.replay.driver.as_ref() {
|
||||
let position = r.position_ms().to_string();
|
||||
let duration = r.duration_ms().to_string();
|
||||
let progress = format!("{:.1}", r.progress() * 100.0);
|
||||
let speed = format!("{:.2}", r.speed());
|
||||
let paused = r.is_paused().to_string();
|
||||
let finished = r.is_finished().to_string();
|
||||
self.system_message(Text::tr(
|
||||
"horizon.studio.replay.status",
|
||||
&[&position, &duration, &progress, &speed, &paused, &finished],
|
||||
));
|
||||
} else {
|
||||
self.system_message(Text::translate("horizon.studio.replay.inactive", &[]));
|
||||
}
|
||||
}
|
||||
Some("close") => {
|
||||
if self.close_studio_replay() {
|
||||
self.system_message(Text::translate("horizon.studio.replay.closed", &[]));
|
||||
} else {
|
||||
self.system_message(Text::translate("horizon.studio.replay.inactive", &[]));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
self.system_message(Text::translate("horizon.studio.replay.usage", &[]));
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
self.system_message(Text::translate("horizon.studio.replay.usage", &[]));
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Reset playback tick accumulator and align positions on seek.
|
||||
pub(super) fn reset_studio_playback_ticks(&mut self) {
|
||||
self.replay.virtual_tick_ms = 0.0;
|
||||
if let Some(player) = &self.player {
|
||||
self.previous_position = player.position;
|
||||
if let Some(m) = self.player_model.as_mut() {
|
||||
m.previous = player.position;
|
||||
m.position = player.position;
|
||||
m.limb_speed = 0.0;
|
||||
m.previous_limb_speed = 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconstruct replay state at a target timestamp. Standalone replays can
|
||||
/// safely reset their synthetic remote; an in-world replay can only seek
|
||||
/// forward because its pre-replay world must remain intact.
|
||||
pub(super) fn seek_studio_replay(&mut self, target_ms: u32) -> std::io::Result<()> {
|
||||
let current_ms = self
|
||||
.replay
|
||||
.driver
|
||||
.as_ref()
|
||||
.map_or(0, ReplayDriver::position_ms);
|
||||
let packets = if self.replay.standalone {
|
||||
let packets = self
|
||||
.replay
|
||||
.driver
|
||||
.as_mut()
|
||||
.expect("replay checked by caller")
|
||||
.seek_and_collect(target_ms)?;
|
||||
self.init_remote_for_replay()
|
||||
.map_err(|error| std::io::Error::other(error.to_string()))?;
|
||||
packets
|
||||
} else {
|
||||
if target_ms < current_ms {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::Unsupported,
|
||||
"backward seek requires a standalone replay",
|
||||
));
|
||||
}
|
||||
self.replay
|
||||
.driver
|
||||
.as_mut()
|
||||
.expect("replay checked by caller")
|
||||
.seek_forward(target_ms)?
|
||||
};
|
||||
|
||||
self.dispatch_studio_replay_packets(packets);
|
||||
self.reset_studio_playback_ticks();
|
||||
self.replay.last_tick = Some(std::time::Instant::now());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn dispatch_studio_replay_packets(
|
||||
&mut self,
|
||||
packets: Vec<horizon_client::studio::mcpr::TimestampedPacket>,
|
||||
) {
|
||||
for pkt in packets {
|
||||
match horizon_protocol::packets::play::Clientbound::decode(pkt.id, &pkt.payload) {
|
||||
Ok(packet) => {
|
||||
let _ = self.handle_packet(packet);
|
||||
}
|
||||
Err(e) => {
|
||||
let name = horizon_protocol::ids::name(
|
||||
horizon_protocol::State::Play,
|
||||
horizon_protocol::Direction::Clientbound,
|
||||
pkt.id,
|
||||
)
|
||||
.unwrap_or("?");
|
||||
log::debug!(
|
||||
"Studio replay skipped packet {name} (id {:#x}): {e}",
|
||||
pkt.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Advance active replay playback by elapsed frame time and dispatch ready packets.
|
||||
pub(super) fn advance_studio_replay(&mut self) -> Result<()> {
|
||||
let Some(driver) = self.replay.driver.as_mut() else {
|
||||
self.replay.last_tick = None;
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let now = std::time::Instant::now();
|
||||
let delta = match self.replay.last_tick {
|
||||
Some(last) => now
|
||||
.saturating_duration_since(last)
|
||||
.min(Duration::from_millis(250)),
|
||||
None => Duration::from_millis(16),
|
||||
};
|
||||
self.replay.last_tick = Some(now);
|
||||
|
||||
let ready_packets = match driver.advance(delta) {
|
||||
Ok(pkts) => pkts,
|
||||
Err(e) => {
|
||||
log::warn!("Studio replay advance error: {e}");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
self.dispatch_studio_replay_packets(ready_packets);
|
||||
let paused = self
|
||||
.replay
|
||||
.driver
|
||||
.as_ref()
|
||||
.is_some_and(|replay| replay.is_paused());
|
||||
if self.player_model.is_none() {
|
||||
self.tick_player_model();
|
||||
}
|
||||
if !paused {
|
||||
let speed = self
|
||||
.replay
|
||||
.driver
|
||||
.as_ref()
|
||||
.map_or(1.0, |r| r.speed() as f32)
|
||||
.max(0.1);
|
||||
self.replay.virtual_tick_ms += delta.as_secs_f32() * 1000.0 * speed;
|
||||
while self.replay.virtual_tick_ms >= 50.0 {
|
||||
self.replay.virtual_tick_ms -= 50.0;
|
||||
if let Some(player) = &self.player {
|
||||
let current_pos = player.position;
|
||||
let (dx, dz) = (
|
||||
current_pos.x - self.previous_position.x,
|
||||
current_pos.z - self.previous_position.z,
|
||||
);
|
||||
let walked = (dx * dx + dz * dz).sqrt() as f32;
|
||||
if let Some(m) = self.player_model.as_mut() {
|
||||
m.previous = self.previous_position;
|
||||
m.position = current_pos;
|
||||
m.previous_limb_speed = m.limb_speed;
|
||||
let target_speed = (walked * 4.0).min(1.0);
|
||||
m.limb_speed += (target_speed - m.limb_speed) * 0.4;
|
||||
if m.limb_speed < 0.001 {
|
||||
m.limb_speed = 0.0;
|
||||
}
|
||||
m.limb_position += m.limb_speed;
|
||||
m.previous_yaw = m.yaw;
|
||||
m.previous_head_yaw = m.head_yaw;
|
||||
m.previous_head_pitch = m.head_pitch;
|
||||
m.previous_body_yaw = m.body_yaw;
|
||||
m.yaw = self.yaw;
|
||||
m.head_yaw = self.yaw;
|
||||
m.head_pitch = self.pitch;
|
||||
let mut target = m.body_yaw;
|
||||
if dx * dx + dz * dz > 0.0025 {
|
||||
let walk = (dz.atan2(dx) as f32).to_degrees() - 90.0;
|
||||
let facing =
|
||||
((self.yaw - walk + 180.0).rem_euclid(360.0) - 180.0).abs();
|
||||
target = if 95.0 < facing && facing < 265.0 {
|
||||
walk - 180.0
|
||||
} else {
|
||||
walk
|
||||
};
|
||||
}
|
||||
let diff = (target - m.body_yaw + 180.0).rem_euclid(360.0) - 180.0;
|
||||
m.body_yaw += diff * 0.3;
|
||||
let body_diff = (self.yaw - m.body_yaw + 180.0).rem_euclid(360.0) - 180.0;
|
||||
if body_diff.abs() > 50.0 {
|
||||
m.body_yaw += body_diff - body_diff.signum() * 50.0;
|
||||
}
|
||||
m.age = m.age.wrapping_add(1);
|
||||
}
|
||||
self.previous_position = current_pos;
|
||||
}
|
||||
}
|
||||
} else if let Some(m) = self.player_model.as_mut() {
|
||||
m.previous_limb_speed = m.limb_speed;
|
||||
}
|
||||
self.studio_replay_advanced();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Control socket command handler for `studio`.
|
||||
pub(super) fn studio_control(&mut self, words: &[&str]) -> Result<Option<Value>> {
|
||||
if words.first().copied() != Some("studio") {
|
||||
return Ok(None);
|
||||
}
|
||||
match words.get(1).copied() {
|
||||
Some("record") => match words.get(2).copied() {
|
||||
Some("start") => {
|
||||
let name = words.get(3).copied();
|
||||
let res = self.start_studio_recording(name)?;
|
||||
Ok(Some(res))
|
||||
}
|
||||
Some("stop") => {
|
||||
let res = self.stop_studio_recording()?;
|
||||
Ok(Some(res))
|
||||
}
|
||||
Some("status") => {
|
||||
let res = self.studio_recording_status();
|
||||
Ok(Some(res))
|
||||
}
|
||||
_ => Ok(Some(json!({ "error": "unknown studio record subcommand" }))),
|
||||
},
|
||||
Some("replay") => match words.get(2).copied() {
|
||||
Some("open") => {
|
||||
let path = words.get(3).ok_or("missing path for studio replay open")?;
|
||||
let res = self.open_studio_replay(path)?;
|
||||
Ok(Some(res))
|
||||
}
|
||||
Some("pause") => {
|
||||
if let Some(r) = self.replay.driver.as_mut() {
|
||||
r.set_paused(true);
|
||||
Ok(Some(json!({ "ok": true, "paused": true })))
|
||||
} else {
|
||||
Ok(Some(json!({ "error": "no active replay" })))
|
||||
}
|
||||
}
|
||||
Some("resume") => {
|
||||
if let Some(r) = self.replay.driver.as_mut() {
|
||||
r.set_paused(false);
|
||||
Ok(Some(json!({ "ok": true, "paused": false })))
|
||||
} else {
|
||||
Ok(Some(json!({ "error": "no active replay" })))
|
||||
}
|
||||
}
|
||||
Some("speed") => {
|
||||
let spd_str = words.get(3).ok_or("missing speed value")?;
|
||||
let spd: f64 = spd_str.parse().map_err(|_| "invalid speed")?;
|
||||
if let Some(r) = self.replay.driver.as_mut() {
|
||||
r.set_speed(spd);
|
||||
Ok(Some(json!({ "ok": true, "speed": r.speed() })))
|
||||
} else {
|
||||
Ok(Some(json!({ "error": "no active replay" })))
|
||||
}
|
||||
}
|
||||
Some("seek") => {
|
||||
let ms_str = words.get(3).ok_or("missing seek timestamp")?;
|
||||
let ms: u32 = ms_str.parse().map_err(|_| "invalid timestamp")?;
|
||||
let pos = if let Some(r) = self.replay.driver.as_mut() {
|
||||
r.seek(ms).map_err(|e| format!("{e}"))?;
|
||||
Some(r.position_ms())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(pos) = pos {
|
||||
self.reset_studio_playback_ticks();
|
||||
Ok(Some(json!({ "ok": true, "position_ms": pos })))
|
||||
} else {
|
||||
Ok(Some(json!({ "error": "no active replay" })))
|
||||
}
|
||||
}
|
||||
Some("status") => {
|
||||
if let Some(r) = self.replay.driver.as_ref() {
|
||||
Ok(Some(json!({
|
||||
"ok": true,
|
||||
"active": true,
|
||||
"position_ms": r.position_ms(),
|
||||
"duration_ms": r.duration_ms(),
|
||||
"progress": r.progress(),
|
||||
"speed": r.speed(),
|
||||
"paused": r.is_paused(),
|
||||
"finished": r.is_finished(),
|
||||
})))
|
||||
} else {
|
||||
Ok(Some(json!({ "ok": true, "active": false })))
|
||||
}
|
||||
}
|
||||
Some("close") => {
|
||||
let closed = self.close_studio_replay();
|
||||
Ok(Some(json!({ "ok": true, "closed": closed })))
|
||||
}
|
||||
_ => Ok(Some(json!({ "error": "unknown studio replay subcommand" }))),
|
||||
},
|
||||
Some("screen") if super::studio::EDITOR => self.studio_screen_control(words),
|
||||
_ => Ok(Some(json!({ "error": "unknown studio command" }))),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn start_studio_recording(&mut self, name: Option<&str>) -> Result<Value> {
|
||||
let Some(r) = self.remote.as_ref() else {
|
||||
return Ok(json!({ "ok": false, "error": "no active world session" }));
|
||||
};
|
||||
if r.session.is_recording() {
|
||||
return Ok(json!({ "ok": false, "error": "recording already active" }));
|
||||
}
|
||||
let _ = std::fs::create_dir_all("recordings");
|
||||
let ts = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
let filename = match name {
|
||||
Some(n) if !n.is_empty() => format!("{}_{}.mcpr", n, ts),
|
||||
_ => format!("recording_{}.mcpr", ts),
|
||||
};
|
||||
let path = PathBuf::from("recordings").join(filename);
|
||||
let server_address = if r.session.address.is_empty() {
|
||||
"local".to_owned()
|
||||
} else {
|
||||
r.session.address.clone()
|
||||
};
|
||||
let is_singleplayer = server_address == "local";
|
||||
let meta = Metadata::new(
|
||||
server_address,
|
||||
is_singleplayer,
|
||||
horizon_core::release::VERSION.into(),
|
||||
);
|
||||
match horizon_client::studio::StudioRecorder::start(path.clone(), meta) {
|
||||
Ok(recorder) => {
|
||||
r.session.start_recording(recorder, |recorder| {
|
||||
self.dump_world_snapshot_to_recorder(recorder);
|
||||
});
|
||||
Ok(json!({ "ok": true, "path": path.display().to_string() }))
|
||||
}
|
||||
Err(e) => Ok(json!({ "ok": false, "error": e.to_string() })),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn stop_studio_recording(&mut self) -> Result<Value> {
|
||||
let Some(r) = self.remote.as_ref() else {
|
||||
return Ok(json!({ "ok": false, "error": "no active world session" }));
|
||||
};
|
||||
match r.session.stop_recording() {
|
||||
Some(Ok(path)) => Ok(json!({ "ok": true, "path": path.display().to_string() })),
|
||||
Some(Err(e)) => Ok(json!({ "ok": false, "error": e.to_string() })),
|
||||
None => Ok(json!({ "ok": false, "error": "no active recording" })),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn studio_recording_status(&self) -> Value {
|
||||
if let Some(r) = self.remote.as_ref()
|
||||
&& let Some(status) = r.session.recorder_status()
|
||||
{
|
||||
json!({
|
||||
"ok": true,
|
||||
"active": status.active,
|
||||
"path": status.path.display().to_string(),
|
||||
"elapsed_ms": status.elapsed.as_millis(),
|
||||
"packet_count": status.packet_count,
|
||||
"bytes_written": status.bytes_written,
|
||||
})
|
||||
} else {
|
||||
json!({ "ok": true, "active": false })
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn open_studio_replay(&mut self, path: &str) -> Result<Value> {
|
||||
if let Err(error) = self.save_studio_camera_track() {
|
||||
return Ok(json!({ "ok": false, "error": error.to_string() }));
|
||||
}
|
||||
// The recordings browser can be opened while the title screen is
|
||||
// available but the slower world tables are still loading. Replay
|
||||
// setup builds the normal client registries, which require `rest`.
|
||||
if self.remote.is_none() && self.res.rest.get().is_none() {
|
||||
let pending = PendingWorldEntry::StudioReplay(PathBuf::from(path));
|
||||
if self.defer_world_entry(pending)? {
|
||||
return Ok(json!({ "ok": true, "pending": true }));
|
||||
}
|
||||
self.join_world_rest()?;
|
||||
}
|
||||
|
||||
// Validate and index the archive before replacing the title menu or active world. A
|
||||
// temp-file, metadata, or protocol error must leave the current screen usable.
|
||||
let driver = match ReplayDriver::open(Path::new(path)) {
|
||||
Ok(driver) => driver,
|
||||
Err(error) => {
|
||||
log::error!("Failed to open studio replay '{path}': {error}");
|
||||
return Ok(json!({ "ok": false, "error": error.to_string() }));
|
||||
}
|
||||
};
|
||||
|
||||
let standalone = self.replay.driver.is_none() && self.remote.is_none();
|
||||
if self.remote.is_none()
|
||||
&& let Err(error) = self.init_remote_for_replay()
|
||||
{
|
||||
log::error!("Failed to initialize studio replay session: {error}");
|
||||
return Ok(json!({ "ok": false, "error": error.to_string() }));
|
||||
}
|
||||
|
||||
let duration_ms = driver.duration_ms();
|
||||
let packets = driver.packet_count();
|
||||
self.studio_replay_opened(&driver);
|
||||
if self.replay.driver.is_none() {
|
||||
self.replay.previous_camera_type = Some(self.camera_type);
|
||||
self.replay.standalone = standalone;
|
||||
}
|
||||
self.replay.driver = Some(driver);
|
||||
self.camera_type = super::perspective::CameraType::FirstPerson;
|
||||
self.title = None;
|
||||
self.screen = Screen::Playing;
|
||||
self.capture(false);
|
||||
self.dragging = false;
|
||||
// Apply timestamp-zero login, player pose, and chunk packets before the first rendered
|
||||
// frame so playback opens at the recorded player's viewpoint.
|
||||
let _ = self.advance_studio_replay();
|
||||
self.reset_studio_playback_ticks();
|
||||
log::info!("Studio: Opened replay '{path}' ({duration_ms} ms, {packets} packets)");
|
||||
Ok(json!({
|
||||
"ok": true,
|
||||
"duration_ms": duration_ms,
|
||||
"packet_count": packets,
|
||||
}))
|
||||
}
|
||||
pub(super) fn close_studio_replay(&mut self) -> bool {
|
||||
self.studio_replay_closing();
|
||||
let closed = self.replay.driver.take().is_some();
|
||||
if !closed {
|
||||
return false;
|
||||
}
|
||||
|
||||
// A standalone replay owns a synthetic Remote connection. Treat that
|
||||
// connection as replay state even if the saved origin flag was lost;
|
||||
// sending it through leave_server() opens the Multiplayer screen.
|
||||
let standalone = self.replay.standalone
|
||||
|| self
|
||||
.remote
|
||||
.as_ref()
|
||||
.is_some_and(|remote| remote.brand == "horizon-studio");
|
||||
self.replay.standalone = false;
|
||||
if let Some(camera_type) = self.replay.previous_camera_type.take() {
|
||||
self.camera_type = camera_type;
|
||||
}
|
||||
self.replay.last_tick = None;
|
||||
self.studio_replay_closed();
|
||||
self.replay.virtual_tick_ms = 0.0;
|
||||
self.capture(false);
|
||||
if standalone {
|
||||
if let Some(mut remote) = self.remote.take() {
|
||||
remote.session.disconnect();
|
||||
}
|
||||
self.using = None;
|
||||
self.end_mod_use();
|
||||
if let Err(error) = self.finish_close_world() {
|
||||
log::error!("Failed to close standalone Studio replay cleanly: {error}");
|
||||
}
|
||||
self.reset_studio_hand_state();
|
||||
// A title-screen replay has no previous world perspective to
|
||||
// restore. Start the next world in first person with visible hands.
|
||||
self.camera_type = super::perspective::CameraType::FirstPerson;
|
||||
if let Some(title) = self.title.as_mut() {
|
||||
title.mode = horizon_client::title::Mode::Main;
|
||||
}
|
||||
} else if self.integrated.is_none() && self.lan.is_none() {
|
||||
let _ = self.close_world();
|
||||
if let Some(title) = self.title.as_mut() {
|
||||
title.mode = horizon_client::title::Mode::Main;
|
||||
}
|
||||
}
|
||||
closed
|
||||
}
|
||||
/// Dump current player pose, dimension login, center chunk, and resident loaded chunks
|
||||
/// into the recording stream at timestamp 0ms so replays accurately reconstruct the world.
|
||||
pub(super) fn dump_world_snapshot_to_recorder(
|
||||
&self,
|
||||
recorder: &horizon_client::studio::StudioRecorder,
|
||||
) {
|
||||
let Some(r) = self.remote.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let record_cb = |pkt: horizon_protocol::packets::play::Clientbound| {
|
||||
let encoded = pkt.encode();
|
||||
let mut reader = horizon_protocol::buf::Reader::new(&encoded);
|
||||
if let Ok(id) = reader.varint() {
|
||||
let payload = reader.rest();
|
||||
recorder.record_packet_at(0, id, payload);
|
||||
}
|
||||
};
|
||||
|
||||
// 1. C::Login packet baseline
|
||||
let login = horizon_protocol::packets::play::Clientbound::Login(
|
||||
horizon_protocol::packets::play::Login {
|
||||
player_id: r.entity_id,
|
||||
hardcore: self.level.hardcore,
|
||||
levels: vec!["minecraft:overworld".into()],
|
||||
max_players: 20,
|
||||
chunk_radius: self.settings.render_distance,
|
||||
simulation_distance: self.settings.simulation_distance,
|
||||
reduced_debug_info: false,
|
||||
show_death_screen: true,
|
||||
limited_crafting: false,
|
||||
spawn: horizon_protocol::packets::play::SpawnInfo {
|
||||
dimension_type: 0,
|
||||
dimension: "minecraft:overworld".into(),
|
||||
seed: 0,
|
||||
game_type: 0,
|
||||
previous_game_type: None,
|
||||
debug: false,
|
||||
flat: false,
|
||||
last_death: None,
|
||||
portal_cooldown: 0,
|
||||
sea_level: 63,
|
||||
},
|
||||
online_mode: false,
|
||||
enforces_secure_chat: false,
|
||||
},
|
||||
);
|
||||
record_cb(login);
|
||||
|
||||
// 2. Center chunk and player position
|
||||
let (pos, yaw, pitch) = if let Some(p) = &self.player {
|
||||
(
|
||||
glam::DVec3::new(p.position.x, p.position.y, p.position.z),
|
||||
self.yaw,
|
||||
self.pitch,
|
||||
)
|
||||
} else {
|
||||
(glam::DVec3::ZERO, self.yaw, self.pitch)
|
||||
};
|
||||
let chunk_x = (pos.x.floor() as i32) >> 4;
|
||||
let chunk_z = (pos.z.floor() as i32) >> 4;
|
||||
|
||||
let center_chunk = horizon_protocol::packets::play::Clientbound::SetChunkCacheCenter(
|
||||
horizon_protocol::packets::play::SetChunkCacheCenter {
|
||||
x: chunk_x,
|
||||
z: chunk_z,
|
||||
},
|
||||
);
|
||||
record_cb(center_chunk);
|
||||
|
||||
let player_pos = horizon_protocol::packets::play::Clientbound::PlayerPosition(
|
||||
horizon_protocol::packets::play::PlayerPosition {
|
||||
teleport: 0,
|
||||
position: [pos.x, pos.y, pos.z],
|
||||
delta: [0.0, 0.0, 0.0],
|
||||
yaw,
|
||||
pitch,
|
||||
relatives: 0,
|
||||
},
|
||||
);
|
||||
record_cb(player_pos);
|
||||
|
||||
// 3. Encode resident loaded chunks into LevelChunkWithLight packets
|
||||
let biome_names: Vec<String> = world_biomes!(self)
|
||||
.list
|
||||
.iter()
|
||||
.map(|b| b.name.clone())
|
||||
.collect();
|
||||
let registry: Vec<String> = r
|
||||
.registries
|
||||
.registries
|
||||
.get("minecraft:worldgen/biome")
|
||||
.map(|v| v.iter().map(|(n, _)| n.clone()).collect())
|
||||
.unwrap_or_else(|| biome_names.clone());
|
||||
|
||||
let encoder = crate::lan::chunks::ChunkEncoder::with_signs(
|
||||
self.res.facts().states.iter().map(|s| {
|
||||
(
|
||||
s.name.clone(),
|
||||
s.fluid_level > 0 || s.waterlogged,
|
||||
s.sign_form(),
|
||||
)
|
||||
}),
|
||||
&r.builtin,
|
||||
&biome_names,
|
||||
®istry,
|
||||
);
|
||||
|
||||
let mut count = 0;
|
||||
for chunk in self.world.chunks.values() {
|
||||
let level_chunk = encoder.encode(chunk, &self.world.block_entities);
|
||||
record_cb(
|
||||
horizon_protocol::packets::play::Clientbound::LevelChunkWithLight(level_chunk),
|
||||
);
|
||||
count += 1;
|
||||
}
|
||||
log::info!(
|
||||
"Studio Recorder: Dumped initial world snapshot ({count} chunks) at timestamp 0ms"
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -73,7 +73,7 @@ impl Animation {
|
||||
/// KeyframeAnimation.apply(millis, scale): offsets added to each bone's pose.
|
||||
pub fn apply(&self, millis: i64, scale: f32, poses: &mut super::anim::Poses) {
|
||||
let mut seconds = millis as f32 / 1000.0;
|
||||
if self.looping && self.length > 0.0 {
|
||||
if self.looping {
|
||||
seconds %= self.length;
|
||||
}
|
||||
for (bone, channels) in &self.bones {
|
||||
@@ -231,34 +231,4 @@ mod tests {
|
||||
];
|
||||
assert!((sample(&k, 1.0, 1.0)[0] - 1.0).abs() < 1e-6);
|
||||
}
|
||||
#[test]
|
||||
fn looping_animation_applies_walk_without_overflow() {
|
||||
let anim = Animation {
|
||||
length: 1.25,
|
||||
looping: true,
|
||||
bones: vec![(
|
||||
"body".to_string(),
|
||||
vec![Channel {
|
||||
target: Target::Position,
|
||||
keyframes: vec![
|
||||
Keyframe {
|
||||
time: 0.0,
|
||||
pre: [0.0; 3],
|
||||
post: [0.0, 1.0, 0.0],
|
||||
catmull: false,
|
||||
},
|
||||
Keyframe {
|
||||
time: 1.25,
|
||||
pre: [0.0, 1.0, 0.0],
|
||||
post: [0.0, 1.0, 0.0],
|
||||
catmull: false,
|
||||
},
|
||||
],
|
||||
}],
|
||||
)],
|
||||
};
|
||||
let mut poses = super::super::anim::Poses::default();
|
||||
anim.apply_walk(50.0, 1.0, 1.0, 2.5, &mut poses);
|
||||
assert!((poses.get("body")[1] - 1.0).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ use crate::lan::discovery::Scanner;
|
||||
use crate::text::{DARK_GRAY, GRAY, Text, format_translation};
|
||||
use crate::title::confirm_top;
|
||||
use horizon_protocol::servers::{self, ServerEntry};
|
||||
pub use page::Form;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::{Path, PathBuf},
|
||||
@@ -139,8 +138,6 @@ pub enum Screen {
|
||||
relay: String,
|
||||
focus: u8,
|
||||
},
|
||||
/// A page a crate registered at the title screen's Friends icon, as a form.
|
||||
Page(Form),
|
||||
Disconnected {
|
||||
reason: String,
|
||||
/// Auto-reconnect's next attempt, until it is cancelled.
|
||||
@@ -149,21 +146,6 @@ pub enum Screen {
|
||||
lines: std::cell::Cell<usize>,
|
||||
},
|
||||
}
|
||||
mod page;
|
||||
|
||||
/// What the title screen's Friends icon opens: the page a crate registered there
|
||||
/// (`Place::Title`), else Join a Friend when an invite code can be joined. None: it is inactive.
|
||||
pub fn title_entry() -> Option<Screen> {
|
||||
match page::title_page() {
|
||||
Some(page) => Some(Screen::Page(Form::open(page))),
|
||||
None => crate::net::joins_friends().then(|| Screen::friend(true)),
|
||||
}
|
||||
}
|
||||
/// Whether the title screen's Friends icon opens something ([`title_entry`]).
|
||||
pub fn has_title_entry() -> bool {
|
||||
page::title_page().is_some() || crate::net::joins_friends()
|
||||
}
|
||||
|
||||
/// What a click asks the title screen to do.
|
||||
pub enum Outcome {
|
||||
None,
|
||||
@@ -194,7 +176,6 @@ impl Screen {
|
||||
Self::Direct { .. } => "direct_connect",
|
||||
Self::ConfirmDelete { .. } => "server_delete",
|
||||
Self::Friend { .. } => "friends_join",
|
||||
Self::Page(form) => form.id(),
|
||||
Self::Disconnected { .. } => "disconnected",
|
||||
}
|
||||
}
|
||||
@@ -763,7 +744,6 @@ impl Screen {
|
||||
53,
|
||||
),
|
||||
],
|
||||
Self::Page(form) => form.buttons(w, h),
|
||||
Self::Friend { code, .. } => vec![
|
||||
b(
|
||||
half(w) - 100.0,
|
||||
@@ -884,7 +864,6 @@ impl Screen {
|
||||
]),
|
||||
)
|
||||
}
|
||||
Self::Page(form) => form.narration(),
|
||||
Self::Friend { .. } => ScreenInfo::new(
|
||||
"multiplayer/friend",
|
||||
get("horizon.multiplayer.join_friend", "Join a Friend"),
|
||||
@@ -1339,7 +1318,6 @@ impl Screen {
|
||||
centered(batch, &line.plain(), top + 17.0 + i as f32 * 9.0, u32::MAX);
|
||||
}
|
||||
}
|
||||
Self::Page(form) => form.draw(atlas, batch, caret, focused_at),
|
||||
Self::Friend {
|
||||
code, relay, focus, ..
|
||||
} => {
|
||||
@@ -1405,7 +1383,6 @@ impl Screen {
|
||||
/// selected server or LAN game, as a click on it does.
|
||||
pub fn confirm(&self) -> Outcome {
|
||||
match self {
|
||||
Self::Page(form) => form.confirm(),
|
||||
Self::Direct { address } if servers::parse_address(address).is_some() => {
|
||||
Outcome::Connect {
|
||||
address: direct_address(address),
|
||||
@@ -1454,9 +1431,6 @@ impl Screen {
|
||||
saves: &Path,
|
||||
) -> Outcome {
|
||||
let (w, h) = size;
|
||||
if let Self::Page(form) = self {
|
||||
return form.click(id, x, y);
|
||||
}
|
||||
match (&mut *self, id) {
|
||||
(
|
||||
Self::List {
|
||||
@@ -1849,7 +1823,6 @@ impl Screen {
|
||||
Self::Friend { focus: 0, .. } => 64.0,
|
||||
Self::Friend { focus: 1, .. } => 105.0,
|
||||
Self::Friend { .. } => return None,
|
||||
Self::Page(form) => return form.focused_box(),
|
||||
_ => return None,
|
||||
};
|
||||
Some([(w / 2.0).floor() - 100.0, y, 200.0, 20.0])
|
||||
@@ -1878,13 +1851,6 @@ impl Screen {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Whether a press off the buttons may land on one of its text fields.
|
||||
pub fn has_fields(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Edit { .. } | Self::Friend { .. } | Self::Page(_)
|
||||
)
|
||||
}
|
||||
/// Join a friend, with the relay the player set before.
|
||||
pub fn friend(from_title: bool) -> Self {
|
||||
Self::Friend {
|
||||
@@ -1944,9 +1910,6 @@ impl Screen {
|
||||
}
|
||||
/// The focused text field, its id and maximum length (copy and paste).
|
||||
pub fn focused_text(&mut self) -> Option<(crate::clipboard::FieldId, &mut String, usize)> {
|
||||
if let Self::Page(form) = self {
|
||||
return form.focused_text();
|
||||
}
|
||||
let id = match self {
|
||||
Self::Edit { focus: 0, .. } => SERVER_NAME,
|
||||
Self::Edit { .. } => SERVER_ADDRESS,
|
||||
@@ -1971,9 +1934,6 @@ impl Screen {
|
||||
/// round to the first field. `button` is the focused button, if any; a field
|
||||
/// has the focus (`focus` below 2) only while no button does.
|
||||
pub fn tab_dir(&mut self, reverse: bool, buttons: &[u8], button: &mut Option<u8>) {
|
||||
if let Self::Page(form) = self {
|
||||
return form.tab_dir(reverse, buttons, button);
|
||||
}
|
||||
let (Self::Edit { focus, .. } | Self::Friend { focus, .. }) = self else {
|
||||
return;
|
||||
};
|
||||
@@ -2000,7 +1960,6 @@ impl Screen {
|
||||
pub fn escape(&mut self, saves: &Path) -> bool {
|
||||
match self {
|
||||
Self::List { .. }
|
||||
| Self::Page(_)
|
||||
| Self::Friend {
|
||||
from_title: true, ..
|
||||
} => true,
|
||||
|
||||
@@ -1,467 +0,0 @@
|
||||
//! A page a crate registers at `horizon_hooks::pages::Place::Title`, which the title screen's
|
||||
//! Friends icon opens: a form drawn as Join a Friend and `ManageServerScreen` are. The title at
|
||||
//! the top, then the rows from y 53 (a field's grey label, its 200-wide `EditBox` 11 below, the
|
||||
//! next 41 lower; text 10 a line), and the page's buttons in a column under them from a quarter
|
||||
//! of the height + 114, 24 apart, the one that leaves it last.
|
||||
//!
|
||||
//! The engine keeps each field's text while the form is open (the title screen's caret edits
|
||||
//! it, as every field of its own) and tells the page each change before anything else reaches
|
||||
//! it ([`Act::Edit`]), then Enter in a field ([`Act::Submit`]) and the presses. A page joins a
|
||||
//! server with `horizon_hooks::pages::connect`.
|
||||
use super::{ButtonSpec, Outcome, centered_x, half, quarter};
|
||||
use crate::hud::{HudAtlas, HudBatch, rgba};
|
||||
use horizon_hooks::pages::{self, Act, Page, Place, Row};
|
||||
use std::cell::RefCell;
|
||||
use std::time::Instant;
|
||||
|
||||
/// The first button's id; button `k` of the page is `FIRST + k`.
|
||||
const FIRST: u8 = 100;
|
||||
/// The text a line of the form wraps at.
|
||||
const WIDTH: f32 = 300.0;
|
||||
|
||||
/// The page the title screen's Friends icon opens: the first registered there.
|
||||
pub(super) fn title_page() -> Option<&'static dyn Page> {
|
||||
pages::engine::all()
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|page| page.place() == Place::Title)
|
||||
}
|
||||
|
||||
/// An open form: the page, and what the engine keeps while it is open.
|
||||
#[derive(Clone)]
|
||||
pub struct Form {
|
||||
page: &'static dyn Page,
|
||||
/// Each field's text, in the order the rows have them.
|
||||
texts: Vec<String>,
|
||||
/// The text the page last heard of each field.
|
||||
sent: RefCell<Vec<String>>,
|
||||
/// The focused field; `texts.len()` (or more) while a button has the focus.
|
||||
pub(super) focus: usize,
|
||||
/// Each field's box where the last draw put it.
|
||||
boxes: RefCell<Vec<[f32; 4]>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Form {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Form")
|
||||
.field("page", &self.page.id())
|
||||
.field("texts", &self.texts)
|
||||
.field("focus", &self.focus)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// A button of the form: its row (None: the one that leaves), label key, and whether it acts.
|
||||
struct Button {
|
||||
row: Option<usize>,
|
||||
label: &'static str,
|
||||
active: bool,
|
||||
half: bool,
|
||||
}
|
||||
|
||||
impl Form {
|
||||
/// The form of `page`, its fields holding the text its rows start with, the first focused.
|
||||
pub fn open(page: &'static dyn Page) -> Self {
|
||||
let mut rows = Vec::new();
|
||||
page.rows(&mut rows);
|
||||
let texts: Vec<String> = rows
|
||||
.iter()
|
||||
.filter_map(|row| match row {
|
||||
Row::Field { text, .. } => Some(text.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
Self {
|
||||
page,
|
||||
sent: RefCell::new(texts.clone()),
|
||||
boxes: RefCell::new(vec![[0.0; 4]; texts.len()]),
|
||||
texts,
|
||||
focus: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn id(&self) -> &'static str {
|
||||
self.page.id()
|
||||
}
|
||||
|
||||
/// The page's rows, after it heard every change to its fields.
|
||||
fn rows(&self) -> Vec<Row> {
|
||||
let mut rows = Vec::new();
|
||||
self.page.rows(&mut rows);
|
||||
let fields = field_rows(&rows);
|
||||
let mut sent = self.sent.borrow_mut();
|
||||
let mut told = false;
|
||||
for ((text, sent), row) in self.texts.iter().zip(sent.iter_mut()).zip(fields) {
|
||||
if text != sent {
|
||||
sent.clone_from(text);
|
||||
self.page.act(row, Act::Edit(text.clone()));
|
||||
told = true;
|
||||
}
|
||||
}
|
||||
if told {
|
||||
rows.clear();
|
||||
self.page.rows(&mut rows);
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
fn buttons_of(rows: &[Row], back: &'static str) -> Vec<Button> {
|
||||
let mut buttons: Vec<Button> = rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(r, row)| match row {
|
||||
Row::Button {
|
||||
label,
|
||||
active,
|
||||
half,
|
||||
..
|
||||
} => Some(Button {
|
||||
row: Some(r),
|
||||
label,
|
||||
active: *active,
|
||||
half: *half,
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
buttons.push(Button {
|
||||
row: None,
|
||||
label: back,
|
||||
active: true,
|
||||
half: false,
|
||||
});
|
||||
buttons
|
||||
}
|
||||
|
||||
/// The id of the button that leaves: Cancel's or Done's, for the controller.
|
||||
fn back_id(&self) -> u8 {
|
||||
if self.page.back() == "gui.cancel" {
|
||||
57
|
||||
} else {
|
||||
50
|
||||
}
|
||||
}
|
||||
|
||||
pub fn buttons(&self, w: f32, h: f32) -> Vec<ButtonSpec> {
|
||||
let rows = self.rows();
|
||||
let cx = half(w);
|
||||
let mut y = quarter(h) + 114.0;
|
||||
let mut beside = false;
|
||||
let mut out = Vec::new();
|
||||
for (k, b) in Self::buttons_of(&rows, self.page.back())
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let id = match b.row {
|
||||
Some(_) => FIRST + k as u8,
|
||||
None => self.back_id(),
|
||||
};
|
||||
let label = crate::lang::get(b.label);
|
||||
let (x, bw) = match (b.half, beside) {
|
||||
(true, true) => {
|
||||
y -= 24.0;
|
||||
beside = false;
|
||||
(cx + 2.0, 98.0)
|
||||
}
|
||||
(true, false) => {
|
||||
beside = true;
|
||||
(cx - 100.0, 98.0)
|
||||
}
|
||||
(false, _) => {
|
||||
beside = false;
|
||||
(cx - 100.0, 200.0)
|
||||
}
|
||||
};
|
||||
out.push((x, y, bw, label, b.active, id));
|
||||
y += 24.0;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn draw(
|
||||
&self,
|
||||
atlas: &HudAtlas,
|
||||
batch: &mut HudBatch,
|
||||
caret: &mut crate::ui::Caret,
|
||||
focused_at: Instant,
|
||||
) {
|
||||
let w = batch.width;
|
||||
let rows = self.rows();
|
||||
let title = crate::lang::get(self.page.title());
|
||||
let tw = HudBatch::text_width(atlas, &title);
|
||||
batch.text(atlas, &title, centered_x(w, tw), 20.0, u32::MAX, true);
|
||||
let mut y = 53.0;
|
||||
let mut field = 0;
|
||||
let mut boxes = self.boxes.borrow_mut();
|
||||
for (r, row) in rows.iter().enumerate() {
|
||||
let (text, color) = match row {
|
||||
Row::Field { label, max, .. } => {
|
||||
let Some(value) = self.texts.get(field) else {
|
||||
break;
|
||||
};
|
||||
super::Screen::field(
|
||||
atlas,
|
||||
batch,
|
||||
caret,
|
||||
focused_at,
|
||||
&crate::lang::get(label),
|
||||
value,
|
||||
("page.field", r),
|
||||
*max,
|
||||
(y, y + 11.0),
|
||||
self.focus == field,
|
||||
);
|
||||
boxes[field] = [half(w) - 100.0, y + 11.0, 200.0, 20.0];
|
||||
field += 1;
|
||||
y += 41.0;
|
||||
continue;
|
||||
}
|
||||
Row::Space(px) => {
|
||||
y += f32::from(*px);
|
||||
continue;
|
||||
}
|
||||
Row::Heading(key) => (crate::lang::get(key), u32::MAX),
|
||||
Row::Note(text) => (text.clone(), rgba(160, 160, 160, 255)),
|
||||
Row::Text { text, quiet, .. } => {
|
||||
let grey = rgba(160, 160, 160, 255);
|
||||
(text.clone(), if *quiet { grey } else { u32::MAX })
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
for line in super::wrap(&text, WIDTH, atlas) {
|
||||
let lw = HudBatch::text_width(atlas, &line);
|
||||
batch.text(atlas, &line, centered_x(w, lw), y, color, true);
|
||||
y += 10.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn narration(&self) -> crate::widgets::ScreenInfo {
|
||||
crate::widgets::ScreenInfo::new(self.page.id(), crate::lang::get(self.page.title()))
|
||||
}
|
||||
|
||||
/// A press on button `id`, or on a field (`id` None).
|
||||
pub fn click(&mut self, id: Option<u8>, x: f32, y: f32) -> Outcome {
|
||||
match id {
|
||||
Some(id) if id == self.back_id() => Outcome::Back,
|
||||
Some(id) if id >= FIRST => {
|
||||
let rows = self.rows();
|
||||
let buttons = Self::buttons_of(&rows, self.page.back());
|
||||
match buttons.get(usize::from(id - FIRST)) {
|
||||
Some(Button {
|
||||
row: Some(r),
|
||||
active: true,
|
||||
..
|
||||
}) => self.act(*r, Act::Press),
|
||||
_ => Outcome::None,
|
||||
}
|
||||
}
|
||||
Some(_) => Outcome::None,
|
||||
None => {
|
||||
let hit = self
|
||||
.boxes
|
||||
.borrow()
|
||||
.iter()
|
||||
.position(|&[bx, by, bw, bh]| crate::ui::hovered((x, y), bx, by, bw, bh));
|
||||
if let Some(field) = hit {
|
||||
self.focus = field;
|
||||
}
|
||||
Outcome::None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Enter: the focused field submits.
|
||||
pub fn confirm(&self) -> Outcome {
|
||||
let rows = self.rows();
|
||||
match field_rows(&rows).nth(self.focus) {
|
||||
Some(r) => self.act(r, Act::Submit),
|
||||
None => Outcome::None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The page acts; a server it asked to join is joined.
|
||||
fn act(&self, row: usize, act: Act) -> Outcome {
|
||||
self.page.act(row, act);
|
||||
match pages::engine::take_connect() {
|
||||
Some(address) => Outcome::Connect {
|
||||
address,
|
||||
lan: false,
|
||||
},
|
||||
None => Outcome::None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn focused_box(&self) -> Option<[f32; 4]> {
|
||||
self.boxes.borrow().get(self.focus).copied()
|
||||
}
|
||||
|
||||
/// The focused field, its id and maximum length.
|
||||
pub fn focused_text(&mut self) -> Option<(crate::clipboard::FieldId, &mut String, usize)> {
|
||||
let mut rows = Vec::new();
|
||||
self.page.rows(&mut rows);
|
||||
let (r, max) = rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(r, row)| match row {
|
||||
Row::Field { max, .. } => Some((r, *max)),
|
||||
_ => None,
|
||||
})
|
||||
.nth(self.focus)?;
|
||||
let text = self.texts.get_mut(self.focus)?;
|
||||
Some((("page.field", r), text, max))
|
||||
}
|
||||
|
||||
/// Tab through the fields, then the enabled `buttons`, then round to the first field.
|
||||
pub fn tab_dir(&mut self, reverse: bool, buttons: &[u8], button: &mut Option<u8>) {
|
||||
let fields = self.texts.len();
|
||||
let stops = fields + buttons.len();
|
||||
if stops == 0 {
|
||||
return;
|
||||
}
|
||||
let at = match *button {
|
||||
Some(id) => buttons.iter().position(|&b| b == id).map(|i| fields + i),
|
||||
None => Some(self.focus.min(fields.saturating_sub(1))),
|
||||
}
|
||||
.unwrap_or(0);
|
||||
let next = if reverse {
|
||||
(at + stops - 1) % stops
|
||||
} else {
|
||||
(at + 1) % stops
|
||||
};
|
||||
if next < fields {
|
||||
self.focus = next;
|
||||
*button = None;
|
||||
} else {
|
||||
self.focus = fields;
|
||||
*button = Some(buttons[next - fields]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The rows that are fields, in order.
|
||||
fn field_rows(rows: &[Row]) -> impl Iterator<Item = usize> + '_ {
|
||||
rows.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, row)| matches!(row, Row::Field { .. }))
|
||||
.map(|(r, _)| r)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// A join form: a code and a relay, Join while the code has 4 characters or more.
|
||||
struct Join {
|
||||
heard: Mutex<Vec<(usize, Act)>>,
|
||||
code: Mutex<String>,
|
||||
}
|
||||
|
||||
impl Page for Join {
|
||||
fn id(&self) -> &'static str {
|
||||
"test.join"
|
||||
}
|
||||
fn title(&self) -> &'static str {
|
||||
"horizon.multiplayer.join_friend"
|
||||
}
|
||||
fn place(&self) -> Place {
|
||||
Place::Title
|
||||
}
|
||||
fn rows(&self, rows: &mut Vec<Row>) {
|
||||
let code = self.code.lock().unwrap().clone();
|
||||
rows.push(Row::field("horizon.multiplayer.invite_code", code.clone()).max(8));
|
||||
rows.push(Row::field(
|
||||
"horizon.multiplayer.relay",
|
||||
"relay.example:25599",
|
||||
));
|
||||
rows.push(Row::button("horizon.multiplayer.join").active(code.len() >= 4));
|
||||
}
|
||||
fn act(&self, row: usize, act: Act) {
|
||||
if let (0, Act::Edit(text)) = (row, &act) {
|
||||
text.clone_into(&mut self.code.lock().unwrap());
|
||||
}
|
||||
if matches!((row, &act), (2, Act::Press) | (0, Act::Submit)) {
|
||||
pages::connect(format!("friend:{}", self.code.lock().unwrap()));
|
||||
}
|
||||
self.heard.lock().unwrap().push((row, act));
|
||||
}
|
||||
fn back(&self) -> &'static str {
|
||||
"gui.cancel"
|
||||
}
|
||||
}
|
||||
|
||||
fn join() -> &'static Join {
|
||||
Box::leak(Box::new(Join {
|
||||
heard: Mutex::new(Vec::new()),
|
||||
code: Mutex::new(String::new()),
|
||||
}))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_form_lays_out_as_join_a_friend() {
|
||||
let form = Form::open(join());
|
||||
let buttons = form.buttons(427.0, 240.0);
|
||||
// Join at a quarter of the height + 114, Cancel 24 below, both 200 wide and centred.
|
||||
assert_eq!(
|
||||
buttons
|
||||
.iter()
|
||||
.map(|(x, y, w, _, on, id)| (*x, *y, *w, *on, *id))
|
||||
.collect::<Vec<_>>(),
|
||||
[
|
||||
(113.0, 174.0, 200.0, false, FIRST),
|
||||
(113.0, 198.0, 200.0, true, 57)
|
||||
]
|
||||
);
|
||||
assert_eq!(form.texts, ["", "relay.example:25599"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_reaches_the_page_before_a_press_and_enter_submits() {
|
||||
let page = join();
|
||||
let mut form = Form::open(page);
|
||||
let (id, text, max) = form.focused_text().unwrap();
|
||||
assert_eq!((id, max), (("page.field", 0), 8));
|
||||
text.push_str("AB");
|
||||
// Join is greyed: the page heard "AB" before the buttons were laid out.
|
||||
assert!(!form.buttons(427.0, 240.0)[0].4);
|
||||
form.focused_text().unwrap().1.push_str("CD");
|
||||
assert!(form.buttons(427.0, 240.0)[0].4);
|
||||
match form.click(Some(FIRST), 0.0, 0.0) {
|
||||
Outcome::Connect { address, lan } => {
|
||||
assert_eq!((address.as_str(), lan), ("friend:ABCD", false))
|
||||
}
|
||||
_ => panic!("Join did not connect"),
|
||||
}
|
||||
match form.confirm() {
|
||||
Outcome::Connect { address, .. } => assert_eq!(address, "friend:ABCD"),
|
||||
_ => panic!("Enter did not submit"),
|
||||
}
|
||||
let heard = page.heard.lock().unwrap();
|
||||
assert_eq!(
|
||||
*heard,
|
||||
[
|
||||
(0, Act::Edit("AB".into())),
|
||||
(0, Act::Edit("ABCD".into())),
|
||||
(2, Act::Press),
|
||||
(0, Act::Submit)
|
||||
]
|
||||
);
|
||||
assert!(matches!(form.click(Some(57), 0.0, 0.0), Outcome::Back));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_goes_through_the_fields_then_the_buttons() {
|
||||
let mut form = Form::open(join());
|
||||
let mut button = None;
|
||||
form.tab_dir(false, &[57], &mut button);
|
||||
assert_eq!((form.focus, button), (1, None));
|
||||
form.tab_dir(false, &[57], &mut button);
|
||||
assert_eq!((form.focus, button), (2, Some(57)));
|
||||
form.tab_dir(false, &[57], &mut button);
|
||||
assert_eq!((form.focus, button), (0, None));
|
||||
form.tab_dir(true, &[57], &mut button);
|
||||
assert_eq!(button, Some(57));
|
||||
assert!(form.focused_text().is_none());
|
||||
}
|
||||
}
|
||||
@@ -523,22 +523,6 @@ pub fn is_invite(code: &str, full: bool) -> bool {
|
||||
horizon_hooks::engine::tunnel().is_some_and(|r| r.is_code(code, full))
|
||||
}
|
||||
|
||||
/// Whether an invite code can be joined: a resolver is registered (horizon-official registers
|
||||
/// horizon-friends'). The title screen's Friends icon, which opens Join a Friend, is inactive
|
||||
/// without one, as the pause menu's is without a page to share from.
|
||||
pub fn joins_friends() -> bool {
|
||||
#[cfg(test)]
|
||||
if JOINS_FRIENDS_IN_TEST.get() {
|
||||
return true;
|
||||
}
|
||||
horizon_hooks::engine::tunnel().is_some()
|
||||
}
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// A test's stand-in for a registered resolver: the registry is the process's and sealed.
|
||||
pub(crate) static JOINS_FRIENDS_IN_TEST: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
}
|
||||
|
||||
/// An address as players and logs see it: a friend's invite code stays
|
||||
/// out of it (its secret is in there).
|
||||
pub fn shown_address(address: &str) -> String {
|
||||
|
||||
@@ -1940,8 +1940,8 @@ impl Title {
|
||||
)
|
||||
.does(crate::widgets::Action::Exit),
|
||||
];
|
||||
// CommonButtons' icon buttons: vanilla's Friends button opens a crate's page or
|
||||
// Join a Friend, inactive with neither (`multiplayer::title_entry`).
|
||||
// CommonButtons' icon buttons; Horizon's Join a Friend (an
|
||||
// invite code) sits behind vanilla's Friends button.
|
||||
for (i, (key, plain, id, icon)) in [
|
||||
("gui.friends.open", "Friends", 5, "friends/friends"),
|
||||
("options.language", "Language...", 6, "icon/language"),
|
||||
@@ -1960,7 +1960,7 @@ impl Title {
|
||||
y + 72.0,
|
||||
20.0,
|
||||
&lang::get_or(key, plain),
|
||||
id != 5 || crate::multiplayer::has_title_entry(),
|
||||
true,
|
||||
id,
|
||||
);
|
||||
button.icon = Some((icon, 15.0));
|
||||
@@ -3658,7 +3658,12 @@ Would you like to enable the Narrator or visit the Accessibility Settings?",
|
||||
.map(|b| b.id);
|
||||
if hit.is_some() {
|
||||
self.focused_button = hit;
|
||||
} else if matches!(&self.mode, Mode::Multiplayer(screen) if screen.has_fields()) {
|
||||
} else if matches!(
|
||||
self.mode,
|
||||
Mode::Multiplayer(
|
||||
crate::multiplayer::Screen::Edit { .. } | crate::multiplayer::Screen::Friend { .. }
|
||||
)
|
||||
) {
|
||||
// A press on a text field takes the focus from the button.
|
||||
self.focused_button = None;
|
||||
}
|
||||
@@ -3777,7 +3782,7 @@ Would you like to enable the Narrator or visit the Accessibility Settings?",
|
||||
crate::multiplayer::Outcome::None => Action::None,
|
||||
},
|
||||
(Mode::Main, Some(5)) => {
|
||||
self.mode = crate::multiplayer::title_entry().map_or(Mode::Main, Mode::Multiplayer);
|
||||
self.mode = Mode::Multiplayer(crate::multiplayer::Screen::friend(true));
|
||||
Action::None
|
||||
}
|
||||
(Mode::Onboarding(_), Some(onboarding::SOUNDS)) => Action::Sounds,
|
||||
@@ -5301,8 +5306,6 @@ mod classic_tests {
|
||||
|
||||
#[test]
|
||||
fn classic_main_menu_icons_route_language_and_accessibility() {
|
||||
// Join a Friend needs a resolver (title/tests.rs tests the icon without one).
|
||||
crate::net::JOINS_FRIENDS_IN_TEST.set(true);
|
||||
let mut title = Title::new(
|
||||
PathBuf::new(),
|
||||
Path::new("/nonexistent/horizon-test-resources"),
|
||||
|
||||
@@ -100,61 +100,13 @@ impl Drop for Fixture {
|
||||
}
|
||||
}
|
||||
|
||||
/// Join a Friend needs a resolver; these tests stand one in (`net::JOINS_FRIENDS_IN_TEST`).
|
||||
fn with_resolver() -> Fixture {
|
||||
crate::net::JOINS_FRIENDS_IN_TEST.set(true);
|
||||
Fixture::new()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_resolver_the_friends_icon_is_inactive_and_opens_nothing() {
|
||||
crate::net::JOINS_FRIENDS_IN_TEST.set(false);
|
||||
let mut f = Fixture::new();
|
||||
let icons: Vec<_> = f
|
||||
.title
|
||||
.buttons(480.0, 262.0)
|
||||
.into_iter()
|
||||
.filter(|b| b.icon.is_some())
|
||||
.map(|b| (b.label.clone(), b.enabled, b.x))
|
||||
.collect();
|
||||
// Same place and look as with one, only greyed; Language and Accessibility stay active.
|
||||
let friends = icons.iter().find(|(label, ..)| label == "Friends").unwrap();
|
||||
assert!(!friends.1);
|
||||
assert!(
|
||||
icons
|
||||
.iter()
|
||||
.filter(|(label, ..)| label != "Friends")
|
||||
.all(|(_, on, _)| *on)
|
||||
);
|
||||
assert_eq!(f.click("Friends"), Action::None);
|
||||
assert!(matches!(f.title.mode, Mode::Main));
|
||||
crate::net::JOINS_FRIENDS_IN_TEST.set(true);
|
||||
let with: Vec<_> = f
|
||||
.title
|
||||
.buttons(480.0, 262.0)
|
||||
.into_iter()
|
||||
.filter(|b| b.icon.is_some())
|
||||
.map(|b| (b.label.clone(), b.x, b.y))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
with.iter()
|
||||
.map(|(l, x, _)| (l.clone(), *x))
|
||||
.collect::<Vec<_>>(),
|
||||
icons
|
||||
.iter()
|
||||
.map(|(l, _, x)| (l.clone(), *x))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
crate::net::JOINS_FRIENDS_IN_TEST.set(false);
|
||||
}
|
||||
|
||||
mod multiplayer_parent_navigation {
|
||||
use super::*;
|
||||
use crate::multiplayer::{Outcome, Screen};
|
||||
|
||||
#[test]
|
||||
fn friend_cancel_returns_to_title() {
|
||||
let mut f = with_resolver();
|
||||
let mut f = Fixture::new();
|
||||
f.click("Friends");
|
||||
assert!(matches!(
|
||||
f.title.mode,
|
||||
@@ -166,7 +118,7 @@ mod multiplayer_parent_navigation {
|
||||
|
||||
#[test]
|
||||
fn friend_escape_returns_to_title() {
|
||||
let mut f = with_resolver();
|
||||
let mut f = Fixture::new();
|
||||
f.click("Friends");
|
||||
assert!(matches!(
|
||||
f.title.mode,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
//! Lang files a crate above the engine ships with its text (docs/layers.md, "Platform
|
||||
//! registries"). A crate [`register`]s its `(code, JSON)` pairs, usually `include_str!`s of its
|
||||
//! `assets/lang/<code>.json`, before the game starts. The game loads them as part of its own
|
||||
//! `horizon` namespace, right after its bundled files for the same language, so resource packs
|
||||
//! still override them; on a key both have, the engine's file wins. A regional variant reads its
|
||||
//! language's file as the engine's do (`fr_ca` reads `fr_fr`, `de_ch` reads `de_de` without `ß`).
|
||||
use crate::{TooLate, registry::Registry};
|
||||
|
||||
/// One crate's lang files: a language code (`en_us`, `fr_fr`) and that file's JSON text.
|
||||
pub type Files = [(&'static str, &'static str)];
|
||||
|
||||
static FILES: Registry<Files> = Registry::new();
|
||||
|
||||
/// Adds a crate's lang files. Before the game starts, as [`crate::register`]; a later call is
|
||||
/// refused.
|
||||
pub fn register(files: &'static Files) -> Result<(), TooLate> {
|
||||
FILES.register(files)
|
||||
}
|
||||
|
||||
pub(crate) fn all() -> &'static [&'static Files] {
|
||||
FILES.all()
|
||||
}
|
||||
|
||||
/// The engine's side. Only the engine calls it.
|
||||
pub mod engine {
|
||||
/// Every crate's files, in registration order, sealing the list on the first call.
|
||||
pub fn all() -> &'static [&'static super::Files] {
|
||||
super::all()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn files_registered_before_the_game_starts_are_kept_in_order() {
|
||||
static A: &Files = &[("en_us", r#"{"a.b.c": "A"}"#)];
|
||||
static B: &Files = &[("en_us", r#"{"d.e.f": "B"}"#), ("fr_fr", "{}")];
|
||||
assert_eq!(register(A), Ok(()));
|
||||
assert_eq!(register(B), Ok(()));
|
||||
let all = engine::all();
|
||||
assert_eq!(all.len(), 2);
|
||||
assert_eq!(all[1][1].0, "fr_fr");
|
||||
assert_eq!(register(A), Err(TooLate));
|
||||
}
|
||||
}
|
||||
@@ -14,10 +14,11 @@
|
||||
//!
|
||||
//! The platform registries (docs/layers.md, "Platform registries") have the same shape:
|
||||
//! [`settings`] for the settings each crate keeps in its own lines, [`pages`] for its settings
|
||||
//! pages, [`hud`] for what it draws on the HUD.
|
||||
//! pages, [`hud`] for what it draws on the HUD, [`lang`] for the lang files with its text.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod hud;
|
||||
pub mod lang;
|
||||
pub mod pages;
|
||||
pub mod players;
|
||||
mod registry;
|
||||
@@ -155,6 +156,7 @@ pub mod engine {
|
||||
crate::settings::all();
|
||||
crate::pages::engine::all();
|
||||
crate::hud::engine::all();
|
||||
crate::lang::all();
|
||||
}
|
||||
|
||||
/// [`Hooks::started`].
|
||||
|
||||
@@ -19,11 +19,6 @@ pub enum Place {
|
||||
/// start a group of their own after the page's. A [`Row::Heading`] starts another group. A
|
||||
/// [`Row::Key`] shows its key there, without changing it: give a page of its own to a key.
|
||||
Extras,
|
||||
/// The title screen's Friends icon slot (vanilla's `CommonButtons.friends`, beside Language
|
||||
/// and Accessibility): with a page registered here the icon opens the first one as a form of
|
||||
/// its own, as Join a Friend is drawn ([`Row::Field`]). It shows heading, note, text, space,
|
||||
/// field and button rows; the others belong in the options shell.
|
||||
Title,
|
||||
}
|
||||
|
||||
/// One page. Its values live in the crate (statics, often its settings [`crate::settings::Store`]);
|
||||
@@ -45,11 +40,6 @@ pub trait Page: Sync {
|
||||
fn tooltip(&self, _row: usize) -> Option<Label> {
|
||||
None
|
||||
}
|
||||
/// The label of the button the engine adds to leave a page drawn as its own screen: a
|
||||
/// translation key, `gui.done` by default (`gui.cancel` for a form that does something).
|
||||
fn back(&self) -> &'static str {
|
||||
"gui.done"
|
||||
}
|
||||
}
|
||||
|
||||
/// A row of a page. Labels are translation keys.
|
||||
@@ -95,16 +85,6 @@ pub enum Row {
|
||||
/// A key binding, as Key Binds shows one: `key` is its key name (`key.keyboard.z`,
|
||||
/// `key.mouse.left`). A press waits for the next key or mouse button ([`Act::Bind`]).
|
||||
Key { label: &'static str, key: String },
|
||||
/// A text field ([`Row::field`]) on a page drawn as its own screen, as vanilla's `EditBox`:
|
||||
/// its label above it, focus by click or Tab, typing, the caret keys, selection, copy and
|
||||
/// paste, at most `max` characters. `text` is its value when the page opens; the engine then
|
||||
/// keeps it, and sends each change ([`Act::Edit`]) and Enter ([`Act::Submit`]).
|
||||
#[non_exhaustive]
|
||||
Field {
|
||||
label: &'static str,
|
||||
text: String,
|
||||
max: usize,
|
||||
},
|
||||
}
|
||||
|
||||
impl Row {
|
||||
@@ -127,23 +107,6 @@ impl Row {
|
||||
}
|
||||
}
|
||||
|
||||
/// A text field starting with `text`, at most 32 characters (`EditBox`'s default).
|
||||
pub fn field(label: &'static str, text: impl Into<String>) -> Self {
|
||||
Self::Field {
|
||||
label,
|
||||
text: text.into(),
|
||||
max: 32,
|
||||
}
|
||||
}
|
||||
|
||||
/// A text field taking at most `chars` characters.
|
||||
pub fn max(mut self, chars: usize) -> Self {
|
||||
if let Self::Field { max, .. } = &mut self {
|
||||
*max = chars;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// A button greyed out (`false`): drawn, never pressed.
|
||||
pub fn active(mut self, on: bool) -> Self {
|
||||
if let Self::Button { active, .. } = &mut self {
|
||||
@@ -204,19 +167,6 @@ pub enum Act {
|
||||
Slide(f32),
|
||||
/// A key row got its new key: its key name (`key.keyboard.z`).
|
||||
Bind(String),
|
||||
/// A field's text changed: all of it.
|
||||
Edit(String),
|
||||
/// Enter in a field.
|
||||
Submit,
|
||||
}
|
||||
|
||||
/// The server a page asked to join ([`connect`]).
|
||||
static CONNECT: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
|
||||
|
||||
/// From a [`Place::Title`] page's [`Page::act`]: join the server at `address` as Direct
|
||||
/// Connection does (the engine's addresses, `friend:<code>` among them), once the act returns.
|
||||
pub fn connect(address: impl Into<String>) {
|
||||
*CONNECT.lock().unwrap_or_else(|e| e.into_inner()) = Some(address.into());
|
||||
}
|
||||
|
||||
static PAGES: Registry<dyn Page> = Registry::new();
|
||||
@@ -235,9 +185,4 @@ pub mod engine {
|
||||
pub fn all() -> &'static [&'static dyn Page] {
|
||||
PAGES.all()
|
||||
}
|
||||
|
||||
/// The server a page asked to join since the last call ([`connect`]).
|
||||
pub fn take_connect() -> Option<String> {
|
||||
CONNECT.lock().unwrap_or_else(|e| e.into_inner()).take()
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,13 @@ pub mod settings {
|
||||
|
||||
/// Settings pages a crate adds to the options screens (docs/layers.md, "Platform registries").
|
||||
pub mod pages {
|
||||
pub use horizon_hooks::pages::{Act, Label, Page, Place, Row, connect, register};
|
||||
pub use horizon_hooks::pages::{Act, Label, Page, Place, Row, register};
|
||||
}
|
||||
|
||||
/// The lang files with a crate's text, loaded with the game's own (docs/layers.md, "Platform
|
||||
/// registries").
|
||||
pub mod lang {
|
||||
pub use horizon_hooks::lang::{Files, register};
|
||||
}
|
||||
|
||||
/// Drawing on the HUD (docs/layers.md, "Platform registries").
|
||||
|
||||
+28
-18
@@ -322,8 +322,8 @@ hook (#14081), `seat()` (#14096).
|
||||
only calls it.
|
||||
- With nothing registered, the engine does no work: a load of an empty slice where it would dispatch. It makes
|
||||
no allocation, no draw call and no file. A vanilla build (horizon-client, horizon-plain) is unchanged.
|
||||
- Visible text is a translation key the registrant ships in its lang files, never English in code. Values a
|
||||
player typed (a name, a path) are the exception.
|
||||
- Visible text is a translation key the registrant ships in its lang files ([Lang files](#lang-files-horizon_platformlang)),
|
||||
never English in code. Values a player typed (a name, a path) are the exception.
|
||||
- Splitscreen players are one process, one thread, each `Game` in turn (`game/split.rs`), so a crate's statics
|
||||
are shared by them. Every call (stores, pages, layers, hooks) happens on one player's turn, and
|
||||
`horizon_platform::hooks::seat()` (0 for P1) says whose: a crate keeps per-player state indexed by it.
|
||||
@@ -363,7 +363,7 @@ pub trait Store: Sync {
|
||||
A page lives in the options screen's shell, and its entry sits on one of the game's menus.
|
||||
|
||||
```rust
|
||||
pub enum Place { HorizonSettings, Pause, Extras, Title }
|
||||
pub enum Place { HorizonSettings, Title, Pause }
|
||||
pub trait Page: Sync {
|
||||
fn id(&self) -> &'static str; // stable, for horizon-ctl and the page stack
|
||||
fn title(&self) -> &'static str; // translation key
|
||||
@@ -371,7 +371,6 @@ pub trait Page: Sync {
|
||||
fn rows(&self, rows: &mut Vec<Row>); // asked when the page is drawn, not every frame elsewhere
|
||||
fn act(&self, row: usize, act: Act); // a click, a reverse click, a slider's value, a key
|
||||
fn tooltip(&self, row: usize) -> Option<Label> { None } // hover text, may follow the value
|
||||
fn back(&self) -> &'static str { "gui.done" } // the leave button of a page drawn as a screen
|
||||
}
|
||||
pub enum Row {
|
||||
Heading(&'static str), // translation key
|
||||
@@ -383,11 +382,9 @@ pub enum Row {
|
||||
Key { label: &'static str, key: String }, // a binding: `key.keyboard.z`
|
||||
Text { text: String, quiet: bool, groups: bool }, // Row::text(..): white or grey, wrapped
|
||||
Space(u8), // GUI pixels, on a page drawn as a screen
|
||||
Field { label: &'static str, text: String, max: usize }, // Row::field(..).max(n): an EditBox, on a page drawn as a screen
|
||||
}
|
||||
pub enum Label { Key(&'static str), Value(String) }
|
||||
pub enum Act { Press, PressBack, Slide(f32), Bind(String), Edit(String), Submit }
|
||||
pub fn connect(address: impl Into<String>); // from a Title page's act: join that server
|
||||
pub enum Act { Press, PressBack, Slide(f32), Bind(String) }
|
||||
```
|
||||
|
||||
- `HorizonSettings`: a row of the Horizon Settings page, after the engine's own pages. `Title` and `Pause`: a
|
||||
@@ -398,17 +395,6 @@ pub fn connect(address: impl Into<String>); // from a Title pa
|
||||
button, `.half()` puts two side by side, `.footer()` sets one beside Done on a page drawn as its own screen,
|
||||
`.quiet()` greys text, `.groups()` breaks it only between `-`-separated groups (an invite code). In the options
|
||||
shell, text is notes and space is nothing.
|
||||
- `Title` is the title screen's Friends icon slot (vanilla's `CommonButtons.friends`), not a new row: the icon
|
||||
keeps its place and look at every GUI scale, and opens the first page registered there as a form drawn as Join a
|
||||
Friend is (`multiplayer/page.rs`): the title, the rows from y 53 (a field's grey label, its 200-wide box 11
|
||||
below, 41 a field), the buttons in a column from a quarter of the height + 114, the back button last
|
||||
(`Page::back`, `gui.cancel` for a form). It shows heading, note, text, space, field and button rows. With no page
|
||||
there the icon opens the engine's Join a Friend while an invite-code resolver is registered, else it is
|
||||
inactive.
|
||||
- `Field` is vanilla's `EditBox` on a page drawn as its own screen: focus by click or Tab, typing, the caret keys,
|
||||
selection, copy and paste, at most `max` characters (32 by default). The engine keeps its text while the page is
|
||||
open and sends each change (`Act::Edit`, before any other act or redraw reaches the page) and Enter
|
||||
(`Act::Submit`). The options shell has no field.
|
||||
- Rows are rebuilt when the page is drawn or acted on, like the engine's own pages: no cost while no page is
|
||||
open.
|
||||
- The engine keeps native mods' pages (`Page::NativeMod`) as they are. Both kinds sit in one module that
|
||||
@@ -514,6 +500,30 @@ fn container_draw(&self, c: &dyn Container, canvas: &mut dyn hud::Canvas); //
|
||||
- `order` is the only text: Sorting ranks by it. The rest is ids and numbers, so the API names no engine type
|
||||
(`ItemStack` stays in the engine).
|
||||
|
||||
### Lang files (`horizon_platform::lang`)
|
||||
|
||||
A crate keeps the text for its keys in its own folder, `crates/<name>/assets/lang/<code>.json`, with the same
|
||||
shape as the engine's `assets/horizon/lang` files (flat `"key": "text"` lines, `en_us.json` the reference).
|
||||
It registers them as `(code, JSON)` pairs, usually `include_str!`s:
|
||||
|
||||
```rust
|
||||
static LANG: &horizon_platform::lang::Files = &[
|
||||
("en_us", include_str!("../assets/lang/en_us.json")),
|
||||
("fr_fr", include_str!("../assets/lang/fr_fr.json")),
|
||||
];
|
||||
horizon_platform::lang::register(LANG)?;
|
||||
```
|
||||
|
||||
- The game loads them into its `horizon` namespace right after its own bundled file for the same language, so a
|
||||
resource pack still overrides them, and a language a crate lacks falls back to its English.
|
||||
- A key belongs to one folder. On a key the engine's file also has, the engine's text wins (and the
|
||||
`lang.rs` test fails).
|
||||
- Regional variants read their language's file as the engine's do (`fr_ca` reads `fr_fr`, `de_ch` reads `de_de`
|
||||
without `ß`).
|
||||
- `tools/lang_coverage.py` reports each crate's folder beside the engine's. A person's review of a crate's entry
|
||||
is kept in `tools/lang_review/<name>/<code>.json` ([translations.md](translations.md)).
|
||||
- With nothing registered, a language loads exactly as before.
|
||||
|
||||
## Extras that may leave the game
|
||||
|
||||
Horizon's extras are Horizon-layer code. Each one is a candidate to become an optional native mod that players add
|
||||
|
||||
@@ -53,8 +53,7 @@ router that opened the port, or with a forwarded port, that's it: no server took
|
||||
(`horizon_hooks::tunnel`, `horizon_platform::hooks::tunnel` for the Horizon layer): the resolver a hook offers
|
||||
reads codes and opens the tunnel, which the join (`net.rs`) then reaches on a loopback port like any server.
|
||||
horizon-friends' `game` side offers it, and the official executable (`horizon-official`) registers it;
|
||||
horizon-client's own executable has none, so there a `friend:` address is refused and the title screen's
|
||||
Friends icon, which opens Join a Friend, is inactive (`net::joins_friends`). Hosting needs no hook: the
|
||||
horizon-client's own executable has none, so there a `friend:` address is refused. Hosting needs no hook: the
|
||||
host end connects to the world's LAN port over loopback, so tunnelled guests are LAN guests. It still lives in
|
||||
horizon-client (`game/friends.rs`, `game/friends/screen.rs`, the `friends` feature, which comes with
|
||||
`horizon-extras`) until the pause menu and `horizon-ctl` offer entries to crates above the engine.
|
||||
|
||||
@@ -11,8 +11,9 @@ no difference: the mark lives in a file the game and the launcher never read.
|
||||
| | Files | Keys | Read by |
|
||||
|---|---|---|---|
|
||||
| Game | `crates/horizon-client/assets/horizon/lang/<code>.json` (`fr_fr`, `de_de`...) | `horizon.options.foo` style; `en_us.json` is the reference | `crates/horizon-client-core/src/lang.rs` (`Text::tr`, `lang::get`); a missing key falls back to English |
|
||||
| A crate's own text | `crates/<name>/assets/lang/<code>.json`, the same shape | the crate's own keys, never one of the engine's | the same, once the crate registers them ([layers.md](layers.md#lang-files-horizon_platformlang)) |
|
||||
| Launcher | `launcher/lang/<code>.json` (`fr`, `de`, `pt-br`...) | the English sentence itself | `launcher/src/i18n.rs` (`tr("…")`, `trf("… {arg} …")`) |
|
||||
| Review record | `tools/lang_review/game/<code>.json`, `tools/lang_review/launcher/<code>.json` | the same keys, with the text a person checked | `tools/lang_coverage.py` only |
|
||||
| Review record | `tools/lang_review/game/<code>.json`, `tools/lang_review/<crate name>/<code>.json`, `tools/lang_review/launcher/<code>.json` | the same keys, with the text a person checked | `tools/lang_coverage.py` only |
|
||||
|
||||
An entry is **reviewed** when the review record holds its key with exactly its present text. Anything else is
|
||||
unreviewed: an entry the record does not hold, and one whose text changed since a person checked it (so a later
|
||||
|
||||
@@ -215,8 +215,49 @@ class ReviewTests(unittest.TestCase):
|
||||
self.assertEqual(code, 0)
|
||||
self.assertTrue((Path(tmp) / "review" / "launcher" / "xx.json").exists())
|
||||
|
||||
def test_a_crates_own_folder_is_reported_and_its_reviews_kept_apart(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
game, crates, review = Path(tmp) / "game", Path(tmp) / "crates", Path(tmp) / "review"
|
||||
friends = crates / "horizon-friends" / "assets" / "lang"
|
||||
game.mkdir()
|
||||
friends.mkdir(parents=True)
|
||||
(crates / "horizon-plain" / "src").mkdir(parents=True) # no lang folder: not reported
|
||||
write(game, "en_us.json", '{\n "a": "Apple"\n}\n')
|
||||
write(game, "xx_yy.json", '{\n "a": "Manzana"\n}\n')
|
||||
write(friends, "en_us.json", '{\n "f.one": "One",\n "f.two": "Two"\n}\n')
|
||||
write(friends, "xx_yy.json", '{\n "f.one": "Uno"\n}\n')
|
||||
common = ("--game-dir", str(game), "--crates-dir", str(crates), "--review-dir", str(review),
|
||||
"--game-only")
|
||||
code, out, _ = run(*common, "--summary")
|
||||
self.assertEqual(code, 0)
|
||||
self.assertIn("GAME xx_yy 1/1 keys, missing 0", out)
|
||||
self.assertIn("GAME horizon-friends xx_yy 1/2 keys, missing 1", out)
|
||||
self.assertNotIn("horizon-plain", out)
|
||||
code, _, err = run(*common, "--fail-under", "90")
|
||||
self.assertEqual(code, 1)
|
||||
self.assertIn("xx_yy: 50.0%", err)
|
||||
# One command marks the keys of both folders; each lands in its folder's record.
|
||||
code, out, _ = run(*common, "--lang", "xx_yy", "--mark-reviewed", "a", "f.one")
|
||||
self.assertEqual(code, 0, out)
|
||||
self.assertEqual(json.loads((review / "game" / "xx_yy.json").read_text(encoding="utf-8")),
|
||||
{"a": "Manzana"})
|
||||
self.assertEqual(json.loads((review / "horizon-friends" / "xx_yy.json").read_text(encoding="utf-8")),
|
||||
{"f.one": "Uno"})
|
||||
# Marking the game's folder again keeps the crate's record.
|
||||
code, _, _ = run(*common, "--lang", "xx_yy", "--mark-reviewed", "a")
|
||||
self.assertEqual(code, 0)
|
||||
self.assertTrue((review / "horizon-friends" / "xx_yy.json").exists())
|
||||
code, _, err = run(*common, "--lang", "xx_yy", "--mark-reviewed", "nosuch")
|
||||
self.assertEqual(code, 1)
|
||||
self.assertIn("no key 'nosuch'", err)
|
||||
# A scratch --game-dir alone leaves the repository's crates out.
|
||||
code, out, _ = run("--game-dir", str(game), "--review-dir", str(review), "--game-only", "--summary")
|
||||
self.assertNotIn("horizon-friends", out)
|
||||
|
||||
def test_every_repository_record_belongs_to_a_lang_file_and_reads(self):
|
||||
for kind, folder in (("game", lang_coverage.GAME), ("launcher", lang_coverage.LAUNCHER)):
|
||||
folders = [("game", lang_coverage.GAME), ("launcher", lang_coverage.LAUNCHER),
|
||||
*lang_coverage.crate_dirs(lang_coverage.CRATES)]
|
||||
for kind, folder in folders:
|
||||
for record in sorted((lang_coverage.REVIEW / kind).glob("*.json")):
|
||||
with self.subTest(record=record.name, kind=kind):
|
||||
self.assertTrue((folder / record.name).exists(), f"{record} names no lang file")
|
||||
|
||||
+75
-23
@@ -8,6 +8,10 @@ a gap is not an error — it is an English sentence on a translated screen. That
|
||||
tests catch stale keys, repeated keys, empty values and changed placeholders, but
|
||||
deliberately not gaps, because a language is allowed to lag. This tool reports them.
|
||||
|
||||
A crate that ships its own text keeps it in `crates/<name>/assets/lang/<code>.json`, the same
|
||||
shape with its own `en_us.json` reference (docs/layers.md, "Lang files"); each such folder is
|
||||
reported beside the game's, and its review record is `tools/lang_review/<name>/`.
|
||||
|
||||
`launcher/lang/<code>.json` is the launcher's: its keys are the English sentence itself,
|
||||
so a file is complete by construction (`launcher/src/i18n/tests.rs` checks every `tr("…")`
|
||||
the code asks for). What it can still hold is a value identical to its English key, and a
|
||||
@@ -43,6 +47,7 @@ from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
GAME = ROOT / "crates/horizon-client/assets/horizon/lang"
|
||||
CRATES = ROOT / "crates"
|
||||
LAUNCHER = ROOT / "launcher/lang"
|
||||
REVIEW = ROOT / "tools/lang_review"
|
||||
REFERENCE = "en_us.json"
|
||||
@@ -192,6 +197,12 @@ def game_rows(directory: Path, codes: set[str],
|
||||
return reference, rows
|
||||
|
||||
|
||||
def crate_dirs(crates: Path) -> list[tuple[str, Path]]:
|
||||
"""The crates that ship their own lang files: (crate name, its `assets/lang`), by name."""
|
||||
return sorted((path.parents[2].name, path.parent)
|
||||
for path in crates.glob("*/assets/lang/" + REFERENCE))
|
||||
|
||||
|
||||
def launcher_rows(directory: Path, codes: set[str], review: Path | None = None) -> list[dict]:
|
||||
"""Every launcher language: the values still identical to their English key, the
|
||||
`{holes}` a value does not match it on, and with `review` (a `lang_review/launcher` folder)
|
||||
@@ -228,8 +239,8 @@ def print_problems(rows: list[dict]) -> None:
|
||||
print(f" ! {row['code']}: {row['problem']}")
|
||||
|
||||
|
||||
def print_game(reference: LangFile, directory: Path, rows: list[dict]) -> None:
|
||||
print(f"GAME {shown(directory)} ({reference.code}: {len(reference.entries)} keys, the reference)")
|
||||
def print_game(reference: LangFile, directory: Path, rows: list[dict], kind: str = "GAME") -> None:
|
||||
print(f"{kind} {shown(directory)} ({reference.code}: {len(reference.entries)} keys, the reference)")
|
||||
print()
|
||||
print(f" {'code':<10}{'keys':>6}{'cover':>8}{'missing':>9}{'extra':>7}{'empty':>7}{'same':>6}"
|
||||
f"{'unreviewed':>12}")
|
||||
@@ -278,9 +289,9 @@ def print_launcher_keys(rows: list[dict]) -> None:
|
||||
print(f" {side}: {', '.join(bad[side])}")
|
||||
|
||||
|
||||
def print_game_summary(reference: LangFile, rows: list[dict]) -> None:
|
||||
def print_game_summary(reference: LangFile, rows: list[dict], kind: str = "GAME") -> None:
|
||||
for row in rows:
|
||||
line = (f"GAME {row['code']} {row['keys']}/{len(reference.entries)} keys, "
|
||||
line = (f"{kind} {row['code']} {row['keys']}/{len(reference.entries)} keys, "
|
||||
f"missing {len(row['missing'])}, extra {len(row['extra'])}, "
|
||||
f"placeholders {len(row['placeholders'])}, empty {len(row['empty'])}, "
|
||||
f"same {len(row['same'])}, unreviewed {len(row['unreviewed'])}")
|
||||
@@ -336,6 +347,9 @@ def main(argv: list[str] | None = None) -> int:
|
||||
which.add_argument("--launcher-only", action="store_true", help="only the launcher's lang")
|
||||
parser.add_argument("--game-dir", type=Path, default=GAME,
|
||||
help="the game's lang folder (default: %(default)s)")
|
||||
parser.add_argument("--crates-dir", type=Path,
|
||||
help="report every <dir>/<crate>/assets/lang too (default: the repository's crates, "
|
||||
"unless --game-dir names another folder)")
|
||||
parser.add_argument("--launcher-dir", type=Path, default=LAUNCHER,
|
||||
help="the launcher's lang folder (default: %(default)s)")
|
||||
parser.add_argument("--lang", nargs="+", metavar="CODE",
|
||||
@@ -359,6 +373,8 @@ def main(argv: list[str] | None = None) -> int:
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
codes = set(args.lang or ())
|
||||
if args.crates_dir is None and args.game_dir == GAME:
|
||||
args.crates_dir = CRATES
|
||||
if args.mark_reviewed is not None:
|
||||
return mark_command(args, codes)
|
||||
if args.fail_under is not None and args.launcher_only:
|
||||
@@ -377,6 +393,18 @@ def main(argv: list[str] | None = None) -> int:
|
||||
print(error, file=sys.stderr)
|
||||
return 2
|
||||
result["game"] = {"reference": reference.code, "keys": len(reference.entries), "files": game}
|
||||
# (crate name, its folder, its reference, its rows): the text crates ship.
|
||||
crates: list[tuple[str, Path, LangFile, list[dict]]] = []
|
||||
if not args.launcher_only and args.crates_dir is not None:
|
||||
for name, folder in crate_dirs(args.crates_dir):
|
||||
try:
|
||||
crates.append((name, folder, *game_rows(folder, codes, args.review_dir / name)))
|
||||
except ValueError as error:
|
||||
print(error, file=sys.stderr)
|
||||
return 2
|
||||
if crates:
|
||||
result["crates"] = {name: {"reference": ref.code, "keys": len(ref.entries), "files": rows}
|
||||
for name, _, ref, rows in crates}
|
||||
launcher: list[dict] = []
|
||||
if not args.game_only:
|
||||
if not args.launcher_dir.is_dir():
|
||||
@@ -388,7 +416,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
print(error, file=sys.stderr)
|
||||
return 2
|
||||
result["launcher"] = {"files": launcher}
|
||||
if codes and not game and not launcher:
|
||||
if codes and not game and not launcher and not any(rows for *_, rows in crates):
|
||||
print(f"no lang file is named {' or '.join(sorted(codes))}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
@@ -397,14 +425,21 @@ def main(argv: list[str] | None = None) -> int:
|
||||
elif args.unreviewed:
|
||||
if reference:
|
||||
print_unreviewed("GAME", game, reference.entries)
|
||||
for name, _, ref, rows in crates:
|
||||
print_unreviewed(f"GAME {name}", rows, ref.entries)
|
||||
print_unreviewed("LAUNCHER", launcher, None)
|
||||
elif args.summary:
|
||||
if reference:
|
||||
print_game_summary(reference, game)
|
||||
for name, _, ref, rows in crates:
|
||||
print_game_summary(ref, rows, f"GAME {name}")
|
||||
print_launcher_summary(launcher)
|
||||
else:
|
||||
if reference:
|
||||
print_game(reference, args.game_dir, game)
|
||||
for name, folder, ref, rows in crates:
|
||||
print()
|
||||
print_game(ref, folder, rows, f"GAME {name}")
|
||||
if not args.game_only:
|
||||
if reference:
|
||||
print()
|
||||
@@ -412,11 +447,14 @@ def main(argv: list[str] | None = None) -> int:
|
||||
if args.show_keys:
|
||||
if reference:
|
||||
print_game_keys(game)
|
||||
for *_, rows in crates:
|
||||
print_game_keys(rows)
|
||||
if launcher:
|
||||
print_launcher_keys(launcher)
|
||||
|
||||
if args.fail_under is not None:
|
||||
under = [row for row in game if 100 * row["coverage"] + 1e-9 < args.fail_under]
|
||||
every = game + [row for *_, rows in crates for row in rows]
|
||||
under = [row for row in every if 100 * row["coverage"] + 1e-9 < args.fail_under]
|
||||
for row in under:
|
||||
print(f"{row['code']}: {100 * row['coverage']:.1f}% < {args.fail_under:g}%", file=sys.stderr)
|
||||
if under:
|
||||
@@ -425,8 +463,9 @@ def main(argv: list[str] | None = None) -> int:
|
||||
|
||||
|
||||
def mark_command(args, codes: set[str]) -> int:
|
||||
"""`--mark-reviewed`: one file, named by `--lang`; --game-only or --launcher-only picks the
|
||||
folder when the code is in both."""
|
||||
"""`--mark-reviewed`: one language, named by `--lang`; --game-only or --launcher-only picks the
|
||||
folder when the code is in both. The game's text may be split between its own folder and the
|
||||
crates' (`--crates-dir`): each key is recorded for the folder that holds it."""
|
||||
if len(codes) != 1:
|
||||
print("--mark-reviewed needs exactly one --lang code", file=sys.stderr)
|
||||
return 2
|
||||
@@ -434,31 +473,44 @@ def mark_command(args, codes: set[str]) -> int:
|
||||
if code == "en_us":
|
||||
print("en_us is the English reference, not a translation", file=sys.stderr)
|
||||
return 2
|
||||
found = [(kind, folder / f"{code}.json") for kind, folder, skip in (
|
||||
("game", args.game_dir, args.launcher_only), ("launcher", args.launcher_dir, args.game_only))
|
||||
if not skip and (folder / f"{code}.json").exists()]
|
||||
if not found:
|
||||
game = [("game", args.game_dir)]
|
||||
if args.crates_dir is not None:
|
||||
game += crate_dirs(args.crates_dir)
|
||||
sides = [[(kind, folder / f"{code}.json") for kind, folder in folders
|
||||
if (folder / f"{code}.json").exists()]
|
||||
for folders, skip in ((game, args.launcher_only), ([("launcher", args.launcher_dir)], args.game_only))
|
||||
if not skip]
|
||||
sides = [side for side in sides if side]
|
||||
if not sides:
|
||||
print(f"no lang file is named {code}", file=sys.stderr)
|
||||
return 2
|
||||
if len(found) > 1:
|
||||
if len(sides) > 1:
|
||||
print(f"{code} is a game and a launcher file: add --game-only or --launcher-only", file=sys.stderr)
|
||||
return 2
|
||||
kind, lang = found[0]
|
||||
found = sides[0]
|
||||
keys = list(args.mark_reviewed)
|
||||
if args.keys_file:
|
||||
keys += [line for line in args.keys_file.read_text(encoding="utf-8").splitlines() if line.strip()]
|
||||
if not keys:
|
||||
print("--mark-reviewed needs keys, on the command line or in --keys-file", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
unknown = mark_reviewed(lang, args.review_dir / kind / lang.name, keys)
|
||||
except ValueError as error:
|
||||
print(error, file=sys.stderr)
|
||||
return 2
|
||||
for key in unknown:
|
||||
print(f"{shown(lang)} has no key {key!r}", file=sys.stderr)
|
||||
print(f"{len(keys) - len(unknown)} entries of {shown(lang)} recorded as reviewed")
|
||||
return 1 if unknown else 0
|
||||
# Each key goes to the first folder that has it; a key none has is reported against them all.
|
||||
left = list(keys)
|
||||
for kind, lang in found:
|
||||
entries = LangFile(lang).entries
|
||||
mine = [key for key in left if key in entries]
|
||||
left = [key for key in left if key not in entries]
|
||||
if not mine and len(found) > 1:
|
||||
continue
|
||||
try:
|
||||
mark_reviewed(lang, args.review_dir / kind / lang.name, mine)
|
||||
except ValueError as error:
|
||||
print(error, file=sys.stderr)
|
||||
return 2
|
||||
print(f"{len(mine)} entries of {shown(lang)} recorded as reviewed")
|
||||
for key in left:
|
||||
print(f"{' or '.join(shown(lang) for _, lang in found)} has no key {key!r}", file=sys.stderr)
|
||||
return 1 if left else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in new issue
Block a user