mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-03-26 05:48:46 +08:00
Compare commits
No commits in common. "51492b1dcd73c0ff65676f0aaca25f7f7890687f" and "dfae4ba2072886b148b0501c4a9aa046f002e993" have entirely different histories.
51492b1dcd
...
dfae4ba207
@ -1,62 +0,0 @@
|
|||||||
schema_version: 1
|
|
||||||
generated_at_utc: "2026-03-21T04:47:58Z"
|
|
||||||
generated_from: ".ai/environment/tools.raw.yaml"
|
|
||||||
generator: "scripts/generate-ai-environment.py"
|
|
||||||
platform:
|
|
||||||
family: "wsl-linux"
|
|
||||||
os: "Linux"
|
|
||||||
distro: "Ubuntu 24.04.4 LTS"
|
|
||||||
shell: "bash"
|
|
||||||
capabilities:
|
|
||||||
dotnet: true
|
|
||||||
python: true
|
|
||||||
node: true
|
|
||||||
bun: true
|
|
||||||
docker: true
|
|
||||||
fast_search: true
|
|
||||||
json_cli: true
|
|
||||||
tool_selection:
|
|
||||||
search:
|
|
||||||
preferred: "rg"
|
|
||||||
fallback: "grep"
|
|
||||||
use_for: "Repository text search."
|
|
||||||
json:
|
|
||||||
preferred: "jq"
|
|
||||||
fallback: "python3"
|
|
||||||
use_for: "Inspecting or transforming JSON command output."
|
|
||||||
shell:
|
|
||||||
preferred: "bash"
|
|
||||||
fallback: "sh"
|
|
||||||
use_for: "Repository shell scripts and command execution."
|
|
||||||
scripting:
|
|
||||||
preferred: "python3"
|
|
||||||
fallback: "bash"
|
|
||||||
use_for: "Non-trivial local automation and helper scripts."
|
|
||||||
docs_package_manager:
|
|
||||||
preferred: "bun"
|
|
||||||
fallback: "npm"
|
|
||||||
use_for: "Installing and previewing the docs site."
|
|
||||||
build_and_test:
|
|
||||||
preferred: "dotnet"
|
|
||||||
fallback: "unavailable"
|
|
||||||
use_for: "Build, test, restore, and solution validation."
|
|
||||||
python:
|
|
||||||
available: true
|
|
||||||
helper_packages:
|
|
||||||
requests: true
|
|
||||||
rich: true
|
|
||||||
openai: false
|
|
||||||
tiktoken: false
|
|
||||||
pydantic: false
|
|
||||||
pytest: false
|
|
||||||
preferences:
|
|
||||||
prefer_project_listed_tools: true
|
|
||||||
prefer_python_for_non_trivial_automation: true
|
|
||||||
avoid_unlisted_system_tools: true
|
|
||||||
rules:
|
|
||||||
- "Use rg instead of grep for repository search when rg is available."
|
|
||||||
- "Use jq for JSON inspection; fall back to python3 if jq is unavailable."
|
|
||||||
- "Prefer python3 over complex bash for non-trivial scripting when python3 is available."
|
|
||||||
- "Use bun for docs preview workflows when bun is available; otherwise fall back to npm."
|
|
||||||
- "Use dotnet for repository build and test workflows."
|
|
||||||
- "Do not assume unrelated system tools are part of the supported project environment."
|
|
||||||
@ -1,89 +0,0 @@
|
|||||||
schema_version: 1
|
|
||||||
generated_at_utc: "2026-03-21T04:47:28Z"
|
|
||||||
generator: "scripts/collect-dev-environment.sh"
|
|
||||||
|
|
||||||
platform:
|
|
||||||
os: "Linux"
|
|
||||||
distro: "Ubuntu 24.04.4 LTS"
|
|
||||||
version: "24.04"
|
|
||||||
kernel: "5.15.167.4-microsoft-standard-WSL2"
|
|
||||||
wsl: true
|
|
||||||
wsl_version: "2.4.13"
|
|
||||||
shell: "bash"
|
|
||||||
|
|
||||||
required_runtimes:
|
|
||||||
dotnet:
|
|
||||||
installed: true
|
|
||||||
version: "10.0.104"
|
|
||||||
path: "/usr/bin/dotnet"
|
|
||||||
purpose: "Builds and tests the GFramework solution."
|
|
||||||
python3:
|
|
||||||
installed: true
|
|
||||||
version: "Python 3.12.3"
|
|
||||||
path: "/usr/bin/python3"
|
|
||||||
purpose: "Runs local automation and environment collection scripts."
|
|
||||||
node:
|
|
||||||
installed: true
|
|
||||||
version: "v20.20.1"
|
|
||||||
path: "/usr/bin/node"
|
|
||||||
purpose: "Provides the JavaScript runtime used by docs tooling."
|
|
||||||
bun:
|
|
||||||
installed: true
|
|
||||||
version: "1.3.10"
|
|
||||||
path: "/root/.bun/bin/bun"
|
|
||||||
purpose: "Installs and previews the VitePress documentation site."
|
|
||||||
|
|
||||||
required_tools:
|
|
||||||
git:
|
|
||||||
installed: true
|
|
||||||
version: "git version 2.43.0"
|
|
||||||
path: "/usr/bin/git"
|
|
||||||
purpose: "Source control and patch review."
|
|
||||||
bash:
|
|
||||||
installed: true
|
|
||||||
version: "GNU bash, version 5.2.21(1)-release (x86_64-pc-linux-gnu)"
|
|
||||||
path: "/usr/bin/bash"
|
|
||||||
purpose: "Executes repository scripts and shell automation."
|
|
||||||
rg:
|
|
||||||
installed: true
|
|
||||||
version: "ripgrep 15.1.0 (rev af60c2de9d)"
|
|
||||||
path: "/root/.bun/install/global/node_modules/@openai/codex-linux-x64/vendor/x86_64-unknown-linux-musl/path/rg"
|
|
||||||
purpose: "Fast text search across the repository."
|
|
||||||
jq:
|
|
||||||
installed: true
|
|
||||||
version: "jq-1.7"
|
|
||||||
path: "/usr/bin/jq"
|
|
||||||
purpose: "Inspecting and transforming JSON outputs."
|
|
||||||
|
|
||||||
project_tools:
|
|
||||||
docker:
|
|
||||||
installed: true
|
|
||||||
version: "Docker version 29.2.1, build a5c7197"
|
|
||||||
path: "/usr/bin/docker"
|
|
||||||
purpose: "Runs MegaLinter and other containerized validation tools."
|
|
||||||
|
|
||||||
python_packages:
|
|
||||||
requests:
|
|
||||||
installed: true
|
|
||||||
version: "2.31.0"
|
|
||||||
purpose: "Simple HTTP calls in local helper scripts."
|
|
||||||
rich:
|
|
||||||
installed: true
|
|
||||||
version: "13.7.1"
|
|
||||||
purpose: "Readable CLI output for local Python helpers."
|
|
||||||
openai:
|
|
||||||
installed: false
|
|
||||||
version: "not-installed"
|
|
||||||
purpose: "Optional scripted access to OpenAI APIs."
|
|
||||||
tiktoken:
|
|
||||||
installed: false
|
|
||||||
version: "not-installed"
|
|
||||||
purpose: "Optional token counting for prompt and context inspection."
|
|
||||||
pydantic:
|
|
||||||
installed: false
|
|
||||||
version: "not-installed"
|
|
||||||
purpose: "Optional typed config and schema validation for helper scripts."
|
|
||||||
pytest:
|
|
||||||
installed: false
|
|
||||||
version: "not-installed"
|
|
||||||
purpose: "Optional lightweight testing for Python helper scripts."
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -13,5 +13,3 @@ opencode.json
|
|||||||
docs/.omc/
|
docs/.omc/
|
||||||
docs/.vitepress/cache/
|
docs/.vitepress/cache/
|
||||||
local-plan/
|
local-plan/
|
||||||
# tool
|
|
||||||
.venv/
|
|
||||||
242
AGENTS.md
242
AGENTS.md
@ -1,220 +1,42 @@
|
|||||||
# AGENTS.md
|
# Repository Guidelines
|
||||||
|
|
||||||
This document is the single source of truth for coding behavior in this repository.
|
## Project Structure & Module Organization
|
||||||
|
|
||||||
All AI agents and contributors must follow these rules when writing, reviewing, or modifying code in `GFramework`.
|
`GFramework.sln` is the entry point for the full .NET solution. Runtime code lives in `GFramework.Core/`,
|
||||||
|
`GFramework.Game/`, `GFramework.Godot/`, and `GFramework.Ecs.Arch/`. Interface-only contracts stay in the paired
|
||||||
|
`*.Abstractions/` projects. Roslyn generators are split across `GFramework.SourceGenerators/`,
|
||||||
|
`GFramework.Godot.SourceGenerators/`, and `GFramework.SourceGenerators.Common/`. Tests mirror the runtime modules in
|
||||||
|
`GFramework.Core.Tests/`, `GFramework.Game.Tests/`, `GFramework.Ecs.Arch.Tests/`, and
|
||||||
|
`GFramework.SourceGenerators.Tests/`. Documentation is under `docs/`, Godot templates under `Godot/script_templates/`,
|
||||||
|
and repository utilities under `scripts/` and `refactor-scripts/`.
|
||||||
|
|
||||||
## Environment Capability Inventory
|
## Build, Test, and Development Commands
|
||||||
|
|
||||||
- Before choosing runtimes or CLI tools, read `@.ai/environment/tools.ai.yaml`.
|
- `dotnet build GFramework.sln` builds the full solution from the repo root.
|
||||||
- Use `@.ai/environment/tools.raw.yaml` only when you need the full collected facts behind the AI-facing hints.
|
- `dotnet test GFramework.sln --no-build` runs all NUnit test projects after a build.
|
||||||
- Prefer the project-relevant tools listed there instead of assuming every installed system tool is fair game.
|
- `dotnet test GFramework.Core.Tests --filter "FullyQualifiedName~CommandExecutorTests.Execute"` runs a focused NUnit
|
||||||
- If the real environment differs from the inventory, use the project-relevant installed tool and report the mismatch.
|
test.
|
||||||
|
- `bash scripts/validate-csharp-naming.sh` checks PascalCase namespace and directory rules used by CI.
|
||||||
|
- `cd docs && bun install && bun run dev` starts the VitePress docs site locally.
|
||||||
|
|
||||||
## Commenting Rules (MUST)
|
## Coding Style & Naming Conventions
|
||||||
|
|
||||||
All generated or modified code MUST include clear and meaningful comments where required by the rules below.
|
Use standard C# formatting with 4-space indentation and one public type per file. The repository keeps `ImplicitUsings`
|
||||||
|
disabled and `Nullable` enabled, so write explicit `using` directives and annotate nullability carefully. Follow
|
||||||
|
`PascalCase` for types, methods, namespaces, directories, and constants; use `_camelCase` for private fields and
|
||||||
|
`camelCase` for locals and parameters. Keep namespaces aligned with folders, for example
|
||||||
|
`GFramework.Core.Architectures`.
|
||||||
|
|
||||||
### XML Documentation (Required)
|
## Testing Guidelines
|
||||||
|
|
||||||
- All public, protected, and internal types and members MUST include XML documentation comments (`///`).
|
Tests use NUnit 4 with `Microsoft.NET.Test.Sdk`; some suites also use Moq. Place tests in the matching module test
|
||||||
- Use `<summary>`, `<param>`, `<returns>`, `<exception>`, and `<remarks>` where applicable.
|
project and name files `*Tests.cs`. Prefer directory parity with production code, for example `GFramework.Core/Logging/`
|
||||||
- Comments must explain intent, contract, and usage constraints instead of restating syntax.
|
and `GFramework.Core.Tests/Logging/`. Add or update tests for every behavior change, especially public APIs, source
|
||||||
- If a member participates in lifecycle, threading, registration, or disposal behavior, document that behavior
|
generators, and integration paths.
|
||||||
explicitly.
|
|
||||||
|
|
||||||
### Inline Comments
|
## Commit & Pull Request Guidelines
|
||||||
|
|
||||||
- Add inline comments for:
|
Recent history follows Conventional Commits style such as `feat(events): ...`, `refactor(localization): ...`,
|
||||||
- Non-trivial logic
|
`docs(guide): ...`, and `test(localization): ...`. Keep commits scoped and imperative. PRs should explain the
|
||||||
- Concurrency or threading behavior
|
motivation, implementation, and validation commands run; link related issues; and include screenshots when docs, UI, or
|
||||||
- Performance-sensitive paths
|
Godot-facing behavior changes.
|
||||||
- Workarounds, compatibility constraints, or edge cases
|
|
||||||
- Registration order, lifecycle sequencing, or generated code assumptions
|
|
||||||
- Avoid obvious comments such as `// increment i`.
|
|
||||||
|
|
||||||
### Architecture-Level Comments
|
|
||||||
|
|
||||||
- Core framework components such as Architecture, Module, System, Context, Registry, Service Module, and Lifecycle types
|
|
||||||
MUST include high-level explanations of:
|
|
||||||
- Responsibilities
|
|
||||||
- Lifecycle
|
|
||||||
- Interaction with other components
|
|
||||||
- Why the abstraction exists
|
|
||||||
- When to use it instead of alternatives
|
|
||||||
|
|
||||||
### Source Generator Comments
|
|
||||||
|
|
||||||
- Generated logic and generator pipelines MUST explain:
|
|
||||||
- What is generated
|
|
||||||
- Why it is generated
|
|
||||||
- The semantic assumptions the generator relies on
|
|
||||||
- Any diagnostics or fallback behavior
|
|
||||||
|
|
||||||
### Complex Logic Requirement
|
|
||||||
|
|
||||||
- Methods with non-trivial logic MUST document:
|
|
||||||
- The core idea
|
|
||||||
- Key decisions
|
|
||||||
- Edge case handling, if any
|
|
||||||
|
|
||||||
### Quality Rules
|
|
||||||
|
|
||||||
- Comments MUST NOT be trivial, redundant, or misleading.
|
|
||||||
- Prefer explaining `why` and `when`, not just `what`.
|
|
||||||
- Code should remain understandable without requiring external context.
|
|
||||||
- Prefer slightly more explanation over too little for framework code.
|
|
||||||
|
|
||||||
### Enforcement
|
|
||||||
|
|
||||||
- Missing required documentation is a coding standards violation.
|
|
||||||
- Code that does not meet the documentation rules is considered incomplete.
|
|
||||||
|
|
||||||
## Code Style
|
|
||||||
|
|
||||||
### Language and Project Settings
|
|
||||||
|
|
||||||
- Follow the repository defaults:
|
|
||||||
- `ImplicitUsings` disabled
|
|
||||||
- `Nullable` enabled
|
|
||||||
- `GenerateDocumentationFile` enabled for shipped libraries
|
|
||||||
- `LangVersion` is generally `preview` in the main libraries and abstractions
|
|
||||||
- Do not rely on implicit imports. Declare every required `using` explicitly.
|
|
||||||
- Write null-safe code that respects nullable annotations instead of suppressing warnings by default.
|
|
||||||
|
|
||||||
### Naming and Structure
|
|
||||||
|
|
||||||
- Use the namespace pattern `GFramework.{Module}.{Feature}` with PascalCase segments.
|
|
||||||
- Follow standard C# naming:
|
|
||||||
- Types, methods, properties, events, and constants: PascalCase
|
|
||||||
- Interfaces: `I` prefix
|
|
||||||
- Parameters and locals: camelCase
|
|
||||||
- Private fields: `_camelCase`
|
|
||||||
- Keep abstractions projects free of implementation details and engine-specific dependencies.
|
|
||||||
- Preserve existing module boundaries. Do not introduce new cross-module dependencies without clear architectural need.
|
|
||||||
|
|
||||||
### Formatting
|
|
||||||
|
|
||||||
- Use 4 spaces for indentation. Do not use tabs.
|
|
||||||
- Use Allman braces.
|
|
||||||
- Keep `using` directives at the top of the file and sort them consistently.
|
|
||||||
- Separate logical blocks with blank lines when it improves readability.
|
|
||||||
- Prefer one primary type per file unless the surrounding project already uses a different local pattern.
|
|
||||||
- Keep line length readable. Around 120 characters is the preferred upper bound.
|
|
||||||
|
|
||||||
### C# Conventions
|
|
||||||
|
|
||||||
- Prefer explicit, readable code over clever shorthand in framework internals.
|
|
||||||
- Match existing async patterns and naming conventions (`Async` suffix for asynchronous methods).
|
|
||||||
- Avoid hidden side effects in property getters, constructors, and registration helpers.
|
|
||||||
- Preserve deterministic behavior in registries, lifecycle orchestration, and generated outputs.
|
|
||||||
- When adding analyzers or suppressions, keep them minimal and justify them in code comments if the reason is not
|
|
||||||
obvious.
|
|
||||||
|
|
||||||
### Analyzer and Validation Expectations
|
|
||||||
|
|
||||||
- The repository uses `Meziantou.Analyzer`; treat analyzer feedback as part of the coding standard.
|
|
||||||
- Naming must remain compatible with `scripts/validate-csharp-naming.sh`.
|
|
||||||
|
|
||||||
## Testing Requirements
|
|
||||||
|
|
||||||
### Required Coverage
|
|
||||||
|
|
||||||
- Every non-trivial feature, bug fix, or behavior change MUST include tests or an explicit justification for why a test
|
|
||||||
is not practical.
|
|
||||||
- Public API changes must be covered by unit or integration tests.
|
|
||||||
- Regression fixes should include a test that fails before the fix and passes after it.
|
|
||||||
|
|
||||||
### Test Organization
|
|
||||||
|
|
||||||
- Mirror the source structure in test projects whenever practical.
|
|
||||||
- Reuse existing architecture test infrastructure when relevant:
|
|
||||||
- `ArchitectureTestsBase<T>`
|
|
||||||
- `SyncTestArchitecture`
|
|
||||||
- `AsyncTestArchitecture`
|
|
||||||
- Keep tests focused on observable behavior, not implementation trivia.
|
|
||||||
|
|
||||||
### Source Generator Tests
|
|
||||||
|
|
||||||
- Source generator changes MUST be covered by generator tests.
|
|
||||||
- Preserve snapshot-based verification patterns already used in the repository.
|
|
||||||
- When generator behavior changes intentionally, update snapshots together with the implementation.
|
|
||||||
|
|
||||||
### Validation Commands
|
|
||||||
|
|
||||||
Use the smallest command set that proves the change, then expand if the change is cross-cutting.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build the full solution
|
|
||||||
dotnet build GFramework.sln -c Release
|
|
||||||
|
|
||||||
# Run all tests
|
|
||||||
dotnet test GFramework.sln -c Release
|
|
||||||
|
|
||||||
# Run a single test project
|
|
||||||
dotnet test GFramework.Core.Tests -c Release
|
|
||||||
dotnet test GFramework.Game.Tests -c Release
|
|
||||||
dotnet test GFramework.SourceGenerators.Tests -c Release
|
|
||||||
dotnet test GFramework.Ecs.Arch.Tests -c Release
|
|
||||||
|
|
||||||
# Run a single NUnit test or test group
|
|
||||||
dotnet test GFramework.Core.Tests -c Release --filter "FullyQualifiedName~CommandExecutorTests.Execute"
|
|
||||||
|
|
||||||
# Validate naming rules used by CI
|
|
||||||
bash scripts/validate-csharp-naming.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test Execution Expectations
|
|
||||||
|
|
||||||
- Run targeted tests for the code you changed whenever possible.
|
|
||||||
- Run broader solution-level validation for changes that touch shared abstractions, lifecycle behavior, source
|
|
||||||
generators, or dependency wiring.
|
|
||||||
- Do not claim completion if required tests were skipped; state what was not run and why.
|
|
||||||
|
|
||||||
## Security Rules
|
|
||||||
|
|
||||||
- Validate external or user-controlled input before it reaches file system, serialization, reflection, code generation,
|
|
||||||
or process boundaries.
|
|
||||||
- Do not build command strings, file paths, type names, or generated code from untrusted input without strict validation
|
|
||||||
or allow-listing.
|
|
||||||
- Avoid logging secrets, tokens, credentials, or machine-specific sensitive data.
|
|
||||||
- Keep source generators deterministic and free of hidden environment or network dependencies.
|
|
||||||
- Prefer least-privilege behavior for file, process, and environment access.
|
|
||||||
- Do not introduce unsafe deserialization, broad reflection-based activation, or dynamic code execution unless it is
|
|
||||||
explicitly required and tightly constrained.
|
|
||||||
- When adding caching, pooling, or shared mutable state, document thread-safety assumptions and failure modes.
|
|
||||||
- Minimize new package dependencies. Add them only when necessary and keep scope narrow.
|
|
||||||
|
|
||||||
## Documentation Rules
|
|
||||||
|
|
||||||
### Code Documentation
|
|
||||||
|
|
||||||
- Any change to public API, lifecycle semantics, module behavior, or extension points MUST update the related XML docs.
|
|
||||||
- If a framework abstraction changes meaning or intended usage, update the explanatory comments in code as part of the
|
|
||||||
same change.
|
|
||||||
|
|
||||||
### Repository Documentation
|
|
||||||
|
|
||||||
- Update the relevant `README.md` or `docs/` page when behavior, setup steps, architecture guidance, or user-facing
|
|
||||||
examples change.
|
|
||||||
- The main documentation site lives under `docs/`, with Chinese content under `docs/zh-CN/`.
|
|
||||||
- Keep code samples, package names, and command examples aligned with the current repository state.
|
|
||||||
- Prefer documenting behavior and design intent, not only API surface.
|
|
||||||
|
|
||||||
### Documentation Preview
|
|
||||||
|
|
||||||
When documentation changes need local preview, use:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd docs && bun install && bun run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
## Review Standard
|
|
||||||
|
|
||||||
Before considering work complete, confirm:
|
|
||||||
|
|
||||||
- Required comments and XML docs are present
|
|
||||||
- Code follows repository style and naming rules
|
|
||||||
- Relevant tests were added or updated
|
|
||||||
- Sensitive or unsafe behavior was not introduced
|
|
||||||
- User-facing documentation is updated when needed
|
|
||||||
|
|||||||
153
CLAUDE.md
153
CLAUDE.md
@ -1,22 +1,36 @@
|
|||||||
# CLAUDE.md
|
# CLAUDE.md
|
||||||
|
|
||||||
This file provides project understanding for AI agents working in this repository.
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
GFramework 是面向游戏开发的模块化 C# 框架,核心能力与引擎解耦。项目灵感参考 QFramework,并在模块边界、工程组织和可扩展性方面持续重构。
|
GFramework 是面向游戏开发的模块化 C# 框架,核心能力与引擎解耦。灵感参考 QFramework,在模块边界和可扩展性方面持续重构。
|
||||||
|
|
||||||
## AI Agent Instructions
|
## Build & Test Commands
|
||||||
|
|
||||||
All coding rules are defined in:
|
```bash
|
||||||
|
# 构建整个解决方案
|
||||||
|
dotnet build GFramework.sln -c Release
|
||||||
|
|
||||||
@AGENTS.md
|
# 运行全部测试
|
||||||
|
dotnet test GFramework.sln -c Release
|
||||||
|
|
||||||
Follow them strictly.
|
# 运行单个测试项目
|
||||||
|
dotnet test GFramework.Core.Tests -c Release
|
||||||
|
dotnet test GFramework.Game.Tests -c Release
|
||||||
|
dotnet test GFramework.SourceGenerators.Tests -c Release
|
||||||
|
dotnet test GFramework.Ecs.Arch.Tests -c Release
|
||||||
|
|
||||||
|
# 运行单个测试方法(NUnit filter)
|
||||||
|
dotnet test GFramework.Core.Tests -c Release --filter "FullyQualifiedName~CommandExecutorTests.Execute"
|
||||||
|
|
||||||
|
# 命名规范验证(CI 中使用)
|
||||||
|
bash scripts/validate-csharp-naming.sh
|
||||||
|
```
|
||||||
|
|
||||||
## Module Dependency Graph
|
## Module Dependency Graph
|
||||||
|
|
||||||
```text
|
```
|
||||||
GFramework (meta package) ─→ Core + Game
|
GFramework (meta package) ─→ Core + Game
|
||||||
GFramework.Core ─→ Core.Abstractions
|
GFramework.Core ─→ Core.Abstractions
|
||||||
GFramework.Game ─→ Game.Abstractions, Core, Core.Abstractions
|
GFramework.Game ─→ Game.Abstractions, Core, Core.Abstractions
|
||||||
@ -25,110 +39,71 @@ GFramework.Ecs.Arch ─→ Ecs.Arch.Abstractions, Core, Core.Abstractions
|
|||||||
GFramework.SourceGenerators ─→ SourceGenerators.Common, SourceGenerators.Abstractions
|
GFramework.SourceGenerators ─→ SourceGenerators.Common, SourceGenerators.Abstractions
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Abstractions projects** (`netstandard2.1`): 只包含接口和契约定义,不承载运行时实现逻辑。
|
- **Abstractions projects** (netstandard2.1): 只含接口定义,零实现依赖
|
||||||
- **Core / Game / Ecs.Arch** (`net8.0;net9.0;net10.0`): 平台无关的核心实现层。
|
- **Core/Game** (net8.0;net9.0;net10.0): 平台无关实现
|
||||||
- **Godot**: Godot 引擎集成层,负责与节点、场景和引擎生命周期对接。
|
- **Godot**: Godot 引擎集成层
|
||||||
- **SourceGenerators** (`netstandard2.1`): Roslyn 增量源码生成器及其公共基础设施。
|
- **SourceGenerators** (netstandard2.1): Roslyn 增量生成器
|
||||||
|
|
||||||
## Architecture Pattern
|
## Architecture Pattern
|
||||||
|
|
||||||
框架核心采用 `Architecture / Model / System / Utility` 四层结构:
|
框架核心采用 Architecture / Model / System / Utility 四层结构:
|
||||||
|
|
||||||
- **IArchitecture**: 顶层容器,负责生命周期管理、组件注册、模块安装和统一服务访问。
|
- **IArchitecture**: 顶层容器,管理生命周期(Init → Ready → Destroy)、注册 Model/System/Utility
|
||||||
- **IContextAware**: 统一上下文访问接口,组件通过 `SetContext(IArchitectureContext)` 获取架构上下文。
|
- **IContextAware**: 统一上下文访问接口,所有组件通过 `SetContext(IArchitectureContext)` 获得对 Architecture 服务的引用
|
||||||
- **IModel**: 数据与状态层,负责长期状态和业务数据建模。
|
- **IModel**: 数据层(状态管理),继承 IContextAware
|
||||||
- **ISystem**: 业务逻辑层,负责命令执行、流程编排和规则落地。
|
- **ISystem**: 业务逻辑层,继承 IContextAware
|
||||||
- **IUtility**: 通用无状态工具层,供其他层复用。
|
- **IUtility**: 无状态工具层
|
||||||
|
|
||||||
关键实现位于 `GFramework.Core/Architectures/Architecture.cs`,其职责是作为总协调器串联生命周期、组件注册和模块系统。
|
关键实现类:`GFramework.Core/Architectures/Architecture.cs`(主流程编排)
|
||||||
|
|
||||||
## Architecture Details
|
|
||||||
|
|
||||||
### Lifecycle
|
|
||||||
|
|
||||||
Architecture 负责统一生命周期编排,核心阶段包括:
|
|
||||||
|
|
||||||
- `Init`
|
|
||||||
- `Ready`
|
|
||||||
- `Destroy`
|
|
||||||
|
|
||||||
在实现层中,生命周期被拆分为更细粒度的初始化与销毁阶段,用于保证 Utility、Model、System、服务模块和钩子的顺序一致性。
|
|
||||||
|
|
||||||
### Component Coordination
|
|
||||||
|
|
||||||
框架通过独立组件协作完成架构编排:
|
|
||||||
|
|
||||||
- `ArchitectureLifecycle`: 管理生命周期阶段、阶段转换和生命周期钩子。
|
|
||||||
- `ArchitectureComponentRegistry`: 管理 Model、System、Utility 的注册与解析。
|
|
||||||
- `ArchitectureModules`: 管理模块安装、服务模块接入和扩展点注册。
|
|
||||||
|
|
||||||
这组拆分的目标是降低单个核心类的职责密度,同时保持对外 API 稳定。
|
|
||||||
|
|
||||||
### Context Propagation
|
|
||||||
|
|
||||||
`IArchitectureContext` 和相关 Provider 类型负责在组件之间传播上下文能力,使 Model、System
|
|
||||||
和外部扩展都能通过统一入口访问架构服务,而不直接耦合具体实现细节。
|
|
||||||
|
|
||||||
## Key Patterns
|
## Key Patterns
|
||||||
|
|
||||||
### CQRS
|
**CQRS**: Command/Query 分离,支持同步与异步。Mediator 模式通过 `Mediator.SourceGenerator` 实现。
|
||||||
|
|
||||||
命令与查询分离,支持同步与异步执行。Mediator 模式通过源码生成器集成,以减少模板代码并保持调用路径清晰。
|
**EventBus**: 类型安全事件总线,支持优先级、过滤器、弱引用订阅。`IEventBus.Send<T>()` / `Register<T>(handler)` →
|
||||||
|
`IUnRegister`。
|
||||||
|
|
||||||
### EventBus
|
**BindableProperty**: 响应式属性绑定,`IBindableProperty<T>.Value` 变更自动触发 `OnValueChanged`。
|
||||||
|
|
||||||
类型安全事件总线支持事件发布、订阅、优先级、过滤器和弱引用订阅。它是模块之间松耦合通信的核心基础设施之一。
|
**Coroutine**: 帧驱动协程系统,`IYieldInstruction` + `CoroutineScheduler`,提供 WaitForSeconds/WaitForEvent/WaitForTask
|
||||||
|
等指令。
|
||||||
|
|
||||||
### BindableProperty
|
**IoC**: 通过 `MicrosoftDiContainer` 封装 `Microsoft.Extensions.DependencyInjection`。
|
||||||
|
|
||||||
响应式属性模型通过值变化通知驱动界面或业务层更新,适合表达轻量级状态同步。
|
**Service Modules**: `IServiceModule` 模式用于向 Architecture 注册内置服务(EventBus、CommandExecutor、QueryExecutor 等)。
|
||||||
|
|
||||||
### Coroutine
|
## Code Conventions
|
||||||
|
|
||||||
帧驱动协程系统基于 `IYieldInstruction` 和调度器抽象,支持等待时间、事件和任务完成等常见模式。
|
- **命名空间**: `GFramework.{Module}.{Feature}` (PascalCase),CI 通过 `scripts/validate-csharp-naming.sh` 强制校验
|
||||||
|
- **ImplicitUsings: disabled** — 所有 using 必须显式声明
|
||||||
|
- **Nullable: enabled**
|
||||||
|
- **LangVersion: preview**
|
||||||
|
- **GenerateDocumentationFile: true** — 公共 API 需要 XML 文档注释
|
||||||
|
- **Analyzers**: Meziantou.Analyzer 在构建时强制代码规范
|
||||||
|
|
||||||
### IoC
|
## Testing
|
||||||
|
|
||||||
依赖注入通过 `MicrosoftDiContainer` 对 `Microsoft.Extensions.DependencyInjection` 进行封装,用于统一组件注册和服务解析体验。
|
- **Framework**: NUnit 4.x + Moq
|
||||||
|
- **测试结构**: 镜像源码目录(如 `Core.Tests/Command/` 对应 `Core/Command/`)
|
||||||
### Service Modules
|
- **基类**: `ArchitectureTestsBase<T>` 提供 Architecture 初始化/销毁模板;`SyncTestArchitecture` /
|
||||||
|
`AsyncTestArchitecture` 用于集成测试
|
||||||
`IServiceModule` 模式用于向 Architecture 注册内置服务,例如 EventBus、CommandExecutor、QueryExecutor 等。这一模式承担“基础设施能力装配”的职责。
|
- **Target frameworks**: net8.0;net10.0
|
||||||
|
|
||||||
## Source Generators
|
## Source Generators
|
||||||
|
|
||||||
当前仓库包含多类 Roslyn 增量源码生成器:
|
四个生成器,均为 Roslyn 增量源码生成器:
|
||||||
|
|
||||||
- `LoggerGenerator` (`[Log]`): 自动生成日志字段和日志辅助方法。
|
- `LoggerGenerator` (`[Log]`): 自动生成 ILogger 字段和日志方法
|
||||||
- `PriorityGenerator` (`[Priority]`): 生成优先级比较相关实现。
|
- `PriorityGenerator` (`[Priority]`): 生成优先级比较实现
|
||||||
- `EnumExtensionsGenerator` (`[GenerateEnumExtensions]`): 生成枚举扩展能力。
|
- `EnumExtensionsGenerator` (`[GenerateEnumExtensions]`): 枚举扩展方法
|
||||||
- `ContextAwareGenerator` (`[ContextAware]`): 自动实现 `IContextAware` 相关样板逻辑。
|
- `ContextAwareGenerator` (`[ContextAware]`): 自动实现 IContextAware 接口
|
||||||
|
|
||||||
这些生成器的目标是减少重复代码,同时保持框架层 API 的一致性与可维护性。
|
测试使用快照验证(Verify + snapshot files)。
|
||||||
|
|
||||||
## Module Structure
|
## Documentation
|
||||||
|
|
||||||
仓库以“抽象层 + 实现层 + 集成层 + 生成器层”的方式组织:
|
VitePress 站点位于 `docs/`,内容为中文 (`docs/zh-CN/`)。修改文档后本地预览:
|
||||||
|
|
||||||
- `GFramework.Core.Abstractions` / `GFramework.Game.Abstractions`: 约束接口和公共契约。
|
```bash
|
||||||
- `GFramework.Core` / `GFramework.Game`: 提供平台无关实现。
|
cd docs && bun install && bun run dev
|
||||||
- `GFramework.Godot`: 提供与 Godot 运行时集成的适配实现。
|
```
|
||||||
- `GFramework.Ecs.Arch`: 提供 ECS Architecture 相关扩展。
|
|
||||||
- `GFramework.SourceGenerators` 及相关 Abstractions/Common: 提供代码生成能力。
|
|
||||||
|
|
||||||
这种结构的核心设计目标是让抽象稳定、实现可替换、引擎集成隔离、生成器能力可独立演进。
|
|
||||||
|
|
||||||
## Documentation Structure
|
|
||||||
|
|
||||||
项目文档位于 `docs/`,中文内容位于 `docs/zh-CN/`。文档内容覆盖:
|
|
||||||
|
|
||||||
- 入门与安装
|
|
||||||
- Core / Game / Godot / ECS 各模块能力
|
|
||||||
- Source Generator 使用说明
|
|
||||||
- 教程、最佳实践与故障排查
|
|
||||||
|
|
||||||
阅读顺序通常建议先看根目录 `README.md` 和各子模块 `README.md`,再进入 `docs/` 查阅专题说明。
|
|
||||||
|
|
||||||
## Design Intent
|
|
||||||
|
|
||||||
GFramework 的设计重点不是把所有能力堆进单一核心类,而是通过清晰的模块边界、可组合的服务注册方式、稳定的抽象契约以及适度自动化的源码生成,构建一个适合长期演进的游戏开发基础框架。
|
|
||||||
|
|||||||
@ -1,16 +0,0 @@
|
|||||||
namespace GFramework.Core.Abstractions.Utility.Numeric;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 数值显示格式化器接口。
|
|
||||||
/// </summary>
|
|
||||||
public interface INumericDisplayFormatter
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 将数值格式化为展示字符串。
|
|
||||||
/// </summary>
|
|
||||||
/// <typeparam name="T">数值类型。</typeparam>
|
|
||||||
/// <param name="value">待格式化的值。</param>
|
|
||||||
/// <param name="options">格式化选项。</param>
|
|
||||||
/// <returns>格式化后的字符串。</returns>
|
|
||||||
string Format<T>(T value, NumericFormatOptions? options = null);
|
|
||||||
}
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
namespace GFramework.Core.Abstractions.Utility.Numeric;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 数值显示规则接口。
|
|
||||||
/// </summary>
|
|
||||||
public interface INumericFormatRule
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 规则名称。
|
|
||||||
/// </summary>
|
|
||||||
string Name { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 尝试按当前规则格式化数值。
|
|
||||||
/// </summary>
|
|
||||||
/// <typeparam name="T">数值类型。</typeparam>
|
|
||||||
/// <param name="value">待格式化的值。</param>
|
|
||||||
/// <param name="options">格式化选项。</param>
|
|
||||||
/// <param name="result">输出结果。</param>
|
|
||||||
/// <returns>格式化是否成功。</returns>
|
|
||||||
bool TryFormat<T>(T value, NumericFormatOptions options, out string result);
|
|
||||||
}
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
namespace GFramework.Core.Abstractions.Utility.Numeric;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 数值显示风格。
|
|
||||||
/// </summary>
|
|
||||||
public enum NumericDisplayStyle
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 紧凑缩写风格,例如 1.2K / 3.4M。
|
|
||||||
/// </summary>
|
|
||||||
Compact = 0
|
|
||||||
}
|
|
||||||
@ -1,52 +0,0 @@
|
|||||||
namespace GFramework.Core.Abstractions.Utility.Numeric;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 数值格式化选项。
|
|
||||||
/// </summary>
|
|
||||||
public sealed record NumericFormatOptions
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 显示风格。
|
|
||||||
/// </summary>
|
|
||||||
public NumericDisplayStyle Style { get; init; } = NumericDisplayStyle.Compact;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 最大保留小数位数。
|
|
||||||
/// </summary>
|
|
||||||
public int MaxDecimalPlaces { get; init; } = 1;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 最少保留小数位数。
|
|
||||||
/// </summary>
|
|
||||||
public int MinDecimalPlaces { get; init; } = 0;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 四舍五入策略。
|
|
||||||
/// </summary>
|
|
||||||
public MidpointRounding MidpointRounding { get; init; } = MidpointRounding.AwayFromZero;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 是否裁剪小数末尾的 0。
|
|
||||||
/// </summary>
|
|
||||||
public bool TrimTrailingZeros { get; init; } = true;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 小于缩写阈值时是否启用千分位分组。
|
|
||||||
/// </summary>
|
|
||||||
public bool UseGroupingBelowThreshold { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 进入缩写显示的阈值。
|
|
||||||
/// </summary>
|
|
||||||
public decimal CompactThreshold { get; init; } = 1000m;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 格式提供者。
|
|
||||||
/// </summary>
|
|
||||||
public IFormatProvider? FormatProvider { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 自定义格式规则。
|
|
||||||
/// </summary>
|
|
||||||
public INumericFormatRule? Rule { get; init; }
|
|
||||||
}
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
namespace GFramework.Core.Abstractions.Utility.Numeric;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 数值缩写阈值定义。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="Divisor">缩写除数,例如 1000、1000000。</param>
|
|
||||||
/// <param name="Suffix">缩写后缀,例如 K、M。</param>
|
|
||||||
public readonly record struct NumericSuffixThreshold(decimal Divisor, string Suffix);
|
|
||||||
@ -49,11 +49,7 @@ public class LocalizationIntegrationTests
|
|||||||
{
|
{
|
||||||
"game.title": "My Game",
|
"game.title": "My Game",
|
||||||
"ui.message.welcome": "Welcome, {playerName}!",
|
"ui.message.welcome": "Welcome, {playerName}!",
|
||||||
"status.health": "Health: {current}/{max}",
|
"status.health": "Health: {current}/{max}"
|
||||||
"status.gold": "Gold: {gold:compact}",
|
|
||||||
"status.damage": "Damage: {damage:compact:maxDecimals=2}",
|
|
||||||
"status.unknownCompact": "Gold: {gold:compact:maxDecimalss=2}",
|
|
||||||
"status.invalidCompact": "Gold: {gold:compact:maxDecimals=abc}"
|
|
||||||
}
|
}
|
||||||
""");
|
""");
|
||||||
|
|
||||||
@ -61,11 +57,7 @@ public class LocalizationIntegrationTests
|
|||||||
{
|
{
|
||||||
"game.title": "我的游戏",
|
"game.title": "我的游戏",
|
||||||
"ui.message.welcome": "欢迎, {playerName}!",
|
"ui.message.welcome": "欢迎, {playerName}!",
|
||||||
"status.health": "生命值: {current}/{max}",
|
"status.health": "生命值: {current}/{max}"
|
||||||
"status.gold": "金币: {gold:compact}",
|
|
||||||
"status.damage": "伤害: {damage:compact:maxDecimals=2}",
|
|
||||||
"status.unknownCompact": "金币: {gold:compact:maxDecimalss=2}",
|
|
||||||
"status.invalidCompact": "金币: {gold:compact:maxDecimals=abc}"
|
|
||||||
}
|
}
|
||||||
""");
|
""");
|
||||||
}
|
}
|
||||||
@ -116,46 +108,6 @@ public class LocalizationIntegrationTests
|
|||||||
Assert.That(health, Is.EqualTo("Health: 80/100"));
|
Assert.That(health, Is.EqualTo("Health: 80/100"));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void GetString_WithCompactFormatter_ShouldFormatCorrectly()
|
|
||||||
{
|
|
||||||
var gold = _manager!.GetString("common", "status.gold")
|
|
||||||
.WithVariable("gold", 1_250)
|
|
||||||
.Format();
|
|
||||||
|
|
||||||
Assert.That(gold, Is.EqualTo("Gold: 1.3K"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void GetString_WithCompactFormatterArgs_ShouldApplyOptions()
|
|
||||||
{
|
|
||||||
var damage = _manager!.GetString("common", "status.damage")
|
|
||||||
.WithVariable("damage", 1_234)
|
|
||||||
.Format();
|
|
||||||
|
|
||||||
Assert.That(damage, Is.EqualTo("Damage: 1.23K"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void GetString_WithUnknownCompactFormatterArgs_ShouldIgnoreUnknownOptions()
|
|
||||||
{
|
|
||||||
var gold = _manager!.GetString("common", "status.unknownCompact")
|
|
||||||
.WithVariable("gold", 1_250)
|
|
||||||
.Format();
|
|
||||||
|
|
||||||
Assert.That(gold, Is.EqualTo("Gold: 1.3K"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void GetString_WithInvalidCompactFormatterArgs_ShouldFallbackToDefaultFormatting()
|
|
||||||
{
|
|
||||||
var gold = _manager!.GetString("common", "status.invalidCompact")
|
|
||||||
.WithVariable("gold", 1_250)
|
|
||||||
.Format();
|
|
||||||
|
|
||||||
Assert.That(gold, Is.EqualTo("Gold: 1250"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void LanguageChange_ShouldTriggerCallback()
|
public void LanguageChange_ShouldTriggerCallback()
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,129 +0,0 @@
|
|||||||
using System.Globalization;
|
|
||||||
using GFramework.Core.Abstractions.Utility.Numeric;
|
|
||||||
using GFramework.Core.Extensions;
|
|
||||||
using GFramework.Core.Utility.Numeric;
|
|
||||||
|
|
||||||
namespace GFramework.Core.Tests.Utility;
|
|
||||||
|
|
||||||
[TestFixture]
|
|
||||||
public class NumericDisplayFormatterTests
|
|
||||||
{
|
|
||||||
[Test]
|
|
||||||
public void FormatCompact_ShouldReturnPlainText_WhenValueIsBelowThreshold()
|
|
||||||
{
|
|
||||||
var result = NumericDisplay.FormatCompact(950);
|
|
||||||
|
|
||||||
Assert.That(result, Is.EqualTo("950"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void FormatCompact_ShouldFormatInt_AsCompactText()
|
|
||||||
{
|
|
||||||
var result = NumericDisplay.FormatCompact(1_200);
|
|
||||||
|
|
||||||
Assert.That(result, Is.EqualTo("1.2K"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void FormatCompact_ShouldFormatLong_AsCompactText()
|
|
||||||
{
|
|
||||||
var result = NumericDisplay.FormatCompact(1_000_000L);
|
|
||||||
|
|
||||||
Assert.That(result, Is.EqualTo("1M"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void FormatCompact_ShouldFormatDecimal_AsCompactText()
|
|
||||||
{
|
|
||||||
var result = NumericDisplay.FormatCompact(1_234.56m);
|
|
||||||
|
|
||||||
Assert.That(result, Is.EqualTo("1.2K"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void FormatCompact_ShouldFormatNegativeValues()
|
|
||||||
{
|
|
||||||
var result = NumericDisplay.FormatCompact(-1_250);
|
|
||||||
|
|
||||||
Assert.That(result, Is.EqualTo("-1.3K"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void FormatCompact_ShouldPromoteRoundedBoundary_ToNextSuffix()
|
|
||||||
{
|
|
||||||
var result = NumericDisplay.FormatCompact(999_950);
|
|
||||||
|
|
||||||
Assert.That(result, Is.EqualTo("1M"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void Format_ShouldRespectFormatProvider()
|
|
||||||
{
|
|
||||||
var result = NumericDisplay.Format(1_234.5m, new NumericFormatOptions
|
|
||||||
{
|
|
||||||
CompactThreshold = 10_000m,
|
|
||||||
FormatProvider = CultureInfo.GetCultureInfo("de-DE")
|
|
||||||
});
|
|
||||||
|
|
||||||
Assert.That(result, Is.EqualTo("1234,5"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void Format_ShouldUseGroupingBelowThreshold_WhenEnabled()
|
|
||||||
{
|
|
||||||
var result = NumericDisplay.Format(12_345, new NumericFormatOptions
|
|
||||||
{
|
|
||||||
CompactThreshold = 1_000_000m,
|
|
||||||
UseGroupingBelowThreshold = true,
|
|
||||||
FormatProvider = CultureInfo.InvariantCulture
|
|
||||||
});
|
|
||||||
|
|
||||||
Assert.That(result, Is.EqualTo("12,345"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void Format_ShouldSupportCustomSuffixRule()
|
|
||||||
{
|
|
||||||
var rule = new NumericSuffixFormatRule("custom",
|
|
||||||
[
|
|
||||||
new NumericSuffixThreshold(10m, "X"),
|
|
||||||
new NumericSuffixThreshold(100m, "Y")
|
|
||||||
]);
|
|
||||||
|
|
||||||
var result = NumericDisplay.Format(123, new NumericFormatOptions
|
|
||||||
{
|
|
||||||
Rule = rule,
|
|
||||||
CompactThreshold = 10m,
|
|
||||||
FormatProvider = CultureInfo.InvariantCulture
|
|
||||||
});
|
|
||||||
|
|
||||||
Assert.That(result, Is.EqualTo("1.2Y"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void Format_ShouldHandlePositiveInfinity()
|
|
||||||
{
|
|
||||||
var result = NumericDisplay.Format(double.PositiveInfinity, new NumericFormatOptions
|
|
||||||
{
|
|
||||||
FormatProvider = CultureInfo.InvariantCulture
|
|
||||||
});
|
|
||||||
|
|
||||||
Assert.That(result, Is.EqualTo("Infinity"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void Format_ObjectOverload_ShouldDispatchToNumericFormatter()
|
|
||||||
{
|
|
||||||
var result = NumericDisplay.Format((object)1_234m);
|
|
||||||
|
|
||||||
Assert.That(result, Is.EqualTo("1.2K"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void ToCompactString_ShouldUseNumericExtension()
|
|
||||||
{
|
|
||||||
var result = 15_320.ToCompactString();
|
|
||||||
|
|
||||||
Assert.That(result, Is.EqualTo("15.3K"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
using System.Numerics;
|
|
||||||
using GFramework.Core.Abstractions.Utility.Numeric;
|
|
||||||
using GFramework.Core.Utility.Numeric;
|
|
||||||
|
|
||||||
namespace GFramework.Core.Extensions;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 数值显示扩展方法。
|
|
||||||
/// </summary>
|
|
||||||
public static class NumericDisplayExtensions
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 按指定选项将数值格式化为展示字符串。
|
|
||||||
/// </summary>
|
|
||||||
public static string ToDisplayString<T>(this T value, NumericFormatOptions? options = null) where T : INumber<T>
|
|
||||||
{
|
|
||||||
return NumericDisplay.Format(value, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 使用默认紧凑风格将数值格式化为展示字符串。
|
|
||||||
/// </summary>
|
|
||||||
public static string ToCompactString<T>(
|
|
||||||
this T value,
|
|
||||||
int maxDecimalPlaces = 1,
|
|
||||||
IFormatProvider? formatProvider = null) where T : INumber<T>
|
|
||||||
{
|
|
||||||
return NumericDisplay.FormatCompact(value, maxDecimalPlaces, formatProvider);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,167 +0,0 @@
|
|||||||
using GFramework.Core.Abstractions.Localization;
|
|
||||||
using GFramework.Core.Abstractions.Utility.Numeric;
|
|
||||||
using GFramework.Core.Utility.Numeric;
|
|
||||||
|
|
||||||
namespace GFramework.Core.Localization.Formatters;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 紧凑数值格式化器。
|
|
||||||
/// 格式: {value:compact} 或 {value:compact:maxDecimals=2,trimZeros=false}
|
|
||||||
/// </summary>
|
|
||||||
public sealed class CompactNumberLocalizationFormatter : ILocalizationFormatter
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 获取格式化器的名称
|
|
||||||
/// </summary>
|
|
||||||
public string Name => "compact";
|
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 尝试将指定值按照紧凑数值格式进行格式化
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="format">格式字符串,可包含以下选项:
|
|
||||||
/// maxDecimals: 最大小数位数
|
|
||||||
/// minDecimals: 最小小数位数
|
|
||||||
/// trimZeros: 是否去除尾随零
|
|
||||||
/// grouping: 是否在阈值以下使用分组</param>
|
|
||||||
/// <param name="value">要格式化的数值对象</param>
|
|
||||||
/// <param name="provider">格式提供程序,用于区域性特定的格式设置</param>
|
|
||||||
/// <param name="result">格式化后的字符串结果</param>
|
|
||||||
/// <returns>如果格式化成功则返回true;如果格式字符串无效或格式化失败则返回false</returns>
|
|
||||||
public bool TryFormat(string format, object value, IFormatProvider? provider, out string result)
|
|
||||||
{
|
|
||||||
result = string.Empty;
|
|
||||||
|
|
||||||
if (!TryParseOptions(format, provider, out var options))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
result = NumericDisplay.Format(value, options);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
catch (ArgumentNullException)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
catch (ArgumentException)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 尝试解析格式字符串中的选项参数
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="format">格式字符串,包含以逗号分隔的键值对,如"maxDecimals=2,trimZeros=false"</param>
|
|
||||||
/// <param name="provider">格式提供程序</param>
|
|
||||||
/// <param name="options">解析成功的选项输出</param>
|
|
||||||
/// <returns>如果所有选项都正确解析则返回true;如果有任何语法错误或无效值则返回false</returns>
|
|
||||||
/// <remarks>
|
|
||||||
/// 支持的选项包括:
|
|
||||||
/// - maxDecimals: 最大小数位数,必须是有效整数
|
|
||||||
/// - minDecimals: 最小小数位数,必须是有效整数
|
|
||||||
/// - trimZeros: 是否去除尾随零,必须是有效布尔值
|
|
||||||
/// - grouping: 是否在阈值以下使用分组,必须是有效布尔值
|
|
||||||
/// 选项之间用逗号或分号分隔,格式为key=value
|
|
||||||
/// </remarks>
|
|
||||||
private static bool TryParseOptions(string format, IFormatProvider? provider, out NumericFormatOptions options)
|
|
||||||
{
|
|
||||||
options = new NumericFormatOptions
|
|
||||||
{
|
|
||||||
FormatProvider = provider
|
|
||||||
};
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(format))
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
var maxDecimalPlaces = options.MaxDecimalPlaces;
|
|
||||||
var minDecimalPlaces = options.MinDecimalPlaces;
|
|
||||||
var trimTrailingZeros = options.TrimTrailingZeros;
|
|
||||||
var useGroupingBelowThreshold = options.UseGroupingBelowThreshold;
|
|
||||||
|
|
||||||
foreach (var segment in format.Split([',', ';'],
|
|
||||||
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
|
||||||
{
|
|
||||||
if (!TryParseSegment(segment, out var key, out var value))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!TryApplyOption(
|
|
||||||
key,
|
|
||||||
value,
|
|
||||||
ref maxDecimalPlaces,
|
|
||||||
ref minDecimalPlaces,
|
|
||||||
ref trimTrailingZeros,
|
|
||||||
ref useGroupingBelowThreshold))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
options = options with
|
|
||||||
{
|
|
||||||
MaxDecimalPlaces = maxDecimalPlaces,
|
|
||||||
MinDecimalPlaces = minDecimalPlaces,
|
|
||||||
TrimTrailingZeros = trimTrailingZeros,
|
|
||||||
UseGroupingBelowThreshold = useGroupingBelowThreshold
|
|
||||||
};
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 尝试解析格式字符串中的单个键值对片段。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="segment">包含键值对的字符串片段,格式应为"key=value"</param>
|
|
||||||
/// <param name="key">解析得到的键名</param>
|
|
||||||
/// <param name="value">解析得到的值</param>
|
|
||||||
/// <returns>如果片段格式有效且成功解析则返回true;如果格式无效(如缺少分隔符、空键等)则返回false</returns>
|
|
||||||
private static bool TryParseSegment(string segment, out string key, out string value)
|
|
||||||
{
|
|
||||||
var separatorIndex = segment.IndexOf('=');
|
|
||||||
if (separatorIndex <= 0 || separatorIndex == segment.Length - 1)
|
|
||||||
{
|
|
||||||
key = string.Empty;
|
|
||||||
value = string.Empty;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
key = segment[..separatorIndex].Trim();
|
|
||||||
value = segment[(separatorIndex + 1)..].Trim();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 尝试将解析得到的键值对应用到相应的选项变量中。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="key">选项名称</param>
|
|
||||||
/// <param name="value">选项值的字符串表示</param>
|
|
||||||
/// <param name="maxDecimalPlaces">最大小数位数的引用参数</param>
|
|
||||||
/// <param name="minDecimalPlaces">最小小数位数的引用参数</param>
|
|
||||||
/// <param name="trimTrailingZeros">是否去除尾随零的引用参数</param>
|
|
||||||
/// <param name="useGroupingBelowThreshold">是否在阈值以下使用分组的引用参数</param>
|
|
||||||
/// <returns>如果值成功解析或键名未知则返回true;如果键名已知但值解析失败则返回false</returns>
|
|
||||||
private static bool TryApplyOption(
|
|
||||||
string key,
|
|
||||||
string value,
|
|
||||||
ref int maxDecimalPlaces,
|
|
||||||
ref int minDecimalPlaces,
|
|
||||||
ref bool trimTrailingZeros,
|
|
||||||
ref bool useGroupingBelowThreshold)
|
|
||||||
{
|
|
||||||
return key switch
|
|
||||||
{
|
|
||||||
"maxDecimals" => int.TryParse(value, out maxDecimalPlaces),
|
|
||||||
"minDecimals" => int.TryParse(value, out minDecimalPlaces),
|
|
||||||
"trimZeros" => bool.TryParse(value, out trimTrailingZeros),
|
|
||||||
"grouping" => bool.TryParse(value, out useGroupingBelowThreshold),
|
|
||||||
_ => true
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -2,7 +2,6 @@ using System.Globalization;
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using GFramework.Core.Abstractions.Localization;
|
using GFramework.Core.Abstractions.Localization;
|
||||||
using GFramework.Core.Localization.Formatters;
|
|
||||||
using GFramework.Core.Systems;
|
using GFramework.Core.Systems;
|
||||||
|
|
||||||
namespace GFramework.Core.Localization;
|
namespace GFramework.Core.Localization;
|
||||||
@ -12,11 +11,11 @@ namespace GFramework.Core.Localization;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class LocalizationManager : AbstractSystem, ILocalizationManager
|
public class LocalizationManager : AbstractSystem, ILocalizationManager
|
||||||
{
|
{
|
||||||
private readonly List<string> _availableLanguages;
|
|
||||||
private readonly LocalizationConfig _config;
|
private readonly LocalizationConfig _config;
|
||||||
private readonly Dictionary<string, ILocalizationFormatter> _formatters;
|
private readonly Dictionary<string, ILocalizationFormatter> _formatters;
|
||||||
private readonly List<Action<string>> _languageChangeCallbacks;
|
private readonly List<Action<string>> _languageChangeCallbacks;
|
||||||
private readonly Dictionary<string, Dictionary<string, ILocalizationTable>> _tables;
|
private readonly Dictionary<string, Dictionary<string, ILocalizationTable>> _tables;
|
||||||
|
private List<string> _availableLanguages;
|
||||||
private CultureInfo _currentCulture;
|
private CultureInfo _currentCulture;
|
||||||
private string _currentLanguage;
|
private string _currentLanguage;
|
||||||
|
|
||||||
@ -33,7 +32,6 @@ public class LocalizationManager : AbstractSystem, ILocalizationManager
|
|||||||
_currentLanguage = _config.DefaultLanguage;
|
_currentLanguage = _config.DefaultLanguage;
|
||||||
_currentCulture = GetCultureInfo(_currentLanguage);
|
_currentCulture = GetCultureInfo(_currentLanguage);
|
||||||
_availableLanguages = new List<string>();
|
_availableLanguages = new List<string>();
|
||||||
RegisterBuiltInFormatters();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
@ -180,13 +178,6 @@ public class LocalizationManager : AbstractSystem, ILocalizationManager
|
|||||||
_languageChangeCallbacks.Clear();
|
_languageChangeCallbacks.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RegisterBuiltInFormatters()
|
|
||||||
{
|
|
||||||
RegisterFormatter("if", new ConditionalFormatter());
|
|
||||||
RegisterFormatter("plural", new PluralFormatter());
|
|
||||||
RegisterFormatter("compact", new CompactNumberLocalizationFormatter());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 扫描可用语言
|
/// 扫描可用语言
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@ -1,58 +0,0 @@
|
|||||||
using System.Numerics;
|
|
||||||
using GFramework.Core.Abstractions.Utility.Numeric;
|
|
||||||
|
|
||||||
namespace GFramework.Core.Utility.Numeric;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 数值显示静态入口。
|
|
||||||
/// </summary>
|
|
||||||
public static class NumericDisplay
|
|
||||||
{
|
|
||||||
private static readonly NumericDisplayFormatter DefaultFormatter = new();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 将数值格式化为展示字符串。
|
|
||||||
/// </summary>
|
|
||||||
public static string Format<T>(T value, NumericFormatOptions? options = null) where T : INumber<T>
|
|
||||||
{
|
|
||||||
return DefaultFormatter.Format(value, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 将运行时数值对象格式化为展示字符串。
|
|
||||||
/// </summary>
|
|
||||||
public static string Format(object value, NumericFormatOptions? options = null)
|
|
||||||
{
|
|
||||||
return DefaultFormatter.Format(value, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 使用默认紧凑风格格式化数值。
|
|
||||||
/// </summary>
|
|
||||||
public static string FormatCompact<T>(
|
|
||||||
T value,
|
|
||||||
int maxDecimalPlaces = 1,
|
|
||||||
IFormatProvider? formatProvider = null) where T : INumber<T>
|
|
||||||
{
|
|
||||||
return Format(value, new NumericFormatOptions
|
|
||||||
{
|
|
||||||
MaxDecimalPlaces = maxDecimalPlaces,
|
|
||||||
FormatProvider = formatProvider
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 使用默认紧凑风格格式化运行时数值对象。
|
|
||||||
/// </summary>
|
|
||||||
public static string FormatCompact(
|
|
||||||
object value,
|
|
||||||
int maxDecimalPlaces = 1,
|
|
||||||
IFormatProvider? formatProvider = null)
|
|
||||||
{
|
|
||||||
return Format(value, new NumericFormatOptions
|
|
||||||
{
|
|
||||||
MaxDecimalPlaces = maxDecimalPlaces,
|
|
||||||
FormatProvider = formatProvider
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,140 +0,0 @@
|
|||||||
using System.Globalization;
|
|
||||||
using System.Numerics;
|
|
||||||
using GFramework.Core.Abstractions.Utility.Numeric;
|
|
||||||
|
|
||||||
namespace GFramework.Core.Utility.Numeric;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 默认数值显示格式化器。
|
|
||||||
/// </summary>
|
|
||||||
public sealed class NumericDisplayFormatter : INumericDisplayFormatter
|
|
||||||
{
|
|
||||||
private readonly INumericFormatRule _defaultRule;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 初始化默认数值显示格式化器。
|
|
||||||
/// </summary>
|
|
||||||
public NumericDisplayFormatter()
|
|
||||||
: this(NumericSuffixFormatRule.InternationalCompact)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 初始化数值显示格式化器。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="defaultRule">默认规则。</param>
|
|
||||||
public NumericDisplayFormatter(INumericFormatRule defaultRule)
|
|
||||||
{
|
|
||||||
_defaultRule = defaultRule ?? throw new ArgumentNullException(nameof(defaultRule));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc/>
|
|
||||||
public string Format<T>(T value, NumericFormatOptions? options = null)
|
|
||||||
{
|
|
||||||
if (value is null)
|
|
||||||
{
|
|
||||||
throw new ArgumentNullException(nameof(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
var resolvedOptions = NormalizeOptions(options);
|
|
||||||
var rule = ResolveRule(resolvedOptions);
|
|
||||||
|
|
||||||
if (rule.TryFormat(value, resolvedOptions, out var result))
|
|
||||||
{
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
return FormatFallback(value!, resolvedOptions.FormatProvider);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 将运行时数值对象格式化为展示字符串。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="value">待格式化的数值对象。</param>
|
|
||||||
/// <param name="options">格式化选项。</param>
|
|
||||||
/// <returns>格式化后的字符串。</returns>
|
|
||||||
public string Format(object value, NumericFormatOptions? options = null)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(value);
|
|
||||||
|
|
||||||
return value switch
|
|
||||||
{
|
|
||||||
byte byteValue => Format(byteValue, options),
|
|
||||||
sbyte sbyteValue => Format(sbyteValue, options),
|
|
||||||
short shortValue => Format(shortValue, options),
|
|
||||||
ushort ushortValue => Format(ushortValue, options),
|
|
||||||
int intValue => Format(intValue, options),
|
|
||||||
uint uintValue => Format(uintValue, options),
|
|
||||||
long longValue => Format(longValue, options),
|
|
||||||
ulong ulongValue => Format(ulongValue, options),
|
|
||||||
nint nativeIntValue => Format(nativeIntValue, options),
|
|
||||||
nuint nativeUIntValue => Format(nativeUIntValue, options),
|
|
||||||
float floatValue => Format(floatValue, options),
|
|
||||||
double doubleValue => Format(doubleValue, options),
|
|
||||||
decimal decimalValue => Format(decimalValue, options),
|
|
||||||
BigInteger bigIntegerValue => Format(bigIntegerValue, options),
|
|
||||||
_ => FormatFallback(value, options?.FormatProvider)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static NumericFormatOptions NormalizeOptions(NumericFormatOptions? options)
|
|
||||||
{
|
|
||||||
var resolved = options ?? new NumericFormatOptions();
|
|
||||||
|
|
||||||
if (resolved.MaxDecimalPlaces < 0)
|
|
||||||
{
|
|
||||||
throw new ArgumentOutOfRangeException(
|
|
||||||
nameof(options),
|
|
||||||
resolved.MaxDecimalPlaces,
|
|
||||||
"MaxDecimalPlaces 不能小于 0。");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resolved.MinDecimalPlaces < 0)
|
|
||||||
{
|
|
||||||
throw new ArgumentOutOfRangeException(
|
|
||||||
nameof(options),
|
|
||||||
resolved.MinDecimalPlaces,
|
|
||||||
"MinDecimalPlaces 不能小于 0。");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resolved.MinDecimalPlaces > resolved.MaxDecimalPlaces)
|
|
||||||
{
|
|
||||||
throw new ArgumentException("MinDecimalPlaces 不能大于 MaxDecimalPlaces。", nameof(options));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resolved.CompactThreshold <= 0m)
|
|
||||||
{
|
|
||||||
throw new ArgumentOutOfRangeException(
|
|
||||||
nameof(options),
|
|
||||||
resolved.CompactThreshold,
|
|
||||||
"CompactThreshold 必须大于 0。");
|
|
||||||
}
|
|
||||||
|
|
||||||
return resolved;
|
|
||||||
}
|
|
||||||
|
|
||||||
private INumericFormatRule ResolveRule(NumericFormatOptions options)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(options);
|
|
||||||
|
|
||||||
if (options.Rule is not null)
|
|
||||||
{
|
|
||||||
return options.Rule;
|
|
||||||
}
|
|
||||||
|
|
||||||
return options.Style switch
|
|
||||||
{
|
|
||||||
NumericDisplayStyle.Compact => _defaultRule,
|
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(options), options.Style, "不支持的数值显示风格。")
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string FormatFallback(object value, IFormatProvider? provider)
|
|
||||||
{
|
|
||||||
return value switch
|
|
||||||
{
|
|
||||||
IFormattable formattable => formattable.ToString(null, provider ?? CultureInfo.CurrentCulture),
|
|
||||||
_ => value.ToString() ?? string.Empty
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,353 +0,0 @@
|
|||||||
using System.Globalization;
|
|
||||||
using System.Numerics;
|
|
||||||
using GFramework.Core.Abstractions.Utility.Numeric;
|
|
||||||
|
|
||||||
namespace GFramework.Core.Utility.Numeric;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 基于后缀阈值表的数值缩写规则。
|
|
||||||
/// </summary>
|
|
||||||
public sealed class NumericSuffixFormatRule : INumericFormatRule
|
|
||||||
{
|
|
||||||
private readonly NumericSuffixThreshold[] _thresholds;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 初始化后缀缩写规则。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="name">规则名称。</param>
|
|
||||||
/// <param name="thresholds">阈值表。</param>
|
|
||||||
public NumericSuffixFormatRule(string name, IEnumerable<NumericSuffixThreshold> thresholds)
|
|
||||||
{
|
|
||||||
ArgumentException.ThrowIfNullOrEmpty(name);
|
|
||||||
ArgumentNullException.ThrowIfNull(thresholds);
|
|
||||||
|
|
||||||
Name = name;
|
|
||||||
_thresholds = thresholds.OrderBy(entry => entry.Divisor).ToArray();
|
|
||||||
|
|
||||||
if (_thresholds.Length == 0)
|
|
||||||
{
|
|
||||||
throw new ArgumentException("至少需要一个缩写阈值。", nameof(thresholds));
|
|
||||||
}
|
|
||||||
|
|
||||||
ValidateThresholds(_thresholds);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 默认国际缩写规则,使用标准的K、M、B、T后缀表示千、百万、十亿、万亿。
|
|
||||||
/// </summary>
|
|
||||||
public static NumericSuffixFormatRule InternationalCompact { get; } = new(
|
|
||||||
"compact",
|
|
||||||
[
|
|
||||||
new NumericSuffixThreshold(1_000m, "K"),
|
|
||||||
new NumericSuffixThreshold(1_000_000m, "M"),
|
|
||||||
new NumericSuffixThreshold(1_000_000_000m, "B"),
|
|
||||||
new NumericSuffixThreshold(1_000_000_000_000m, "T")
|
|
||||||
]);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 获取此格式化规则的名称。
|
|
||||||
/// </summary>
|
|
||||||
public string Name { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 尝试将指定的数值按照当前规则进行格式化。
|
|
||||||
/// </summary>
|
|
||||||
/// <typeparam name="T">数值的类型</typeparam>
|
|
||||||
/// <param name="value">要格式化的数值</param>
|
|
||||||
/// <param name="options">格式化选项,包含小数位数、舍入模式等设置</param>
|
|
||||||
/// <param name="result">格式化后的字符串结果</param>
|
|
||||||
/// <returns>如果格式化成功则返回true;如果输入无效或格式化失败则返回false</returns>
|
|
||||||
public bool TryFormat<T>(T value, NumericFormatOptions options, out string result)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(options);
|
|
||||||
NumericDisplayFormatter.NormalizeOptions(options);
|
|
||||||
|
|
||||||
if (TryFormatSpecialFloatingPoint(value, options.FormatProvider, out result))
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
object? boxedValue = value;
|
|
||||||
if (boxedValue is null)
|
|
||||||
{
|
|
||||||
result = string.Empty;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return boxedValue switch
|
|
||||||
{
|
|
||||||
byte byteValue => TryFormatDecimal(byteValue, options, out result),
|
|
||||||
sbyte sbyteValue => TryFormatDecimal(sbyteValue, options, out result),
|
|
||||||
short shortValue => TryFormatDecimal(shortValue, options, out result),
|
|
||||||
ushort ushortValue => TryFormatDecimal(ushortValue, options, out result),
|
|
||||||
int intValue => TryFormatDecimal(intValue, options, out result),
|
|
||||||
uint uintValue => TryFormatDecimal(uintValue, options, out result),
|
|
||||||
long longValue => TryFormatDecimal(longValue, options, out result),
|
|
||||||
ulong ulongValue => TryFormatDecimal(ulongValue, options, out result),
|
|
||||||
nint nativeIntValue => TryFormatDecimal(nativeIntValue, options, out result),
|
|
||||||
nuint nativeUIntValue => TryFormatDecimal(nativeUIntValue, options, out result),
|
|
||||||
decimal decimalValue => TryFormatDecimal(decimalValue, options, out result),
|
|
||||||
float floatValue => TryFormatDouble(floatValue, options, out result),
|
|
||||||
double doubleValue => TryFormatDouble(doubleValue, options, out result),
|
|
||||||
BigInteger bigIntegerValue => TryFormatBigInteger(bigIntegerValue, options, out result),
|
|
||||||
_ => TryFormatConvertible(boxedValue, options, out result)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ValidateThresholds(IReadOnlyList<NumericSuffixThreshold> thresholds)
|
|
||||||
{
|
|
||||||
decimal? previousDivisor = null;
|
|
||||||
|
|
||||||
foreach (var threshold in thresholds)
|
|
||||||
{
|
|
||||||
if (threshold.Divisor <= 0m)
|
|
||||||
{
|
|
||||||
throw new ArgumentOutOfRangeException(nameof(thresholds), "阈值除数必须大于 0。");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(threshold.Suffix))
|
|
||||||
{
|
|
||||||
throw new ArgumentException("阈值后缀不能为空。", nameof(thresholds));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (previousDivisor.HasValue && threshold.Divisor <= previousDivisor.Value)
|
|
||||||
{
|
|
||||||
throw new ArgumentException("阈值除数必须严格递增。", nameof(thresholds));
|
|
||||||
}
|
|
||||||
|
|
||||||
previousDivisor = threshold.Divisor;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool TryFormatSpecialFloatingPoint<T>(
|
|
||||||
T value,
|
|
||||||
IFormatProvider? provider,
|
|
||||||
out string result)
|
|
||||||
{
|
|
||||||
object? boxedValue = value;
|
|
||||||
if (boxedValue is null)
|
|
||||||
{
|
|
||||||
result = string.Empty;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (boxedValue)
|
|
||||||
{
|
|
||||||
case float floatValue when float.IsNaN(floatValue) || float.IsInfinity(floatValue):
|
|
||||||
result = floatValue.ToString(null, provider);
|
|
||||||
return true;
|
|
||||||
case double doubleValue when double.IsNaN(doubleValue) || double.IsInfinity(doubleValue):
|
|
||||||
result = doubleValue.ToString(null, provider);
|
|
||||||
return true;
|
|
||||||
default:
|
|
||||||
result = string.Empty;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool TryFormatConvertible(object value, NumericFormatOptions options, out string result)
|
|
||||||
{
|
|
||||||
if (value is not IConvertible convertible)
|
|
||||||
{
|
|
||||||
result = string.Empty;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var decimalValue = convertible.ToDecimal(options.FormatProvider ?? CultureInfo.InvariantCulture);
|
|
||||||
return TryFormatDecimal(decimalValue, options, out result);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
result = string.Empty;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool TryFormatBigInteger(BigInteger value, NumericFormatOptions options, out string result)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return TryFormatDecimal((decimal)value, options, out result);
|
|
||||||
}
|
|
||||||
catch (OverflowException)
|
|
||||||
{
|
|
||||||
var doubleValue = (double)value;
|
|
||||||
if (TryFormatSpecialFloatingPoint(doubleValue, options.FormatProvider, out result))
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return TryFormatDouble(doubleValue, options, out result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool TryFormatDecimal(decimal value, NumericFormatOptions options, out string result)
|
|
||||||
{
|
|
||||||
var absoluteValue = Math.Abs(value);
|
|
||||||
|
|
||||||
if (absoluteValue < options.CompactThreshold)
|
|
||||||
{
|
|
||||||
result = FormatPlainDecimal(value, options);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
var suffixIndex = FindThresholdIndex(absoluteValue);
|
|
||||||
if (suffixIndex < 0)
|
|
||||||
{
|
|
||||||
result = FormatPlainDecimal(value, options);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
var scaledValue = RoundScaledDecimal(absoluteValue, suffixIndex, options, out suffixIndex);
|
|
||||||
result = ComposeResult(value < 0m, FormatDecimalCore(scaledValue, options, false), suffixIndex);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool TryFormatDouble(double value, NumericFormatOptions options, out string result)
|
|
||||||
{
|
|
||||||
var absoluteValue = Math.Abs(value);
|
|
||||||
|
|
||||||
if (absoluteValue < (double)options.CompactThreshold)
|
|
||||||
{
|
|
||||||
result = FormatPlainDouble(value, options);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
var suffixIndex = FindThresholdIndex(absoluteValue);
|
|
||||||
if (suffixIndex < 0)
|
|
||||||
{
|
|
||||||
result = FormatPlainDouble(value, options);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
var scaledValue = RoundScaledDouble(absoluteValue, suffixIndex, options, out suffixIndex);
|
|
||||||
result = ComposeResult(value < 0d, FormatDoubleCore(scaledValue, options, false), suffixIndex);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private string ComposeResult(bool negative, string numericPart, int suffixIndex)
|
|
||||||
{
|
|
||||||
return $"{(negative ? "-" : string.Empty)}{numericPart}{_thresholds[suffixIndex].Suffix}";
|
|
||||||
}
|
|
||||||
|
|
||||||
private int FindThresholdIndex(decimal absoluteValue)
|
|
||||||
{
|
|
||||||
for (var i = _thresholds.Length - 1; i >= 0; i--)
|
|
||||||
{
|
|
||||||
if (absoluteValue >= _thresholds[i].Divisor)
|
|
||||||
{
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
private int FindThresholdIndex(double absoluteValue)
|
|
||||||
{
|
|
||||||
for (var i = _thresholds.Length - 1; i >= 0; i--)
|
|
||||||
{
|
|
||||||
if (absoluteValue >= (double)_thresholds[i].Divisor)
|
|
||||||
{
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
private decimal RoundScaledDecimal(decimal absoluteValue, int suffixIndex, NumericFormatOptions options,
|
|
||||||
out int resolvedIndex)
|
|
||||||
{
|
|
||||||
resolvedIndex = suffixIndex;
|
|
||||||
var roundedValue = RoundDecimal(absoluteValue / _thresholds[resolvedIndex].Divisor, options);
|
|
||||||
|
|
||||||
while (resolvedIndex < _thresholds.Length - 1)
|
|
||||||
{
|
|
||||||
var promoteThreshold = _thresholds[resolvedIndex + 1].Divisor / _thresholds[resolvedIndex].Divisor;
|
|
||||||
if (roundedValue < promoteThreshold)
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
resolvedIndex++;
|
|
||||||
roundedValue = RoundDecimal(absoluteValue / _thresholds[resolvedIndex].Divisor, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
return roundedValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
private double RoundScaledDouble(double absoluteValue, int suffixIndex, NumericFormatOptions options,
|
|
||||||
out int resolvedIndex)
|
|
||||||
{
|
|
||||||
resolvedIndex = suffixIndex;
|
|
||||||
var roundedValue = RoundDouble(absoluteValue / (double)_thresholds[resolvedIndex].Divisor, options);
|
|
||||||
|
|
||||||
while (resolvedIndex < _thresholds.Length - 1)
|
|
||||||
{
|
|
||||||
var promoteThreshold =
|
|
||||||
(double)(_thresholds[resolvedIndex + 1].Divisor / _thresholds[resolvedIndex].Divisor);
|
|
||||||
if (roundedValue < promoteThreshold)
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
resolvedIndex++;
|
|
||||||
roundedValue = RoundDouble(absoluteValue / (double)_thresholds[resolvedIndex].Divisor, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
return roundedValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static decimal RoundDecimal(decimal value, NumericFormatOptions options)
|
|
||||||
{
|
|
||||||
return Math.Round(value, options.MaxDecimalPlaces, options.MidpointRounding);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static double RoundDouble(double value, NumericFormatOptions options)
|
|
||||||
{
|
|
||||||
return Math.Round(value, options.MaxDecimalPlaces, options.MidpointRounding);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string FormatPlainDecimal(decimal value, NumericFormatOptions options)
|
|
||||||
{
|
|
||||||
return FormatDecimalCore(RoundDecimal(value, options), options, options.UseGroupingBelowThreshold);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string FormatPlainDouble(double value, NumericFormatOptions options)
|
|
||||||
{
|
|
||||||
return FormatDoubleCore(RoundDouble(value, options), options, options.UseGroupingBelowThreshold);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string FormatDecimalCore(decimal value, NumericFormatOptions options, bool useGrouping)
|
|
||||||
{
|
|
||||||
return value.ToString(BuildFormatString(options, useGrouping), options.FormatProvider);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string FormatDoubleCore(double value, NumericFormatOptions options, bool useGrouping)
|
|
||||||
{
|
|
||||||
return value.ToString(BuildFormatString(options, useGrouping), options.FormatProvider);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string BuildFormatString(NumericFormatOptions options, bool useGrouping)
|
|
||||||
{
|
|
||||||
var integerPart = useGrouping ? "#,0" : "0";
|
|
||||||
|
|
||||||
if (options.MaxDecimalPlaces == 0)
|
|
||||||
{
|
|
||||||
return integerPart;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!options.TrimTrailingZeros)
|
|
||||||
{
|
|
||||||
var fixedDigits = Math.Max(options.MaxDecimalPlaces, options.MinDecimalPlaces);
|
|
||||||
return $"{integerPart}.{new string('0', fixedDigits)}";
|
|
||||||
}
|
|
||||||
|
|
||||||
var requiredDigits = new string('0', options.MinDecimalPlaces);
|
|
||||||
var optionalDigits = new string('#', options.MaxDecimalPlaces - options.MinDecimalPlaces);
|
|
||||||
return $"{integerPart}.{requiredDigits}{optionalDigits}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -118,10 +118,6 @@ GFramework 是一个开源的游戏开发框架,我们欢迎所有形式的贡
|
|||||||
|
|
||||||
## 开发环境设置
|
## 开发环境设置
|
||||||
|
|
||||||
当前推荐的项目相关环境、CLI 与 AI 可用工具清单请查看:
|
|
||||||
|
|
||||||
- [开发环境能力清单](./contributor/development-environment.md)
|
|
||||||
|
|
||||||
### 前置要求
|
### 前置要求
|
||||||
|
|
||||||
- **.NET SDK**:8.0、9.0 或 10.0
|
- **.NET SDK**:8.0、9.0 或 10.0
|
||||||
|
|||||||
@ -1,96 +0,0 @@
|
|||||||
# 开发环境能力清单
|
|
||||||
|
|
||||||
这份文档只记录对 `GFramework` 当前开发和 AI 协作真正有用的环境能力,不收录与本项目无关的系统工具。
|
|
||||||
|
|
||||||
如果某个工具没有出现在这里,默认表示它对当前仓库不是必需项,AI 也不应因为“系统里刚好装了”就优先使用它。
|
|
||||||
|
|
||||||
## 当前环境基线
|
|
||||||
|
|
||||||
当前仓库验证基线是:
|
|
||||||
|
|
||||||
- **运行环境**:WSL2
|
|
||||||
- **发行版**:Ubuntu 24.04 LTS
|
|
||||||
- **Shell**:`bash`
|
|
||||||
|
|
||||||
机器可读的环境数据分成两层:
|
|
||||||
|
|
||||||
- `GFramework/.ai/environment/tools.raw.yaml`:完整事实采集
|
|
||||||
- `GFramework/.ai/environment/tools.ai.yaml`:给 AI 看的精简决策提示
|
|
||||||
|
|
||||||
AI 应优先读取 `tools.ai.yaml`,只有在需要追溯完整事实时才查看 `tools.raw.yaml`。
|
|
||||||
|
|
||||||
## 当前项目需要的运行时
|
|
||||||
|
|
||||||
| 工具 | 是否需要 | 在 GFramework 中的用途 |
|
|
||||||
|-----------|------|---------------------------------|
|
|
||||||
| `dotnet` | 必需 | 构建、测试、打包整个解决方案 |
|
|
||||||
| `python3` | 推荐 | 运行本地辅助脚本、环境采集和轻量自动化 |
|
|
||||||
| `node` | 推荐 | 作为文档工具链的 JavaScript 运行时 |
|
|
||||||
| `bun` | 推荐 | 安装并预览 `docs/` 下的 VitePress 文档站点 |
|
|
||||||
|
|
||||||
## 当前项目需要的命令行工具
|
|
||||||
|
|
||||||
| 工具 | 是否需要 | 在 GFramework 中的用途 |
|
|
||||||
|----------|------|-----------------------------------------------|
|
|
||||||
| `git` | 必需 | 提交代码、查看 diff、审查变更 |
|
|
||||||
| `bash` | 必需 | 执行仓库脚本,例如 `scripts/validate-csharp-naming.sh` |
|
|
||||||
| `rg` | 必需 | 在仓库中快速搜索代码和文档 |
|
|
||||||
| `jq` | 推荐 | 处理 JSON 输出,便于本地脚本和 AI 做结构化检查 |
|
|
||||||
| `docker` | 可选 | 运行 MegaLinter 等容器化检查工具 |
|
|
||||||
|
|
||||||
这里只保留和当前仓库直接相关的 CLI。像 `kubectl`、`terraform`、`helm`、`java`、数据库客户端等工具,即使系统已安装,也不进入正式清单。
|
|
||||||
|
|
||||||
## Python 包
|
|
||||||
|
|
||||||
Python 包只记录两类内容:
|
|
||||||
|
|
||||||
- 当前环境里已经存在、对开发辅助有价值的包
|
|
||||||
- 明确对 AI/脚本化开发有帮助、后续可能会安装的包
|
|
||||||
|
|
||||||
| 包 | 当前状态 | 用途 |
|
|
||||||
|------------|---------|---------------------|
|
|
||||||
| `requests` | 当前环境已安装 | 用于简单 HTTP 调用和脚本集成 |
|
|
||||||
| `rich` | 当前环境已安装 | 用于更易读的终端输出 |
|
|
||||||
| `openai` | 当前环境可选 | 用于脚本化调用 OpenAI API |
|
|
||||||
| `tiktoken` | 当前环境可选 | 用于 token 估算和上下文检查 |
|
|
||||||
| `pydantic` | 当前环境可选 | 用于结构化配置和模式校验 |
|
|
||||||
| `pytest` | 当前环境可选 | 用于 Python 辅助脚本的小型测试 |
|
|
||||||
|
|
||||||
如果某个 Python 包与当前仓库没有直接关系,就不要加入清单。
|
|
||||||
|
|
||||||
## AI 使用约定
|
|
||||||
|
|
||||||
AI 在这个仓库里应优先使用:
|
|
||||||
|
|
||||||
- `rg` 做文本搜索
|
|
||||||
- `jq` 做 JSON 检查
|
|
||||||
- `bash` 执行仓库脚本
|
|
||||||
- `dotnet` 做构建和测试
|
|
||||||
- `bun` 做文档预览
|
|
||||||
- `python3 + requests` 做轻量本地辅助脚本
|
|
||||||
|
|
||||||
AI 不应直接把原始探测数据当成决策规则;应以 `tools.ai.yaml` 中的推荐和 fallback 为准。如果确实需要引入新工具,应先更新环境清单,再在任务中使用。
|
|
||||||
|
|
||||||
## 如何刷新环境清单
|
|
||||||
|
|
||||||
使用仓库脚本先采集原始环境,再生成 AI 版本:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 输出原始环境清单到终端
|
|
||||||
bash scripts/collect-dev-environment.sh --check
|
|
||||||
|
|
||||||
# 写回原始清单
|
|
||||||
bash scripts/collect-dev-environment.sh --write
|
|
||||||
|
|
||||||
# 由原始清单生成 AI 决策清单
|
|
||||||
python3 scripts/generate-ai-environment.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## 维护规则
|
|
||||||
|
|
||||||
- 目标不是记录“这台机器装了什么”,而是记录“GFramework 开发和 AI 协作实际该用什么”。
|
|
||||||
- 新工具只有在满足以下条件之一时才应加入清单:
|
|
||||||
- 当前仓库构建、测试、文档或验证直接依赖它
|
|
||||||
- AI 在当前仓库中会高频使用,且能明显提升效率
|
|
||||||
- 新贡献者配置当前仓库开发环境时确实需要知道它
|
|
||||||
- 不满足上述条件的工具,不写入文档,也不写入 `.ai/environment/tools.raw.yaml` / `.ai/environment/tools.ai.yaml`。
|
|
||||||
@ -228,3 +228,4 @@ architecture.RegisterLifecycleHook(new MyLifecycleHook());
|
|||||||
**相关文档**:
|
**相关文档**:
|
||||||
|
|
||||||
- [核心框架概述](./index.md)
|
- [核心框架概述](./index.md)
|
||||||
|
- [ADR-001: 拆分 Architecture 核心类](/docs/adr/001-split-architecture-class.md)
|
||||||
|
|||||||
@ -105,7 +105,7 @@ Any → FailedInitialization
|
|||||||
- 初始化/销毁 - Utility 注册
|
- 初始化/销毁 - Utility 注册
|
||||||
```
|
```
|
||||||
|
|
||||||
这种设计遵循单一职责原则,使代码更易维护和测试。
|
这种设计遵循单一职责原则,使代码更易维护和测试。详见 [ADR-001](/docs/adr/001-split-architecture-class.md)。
|
||||||
|
|
||||||
```
|
```
|
||||||
┌──────────────────┐
|
┌──────────────────┐
|
||||||
@ -398,7 +398,7 @@ public class PlayerController : IController
|
|||||||
4. **易于扩展**: 添加新功能更容易
|
4. **易于扩展**: 添加新功能更容易
|
||||||
5. **代码安全**: 消除了 `null!` 断言,所有字段在构造后立即可用
|
5. **代码安全**: 消除了 `null!` 断言,所有字段在构造后立即可用
|
||||||
|
|
||||||
详细的设计决策已在架构实现重构中落地。
|
详细的设计决策请参考 [ADR-001: 拆分 Architecture 核心类](/docs/adr/001-split-architecture-class.md)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -654,3 +654,4 @@ public interface IController :
|
|||||||
|
|
||||||
**向后兼容**: 所有公共 API 保持不变,现有代码无需修改。
|
**向后兼容**: 所有公共 API 保持不变,现有代码无需修改。
|
||||||
|
|
||||||
|
详见 [ADR-001: 拆分 Architecture 核心类](/docs/adr/001-split-architecture-class.md)
|
||||||
@ -365,42 +365,6 @@ public class EncryptionUtility : IUtility
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 内置数值显示工具
|
|
||||||
|
|
||||||
对于 UI 中常见的数值缩写显示,优先使用 `GFramework.Core` 提供的数值显示工具,而不是在业务层重复拼接字符串。
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using System.Globalization;
|
|
||||||
using GFramework.Core.Abstractions.Utility.Numeric;
|
|
||||||
using GFramework.Core.Extensions;
|
|
||||||
using GFramework.Core.Utility.Numeric;
|
|
||||||
|
|
||||||
var gold = NumericDisplay.FormatCompact(1250); // "1.3K"
|
|
||||||
var damage = 15320.ToCompactString(); // "15.3K"
|
|
||||||
|
|
||||||
var exact = NumericDisplay.Format(1234.56m, new NumericFormatOptions
|
|
||||||
{
|
|
||||||
MaxDecimalPlaces = 2,
|
|
||||||
FormatProvider = CultureInfo.InvariantCulture
|
|
||||||
}); // "1.23K"
|
|
||||||
|
|
||||||
var grouped = NumericDisplay.Format(12345, new NumericFormatOptions
|
|
||||||
{
|
|
||||||
CompactThreshold = 1000000m,
|
|
||||||
UseGroupingBelowThreshold = true,
|
|
||||||
FormatProvider = CultureInfo.InvariantCulture
|
|
||||||
}); // "12,345"
|
|
||||||
```
|
|
||||||
|
|
||||||
如果你在本地化文本中展示数值,也可以直接使用内置 formatter:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status.gold": "Gold: {gold:compact}",
|
|
||||||
"status.damage": "Damage: {damage:compact:maxDecimals=2}"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. 对象池工具
|
### 5. 对象池工具
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
|
|||||||
@ -1,268 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
ROOT_DIR="$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
||||||
OUTPUT_PATH="${ROOT_DIR}/.ai/environment/tools.raw.yaml"
|
|
||||||
MODE="${1:---check}"
|
|
||||||
|
|
||||||
usage() {
|
|
||||||
cat <<'EOF'
|
|
||||||
Usage:
|
|
||||||
bash scripts/collect-dev-environment.sh --check
|
|
||||||
bash scripts/collect-dev-environment.sh --write
|
|
||||||
|
|
||||||
Modes:
|
|
||||||
--check Print the raw project-relevant environment inventory.
|
|
||||||
--write Write the raw inventory to .ai/environment/tools.raw.yaml.
|
|
||||||
EOF
|
|
||||||
}
|
|
||||||
|
|
||||||
ensure_supported_mode() {
|
|
||||||
case "${MODE}" in
|
|
||||||
--check|--write)
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
usage
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
command_path() {
|
|
||||||
local tool="$1"
|
|
||||||
|
|
||||||
if command -v "${tool}" >/dev/null 2>&1; then
|
|
||||||
command -v "${tool}"
|
|
||||||
else
|
|
||||||
printf '%s' ""
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
command_installed() {
|
|
||||||
local tool="$1"
|
|
||||||
|
|
||||||
if command -v "${tool}" >/dev/null 2>&1; then
|
|
||||||
printf 'true'
|
|
||||||
else
|
|
||||||
printf 'false'
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
command_version() {
|
|
||||||
local tool="$1"
|
|
||||||
|
|
||||||
if ! command -v "${tool}" >/dev/null 2>&1; then
|
|
||||||
printf '%s' "not-installed"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
case "${tool}" in
|
|
||||||
dotnet)
|
|
||||||
dotnet --version 2>/dev/null || printf '%s' "unknown"
|
|
||||||
;;
|
|
||||||
python3)
|
|
||||||
python3 --version 2>/dev/null || printf '%s' "unknown"
|
|
||||||
;;
|
|
||||||
node)
|
|
||||||
node --version 2>/dev/null || printf '%s' "unknown"
|
|
||||||
;;
|
|
||||||
npm)
|
|
||||||
npm --version 2>/dev/null || printf '%s' "unknown"
|
|
||||||
;;
|
|
||||||
bun)
|
|
||||||
bun --version 2>/dev/null || printf '%s' "unknown"
|
|
||||||
;;
|
|
||||||
git)
|
|
||||||
git --version 2>/dev/null || printf '%s' "unknown"
|
|
||||||
;;
|
|
||||||
rg)
|
|
||||||
rg --version 2>/dev/null | head -n 1 || printf '%s' "unknown"
|
|
||||||
;;
|
|
||||||
jq)
|
|
||||||
jq --version 2>/dev/null || printf '%s' "unknown"
|
|
||||||
;;
|
|
||||||
docker)
|
|
||||||
docker --version 2>/dev/null || printf '%s' "unknown"
|
|
||||||
;;
|
|
||||||
bash)
|
|
||||||
bash --version 2>/dev/null | head -n 1 || printf '%s' "unknown"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
"${tool}" --version 2>/dev/null | head -n 1 || printf '%s' "unknown"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
python_package_version() {
|
|
||||||
local package_name="$1"
|
|
||||||
|
|
||||||
python3 - "${package_name}" <<'PY'
|
|
||||||
from importlib import metadata
|
|
||||||
import sys
|
|
||||||
|
|
||||||
package_name = sys.argv[1]
|
|
||||||
|
|
||||||
try:
|
|
||||||
print(metadata.version(package_name))
|
|
||||||
except metadata.PackageNotFoundError:
|
|
||||||
print("not-installed")
|
|
||||||
PY
|
|
||||||
}
|
|
||||||
|
|
||||||
python_package_installed() {
|
|
||||||
local package_name="$1"
|
|
||||||
local version
|
|
||||||
|
|
||||||
version="$(python_package_version "${package_name}")"
|
|
||||||
|
|
||||||
if [[ "${version}" == "not-installed" ]]; then
|
|
||||||
printf 'false'
|
|
||||||
else
|
|
||||||
printf 'true'
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
read_os_release() {
|
|
||||||
local key="$1"
|
|
||||||
|
|
||||||
python3 - "$key" <<'PY'
|
|
||||||
import pathlib
|
|
||||||
import sys
|
|
||||||
|
|
||||||
target_key = sys.argv[1]
|
|
||||||
values = {}
|
|
||||||
for line in pathlib.Path("/etc/os-release").read_text(encoding="utf-8").splitlines():
|
|
||||||
if "=" not in line:
|
|
||||||
continue
|
|
||||||
key, value = line.split("=", 1)
|
|
||||||
values[key] = value.strip().strip('"')
|
|
||||||
|
|
||||||
print(values.get(target_key, "unknown"))
|
|
||||||
PY
|
|
||||||
}
|
|
||||||
|
|
||||||
collect_inventory() {
|
|
||||||
local os_name distro version_id kernel shell_name wsl_enabled wsl_version timestamp
|
|
||||||
|
|
||||||
os_name="$(uname -s)"
|
|
||||||
distro="$(read_os_release PRETTY_NAME)"
|
|
||||||
version_id="$(read_os_release VERSION_ID)"
|
|
||||||
kernel="$(uname -r)"
|
|
||||||
shell_name="$(basename "${SHELL:-bash}")"
|
|
||||||
timestamp="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
|
||||||
|
|
||||||
if grep -qi microsoft /proc/version 2>/dev/null; then
|
|
||||||
wsl_enabled="true"
|
|
||||||
else
|
|
||||||
wsl_enabled="false"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if command -v wslinfo >/dev/null 2>&1; then
|
|
||||||
wsl_version="$(wslinfo --wsl-version 2>/dev/null || printf '%s' "unknown")"
|
|
||||||
else
|
|
||||||
wsl_version="unknown"
|
|
||||||
fi
|
|
||||||
|
|
||||||
cat <<EOF
|
|
||||||
schema_version: 1
|
|
||||||
generated_at_utc: "${timestamp}"
|
|
||||||
generator: "scripts/collect-dev-environment.sh"
|
|
||||||
|
|
||||||
platform:
|
|
||||||
os: "${os_name}"
|
|
||||||
distro: "${distro}"
|
|
||||||
version: "${version_id}"
|
|
||||||
kernel: "${kernel}"
|
|
||||||
wsl: ${wsl_enabled}
|
|
||||||
wsl_version: "${wsl_version}"
|
|
||||||
shell: "${shell_name}"
|
|
||||||
|
|
||||||
required_runtimes:
|
|
||||||
dotnet:
|
|
||||||
installed: $(command_installed dotnet)
|
|
||||||
version: "$(command_version dotnet)"
|
|
||||||
path: "$(command_path dotnet)"
|
|
||||||
purpose: "Builds and tests the GFramework solution."
|
|
||||||
python3:
|
|
||||||
installed: $(command_installed python3)
|
|
||||||
version: "$(command_version python3)"
|
|
||||||
path: "$(command_path python3)"
|
|
||||||
purpose: "Runs local automation and environment collection scripts."
|
|
||||||
node:
|
|
||||||
installed: $(command_installed node)
|
|
||||||
version: "$(command_version node)"
|
|
||||||
path: "$(command_path node)"
|
|
||||||
purpose: "Provides the JavaScript runtime used by docs tooling."
|
|
||||||
bun:
|
|
||||||
installed: $(command_installed bun)
|
|
||||||
version: "$(command_version bun)"
|
|
||||||
path: "$(command_path bun)"
|
|
||||||
purpose: "Installs and previews the VitePress documentation site."
|
|
||||||
|
|
||||||
required_tools:
|
|
||||||
git:
|
|
||||||
installed: $(command_installed git)
|
|
||||||
version: "$(command_version git)"
|
|
||||||
path: "$(command_path git)"
|
|
||||||
purpose: "Source control and patch review."
|
|
||||||
bash:
|
|
||||||
installed: $(command_installed bash)
|
|
||||||
version: "$(command_version bash)"
|
|
||||||
path: "$(command_path bash)"
|
|
||||||
purpose: "Executes repository scripts and shell automation."
|
|
||||||
rg:
|
|
||||||
installed: $(command_installed rg)
|
|
||||||
version: "$(command_version rg)"
|
|
||||||
path: "$(command_path rg)"
|
|
||||||
purpose: "Fast text search across the repository."
|
|
||||||
jq:
|
|
||||||
installed: $(command_installed jq)
|
|
||||||
version: "$(command_version jq)"
|
|
||||||
path: "$(command_path jq)"
|
|
||||||
purpose: "Inspecting and transforming JSON outputs."
|
|
||||||
|
|
||||||
project_tools:
|
|
||||||
docker:
|
|
||||||
installed: $(command_installed docker)
|
|
||||||
version: "$(command_version docker)"
|
|
||||||
path: "$(command_path docker)"
|
|
||||||
purpose: "Runs MegaLinter and other containerized validation tools."
|
|
||||||
|
|
||||||
python_packages:
|
|
||||||
requests:
|
|
||||||
installed: $(python_package_installed requests)
|
|
||||||
version: "$(python_package_version requests)"
|
|
||||||
purpose: "Simple HTTP calls in local helper scripts."
|
|
||||||
rich:
|
|
||||||
installed: $(python_package_installed rich)
|
|
||||||
version: "$(python_package_version rich)"
|
|
||||||
purpose: "Readable CLI output for local Python helpers."
|
|
||||||
openai:
|
|
||||||
installed: $(python_package_installed openai)
|
|
||||||
version: "$(python_package_version openai)"
|
|
||||||
purpose: "Optional scripted access to OpenAI APIs."
|
|
||||||
tiktoken:
|
|
||||||
installed: $(python_package_installed tiktoken)
|
|
||||||
version: "$(python_package_version tiktoken)"
|
|
||||||
purpose: "Optional token counting for prompt and context inspection."
|
|
||||||
pydantic:
|
|
||||||
installed: $(python_package_installed pydantic)
|
|
||||||
version: "$(python_package_version pydantic)"
|
|
||||||
purpose: "Optional typed config and schema validation for helper scripts."
|
|
||||||
pytest:
|
|
||||||
installed: $(python_package_installed pytest)
|
|
||||||
version: "$(python_package_version pytest)"
|
|
||||||
purpose: "Optional lightweight testing for Python helper scripts."
|
|
||||||
EOF
|
|
||||||
}
|
|
||||||
|
|
||||||
ensure_supported_mode
|
|
||||||
|
|
||||||
if [[ "${MODE}" == "--write" ]]; then
|
|
||||||
mkdir -p "$(dirname "${OUTPUT_PATH}")"
|
|
||||||
collect_inventory > "${OUTPUT_PATH}"
|
|
||||||
printf 'Wrote %s\n' "${OUTPUT_PATH}"
|
|
||||||
else
|
|
||||||
collect_inventory
|
|
||||||
fi
|
|
||||||
@ -1,236 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
|
||||||
ROOT_DIR = Path(__file__).resolve().parent.parent
|
|
||||||
RAW_PATH = ROOT_DIR / ".ai" / "environment" / "tools.raw.yaml"
|
|
||||||
AI_PATH = ROOT_DIR / ".ai" / "environment" / "tools.ai.yaml"
|
|
||||||
|
|
||||||
|
|
||||||
def parse_scalar(value: str) -> Any:
|
|
||||||
if value == "true":
|
|
||||||
return True
|
|
||||||
if value == "false":
|
|
||||||
return False
|
|
||||||
if value.startswith('"') and value.endswith('"'):
|
|
||||||
return value[1:-1]
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def parse_simple_yaml(path: Path) -> dict[str, Any]:
|
|
||||||
root: dict[str, Any] = {}
|
|
||||||
stack: list[tuple[int, dict[str, Any]]] = [(-1, root)]
|
|
||||||
|
|
||||||
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
|
||||||
if not raw_line.strip():
|
|
||||||
continue
|
|
||||||
if raw_line.lstrip().startswith("#"):
|
|
||||||
continue
|
|
||||||
|
|
||||||
indent = len(raw_line) - len(raw_line.lstrip(" "))
|
|
||||||
key, _, tail = raw_line.strip().partition(":")
|
|
||||||
|
|
||||||
while len(stack) > 1 and indent <= stack[-1][0]:
|
|
||||||
stack.pop()
|
|
||||||
|
|
||||||
current = stack[-1][1]
|
|
||||||
value = tail.strip()
|
|
||||||
|
|
||||||
if value == "":
|
|
||||||
child: dict[str, Any] = {}
|
|
||||||
current[key] = child
|
|
||||||
stack.append((indent, child))
|
|
||||||
continue
|
|
||||||
|
|
||||||
current[key] = parse_scalar(value)
|
|
||||||
|
|
||||||
return root
|
|
||||||
|
|
||||||
|
|
||||||
def bool_value(data: dict[str, Any], *keys: str) -> bool:
|
|
||||||
current: Any = data
|
|
||||||
for key in keys:
|
|
||||||
current = current[key]
|
|
||||||
return bool(current)
|
|
||||||
|
|
||||||
|
|
||||||
def string_value(data: dict[str, Any], *keys: str) -> str:
|
|
||||||
current: Any = data
|
|
||||||
for key in keys:
|
|
||||||
current = current[key]
|
|
||||||
return str(current)
|
|
||||||
|
|
||||||
|
|
||||||
def choose(preferred: str | None, fallback: str | None) -> str:
|
|
||||||
if preferred:
|
|
||||||
return preferred
|
|
||||||
return fallback or "unavailable"
|
|
||||||
|
|
||||||
|
|
||||||
def available_tool(raw: dict[str, Any], section: str, name: str) -> bool:
|
|
||||||
return bool_value(raw, section, name, "installed")
|
|
||||||
|
|
||||||
|
|
||||||
def select_tool(
|
|
||||||
use_for: str,
|
|
||||||
preferred: str | None,
|
|
||||||
fallback: str | None,
|
|
||||||
) -> dict[str, str]:
|
|
||||||
return {
|
|
||||||
"preferred": choose(preferred, fallback),
|
|
||||||
"fallback": fallback or "unavailable",
|
|
||||||
"use_for": use_for,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def build_ai_inventory(raw: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
has_python = available_tool(raw, "required_runtimes", "python3")
|
|
||||||
has_node = available_tool(raw, "required_runtimes", "node")
|
|
||||||
has_bun = available_tool(raw, "required_runtimes", "bun")
|
|
||||||
has_dotnet = available_tool(raw, "required_runtimes", "dotnet")
|
|
||||||
has_rg = available_tool(raw, "required_tools", "rg")
|
|
||||||
has_jq = available_tool(raw, "required_tools", "jq")
|
|
||||||
has_bash = available_tool(raw, "required_tools", "bash")
|
|
||||||
has_docker = available_tool(raw, "project_tools", "docker")
|
|
||||||
|
|
||||||
search = select_tool(
|
|
||||||
use_for="Repository text search.",
|
|
||||||
preferred="rg" if has_rg else None,
|
|
||||||
fallback="grep",
|
|
||||||
)
|
|
||||||
json = select_tool(
|
|
||||||
use_for="Inspecting or transforming JSON command output.",
|
|
||||||
preferred="jq" if has_jq else None,
|
|
||||||
fallback="python3" if has_python else None,
|
|
||||||
)
|
|
||||||
scripting = select_tool(
|
|
||||||
use_for="Non-trivial local automation and helper scripts.",
|
|
||||||
preferred="python3" if has_python else None,
|
|
||||||
fallback="bash" if has_bash else None,
|
|
||||||
)
|
|
||||||
shell = select_tool(
|
|
||||||
use_for="Repository shell scripts and command execution.",
|
|
||||||
preferred="bash" if has_bash else None,
|
|
||||||
fallback="sh",
|
|
||||||
)
|
|
||||||
docs = select_tool(
|
|
||||||
use_for="Installing and previewing the docs site.",
|
|
||||||
preferred="bun" if has_bun else None,
|
|
||||||
fallback="npm" if has_node else None,
|
|
||||||
)
|
|
||||||
build = select_tool(
|
|
||||||
use_for="Build, test, restore, and solution validation.",
|
|
||||||
preferred="dotnet" if has_dotnet else None,
|
|
||||||
fallback=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
if bool_value(raw, "platform", "wsl"):
|
|
||||||
platform_family = "wsl-linux"
|
|
||||||
else:
|
|
||||||
platform_family = string_value(raw, "platform", "os").lower()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"schema_version": 1,
|
|
||||||
"generated_at_utc": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
||||||
"generated_from": ".ai/environment/tools.raw.yaml",
|
|
||||||
"generator": "scripts/generate-ai-environment.py",
|
|
||||||
"platform": {
|
|
||||||
"family": platform_family,
|
|
||||||
"os": string_value(raw, "platform", "os"),
|
|
||||||
"distro": string_value(raw, "platform", "distro"),
|
|
||||||
"shell": string_value(raw, "platform", "shell"),
|
|
||||||
},
|
|
||||||
"capabilities": {
|
|
||||||
"dotnet": has_dotnet,
|
|
||||||
"python": has_python,
|
|
||||||
"node": has_node,
|
|
||||||
"bun": has_bun,
|
|
||||||
"docker": has_docker,
|
|
||||||
"fast_search": has_rg,
|
|
||||||
"json_cli": has_jq,
|
|
||||||
},
|
|
||||||
"tool_selection": {
|
|
||||||
"search": search,
|
|
||||||
"json": json,
|
|
||||||
"shell": shell,
|
|
||||||
"scripting": scripting,
|
|
||||||
"docs_package_manager": docs,
|
|
||||||
"build_and_test": build,
|
|
||||||
},
|
|
||||||
"python": {
|
|
||||||
"available": has_python,
|
|
||||||
"helper_packages": {
|
|
||||||
"requests": bool_value(raw, "python_packages", "requests", "installed"),
|
|
||||||
"rich": bool_value(raw, "python_packages", "rich", "installed"),
|
|
||||||
"openai": bool_value(raw, "python_packages", "openai", "installed"),
|
|
||||||
"tiktoken": bool_value(raw, "python_packages", "tiktoken", "installed"),
|
|
||||||
"pydantic": bool_value(raw, "python_packages", "pydantic", "installed"),
|
|
||||||
"pytest": bool_value(raw, "python_packages", "pytest", "installed"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"preferences": {
|
|
||||||
"prefer_project_listed_tools": True,
|
|
||||||
"prefer_python_for_non_trivial_automation": has_python,
|
|
||||||
"avoid_unlisted_system_tools": True,
|
|
||||||
},
|
|
||||||
"rules": [
|
|
||||||
"Use rg instead of grep for repository search when rg is available.",
|
|
||||||
"Use jq for JSON inspection; fall back to python3 if jq is unavailable.",
|
|
||||||
"Prefer python3 over complex bash for non-trivial scripting when python3 is available.",
|
|
||||||
"Use bun for docs preview workflows when bun is available; otherwise fall back to npm.",
|
|
||||||
"Use dotnet for repository build and test workflows.",
|
|
||||||
"Do not assume unrelated system tools are part of the supported project environment.",
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def emit_yaml(value: Any, indent: int = 0) -> list[str]:
|
|
||||||
prefix = " " * indent
|
|
||||||
|
|
||||||
if isinstance(value, dict):
|
|
||||||
lines: list[str] = []
|
|
||||||
for key, nested in value.items():
|
|
||||||
if isinstance(nested, (dict, list)):
|
|
||||||
lines.append(f"{prefix}{key}:")
|
|
||||||
lines.extend(emit_yaml(nested, indent + 2))
|
|
||||||
else:
|
|
||||||
lines.append(f"{prefix}{key}: {format_scalar(nested)}")
|
|
||||||
return lines
|
|
||||||
|
|
||||||
if isinstance(value, list):
|
|
||||||
lines = []
|
|
||||||
for item in value:
|
|
||||||
if isinstance(item, (dict, list)):
|
|
||||||
lines.append(f"{prefix}-")
|
|
||||||
lines.extend(emit_yaml(item, indent + 2))
|
|
||||||
else:
|
|
||||||
lines.append(f"{prefix}- {format_scalar(item)}")
|
|
||||||
return lines
|
|
||||||
|
|
||||||
return [f"{prefix}{format_scalar(value)}"]
|
|
||||||
|
|
||||||
|
|
||||||
def format_scalar(value: Any) -> str:
|
|
||||||
if isinstance(value, bool):
|
|
||||||
return "true" if value else "false"
|
|
||||||
if isinstance(value, int):
|
|
||||||
return str(value)
|
|
||||||
text = str(value).replace('"', '\\"')
|
|
||||||
return f'"{text}"'
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
raw = parse_simple_yaml(RAW_PATH)
|
|
||||||
ai_inventory = build_ai_inventory(raw)
|
|
||||||
AI_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
AI_PATH.write_text("\n".join(emit_yaml(ai_inventory)) + "\n", encoding="utf-8")
|
|
||||||
print(f"Wrote {AI_PATH}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
Loading…
x
Reference in New Issue
Block a user