mirror of
https://github.com/prosolis/gogobee.git
synced 2026-09-14 02:41:09 +00:00
The engine narrates every fight, trap and haul to one Matrix DM and then discards the shape underneath it. This records that shape as it happens so Pete can retell the run to somebody who wasn't in the room. Beats ride the roster ticker (one extra request per two minutes, not one per room) but on their own table, never pete_emit_queue: they are high-volume and low-stakes, and a chatty run must not be able to spend the retry budget a death dispatch depends on. Unlike the snapshots they ARE retried — a dropped beat is a hole in a story, not a stale number the next tick corrects. Recording is a leaf everywhere it is called. If a beat can't be written the walk carries on exactly as it did before this existed. Privacy is stricter here than on the board. The board omits an opted-out player from a snapshot; a liveblog would be an account of where they are and what is happening to them, so their beats never leave the box at all — and a run whose owner can't be resolved is refused rather than published. Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
511 lines
18 KiB
Go
511 lines
18 KiB
Go
package plugin
|
||
|
||
import (
|
||
"fmt"
|
||
"hash/fnv"
|
||
"log/slog"
|
||
"math/rand/v2"
|
||
"strings"
|
||
|
||
"maunium.net/go/mautrix/id"
|
||
)
|
||
|
||
// Phase 11 D1e — combat / trap / boss resolution per zone room.
|
||
//
|
||
// D1c shipped the !zone command surface and D1d wired TwinBee narration
|
||
// + mood. D1e fills in the "what actually happens when you !zone advance
|
||
// into a fight" — spawning enemies from the zone roster, running them
|
||
// through the existing combat engine with the player's full D&D layer,
|
||
// awarding XP per kill and zone loot on boss defeat, and ending the run
|
||
// (mood penalty + DMPlayerDeath narration) if the player goes down.
|
||
//
|
||
// Per-room behavior:
|
||
// Entry → no combat (already narrated on enter)
|
||
// Exploration → SpawnWeight-biased pick from non-elite roster, combat
|
||
// Trap → flat % MaxHP nick + trap_detected/trap_tripped flavor
|
||
// Elite → pick from elite-only roster (falls back to non-elite),
|
||
// combat with +tier scaling on stats
|
||
// Boss → zone.Boss bestiary entry, combat + zone Loot table on win
|
||
//
|
||
// Boss fights remain one-shot SimulateCombat in D1e — the per-turn
|
||
// !attack/!cast/!dodge interface from gogobee_dungeon_zones.md §4 is
|
||
// scoped to a later sub-phase. The roster, stat blocks, and mood/loot
|
||
// scaffolding land here so the run loop is end-to-end playable.
|
||
|
||
// ── Enemy selection ─────────────────────────────────────────────────────────
|
||
|
||
// pickZoneEnemy picks one enemy from the zone roster for the given room
|
||
// kind. Selection is deterministic on (run, room) so re-running the same
|
||
// room (idempotent reads of !zone advance after it's already resolved)
|
||
// yields the same line of fate. isElite filters to entries flagged elite,
|
||
// falling back to the full roster if no elite entries exist.
|
||
//
|
||
// SpawnWeight (1..10) biases the cumulative wheel; weight 0 falls back to 5.
|
||
func pickZoneEnemy(zone ZoneDefinition, runID string, roomIdx int, isElite bool) (DnDMonsterTemplate, bool) {
|
||
pool := make([]ZoneEnemy, 0, len(zone.Enemies))
|
||
if isElite {
|
||
for _, e := range zone.Enemies {
|
||
if e.IsElite {
|
||
pool = append(pool, e)
|
||
}
|
||
}
|
||
}
|
||
if len(pool) == 0 {
|
||
for _, e := range zone.Enemies {
|
||
if !isElite && e.IsElite {
|
||
continue
|
||
}
|
||
pool = append(pool, e)
|
||
}
|
||
}
|
||
if len(pool) == 0 {
|
||
return DnDMonsterTemplate{}, false
|
||
}
|
||
|
||
total := 0
|
||
for _, e := range pool {
|
||
w := e.SpawnWeight
|
||
if w <= 0 {
|
||
w = 5
|
||
}
|
||
total += w
|
||
}
|
||
roll := int(zoneSelectorHash(runID, roomIdx) % uint64(total))
|
||
cum := 0
|
||
var chosen ZoneEnemy
|
||
for _, e := range pool {
|
||
w := e.SpawnWeight
|
||
if w <= 0 {
|
||
w = 5
|
||
}
|
||
cum += w
|
||
if roll < cum {
|
||
chosen = e
|
||
break
|
||
}
|
||
}
|
||
tmpl, ok := dndBestiary[chosen.BestiaryID]
|
||
return tmpl, ok
|
||
}
|
||
|
||
// zoneSelectorHash — same family as pickLineDeterministic; kept separate
|
||
// so a roster pick and a narration pick on the same (run, room) don't
|
||
// collide on the same hash output.
|
||
func zoneSelectorHash(runID string, roomIdx int) uint64 {
|
||
h := fnv.New64a()
|
||
h.Write([]byte("enemy:"))
|
||
h.Write([]byte(runID))
|
||
var sb [8]byte
|
||
for i := 0; i < 8; i++ {
|
||
sb[i] = byte(roomIdx >> (8 * i))
|
||
}
|
||
h.Write(sb[:])
|
||
return h.Sum64()
|
||
}
|
||
|
||
// ── Combat runner ───────────────────────────────────────────────────────────
|
||
|
||
// runZoneCombat sets up a Combatant pair from the player's full D&D
|
||
// layer + a bestiary monster, runs SimulateCombat with the given phase
|
||
// budget (nil = dungeonCombatPhases), and persists post-combat side
|
||
// effects (HP, subclass state, XP).
|
||
//
|
||
// dmMood (0–100) drives the DM's combat tilt: Effusive softens monster
|
||
// Attack and gives the player +InitiativeBias; Hostile sharpens monster
|
||
// Attack and slows the player. Pass 50 (neutral) when no run context.
|
||
//
|
||
// Returns the engine result so the caller can branch on PlayerWon and
|
||
// fold combat events into the per-room narration.
|
||
// runZoneCombat resolves a zone encounter for exactly one player. It is the
|
||
// one-seat case of runZoneCombatRoster, and the entry point for every fight
|
||
// that is not on an expedition — the arena's encounter bouts included, which is
|
||
// why the roster is not resolved here: an arena fighter must never drag their
|
||
// party into the ring.
|
||
func (p *AdventurePlugin) runZoneCombat(
|
||
userID id.UserID,
|
||
monster DnDMonsterTemplate,
|
||
tier int,
|
||
phases []CombatPhase,
|
||
dmMood int,
|
||
) (CombatResult, error) {
|
||
res, _, err := p.runZoneCombatRoster([]id.UserID{userID}, monster, tier, phases, dmMood)
|
||
if err != nil {
|
||
return CombatResult{}, err
|
||
}
|
||
return res.Seats[0], nil
|
||
}
|
||
|
||
// zoneCombatXP — CR-weighted XP per kill, with a tier-based floor so
|
||
// low-CR roster fillers in a high-tier zone still feel worthwhile.
|
||
// Loss grants the standard dndLossXPFraction.
|
||
func zoneCombatXP(result CombatResult, cr float32, tier int) int {
|
||
if tier < 1 {
|
||
tier = 1
|
||
}
|
||
// CR 0.25 → 25, CR 1 → 100, CR 3 → 300, CR 5 → 500. Caps at CR 20.
|
||
base := int(cr * 100)
|
||
floor := dndDungeonXPPerTier * tier // T1=15, T5=75
|
||
if base < floor {
|
||
base = floor
|
||
}
|
||
if !result.PlayerWon {
|
||
return int(float64(base) * dndLossXPFraction)
|
||
}
|
||
if result.NearDeath {
|
||
return int(float64(base) * dndNearDeathXPBonus)
|
||
}
|
||
return base
|
||
}
|
||
|
||
// ── Mood event scanning ─────────────────────────────────────────────────────
|
||
|
||
// scanMoodEventsFromCombat walks combat events and applies §3.2 mood
|
||
// triggers for nat-20s and nat-1s. Returns the count of each so the
|
||
// caller can include the deltas in the rendered status line.
|
||
func scanMoodEventsFromCombat(runID string, result CombatResult) (nat20s, nat1s int) {
|
||
return scanMoodEventsFromEvents(runID, result.Events)
|
||
}
|
||
|
||
// scanMoodEventsFromEvents is the event-slice core of scanMoodEventsFromCombat
|
||
// — used by the turn-based close-out, which has a CombatEvent log (the
|
||
// session's accumulated TurnLog) rather than a one-shot CombatResult.
|
||
func scanMoodEventsFromEvents(runID string, events []CombatEvent) (nat20s, nat1s int) {
|
||
for _, ev := range events {
|
||
if ev.Roll == 20 && ev.Actor == "player" {
|
||
nat20s++
|
||
}
|
||
if ev.Roll == 1 && ev.Actor == "player" {
|
||
nat1s++
|
||
}
|
||
}
|
||
for i := 0; i < nat20s; i++ {
|
||
if _, err := applyMoodEvent(runID, MoodEventNat20); err != nil {
|
||
slog.Error("zone: applyMoodEvent nat20", "run", runID, "err", err)
|
||
break
|
||
}
|
||
}
|
||
for i := 0; i < nat1s; i++ {
|
||
if _, err := applyMoodEvent(runID, MoodEventNat1); err != nil {
|
||
slog.Error("zone: applyMoodEvent nat1", "run", runID, "err", err)
|
||
break
|
||
}
|
||
}
|
||
return nat20s, nat1s
|
||
}
|
||
|
||
// ── Trap rooms ──────────────────────────────────────────────────────────────
|
||
|
||
// resolveTrapRoom picks a trap from the zone's tier catalog (D2a),
|
||
// rolls a detection skill check (Perception/Investigation, per trap),
|
||
// and either narrates a clean spot or applies the trap's damage dice.
|
||
// Damage is capped so a single trap can't outright KO the player.
|
||
//
|
||
// Higher-tier zones currently fall through to the pre-D2a flat-percent
|
||
// nick — D3a/D4a will add Tier 2+ trap catalogs.
|
||
// resolveSecretRoom resolves a NodeKindSecret room as a no-combat treasure
|
||
// cache (N5/D4). Finding the secret — via the perception/stat check on the fork
|
||
// that hangs it, or a cross-zone key — is the reward: a guaranteed journal page
|
||
// (the Hollow King's fragments gather in hidden rooms), a LootBias-weighted
|
||
// treasure roll, a guaranteed zone-tier consumable cache, and, for the two
|
||
// key-bearing secrets, a cross-zone key. No enemy, no threat.
|
||
//
|
||
// LootBias has been authored on every secret node since the branching-zones
|
||
// phase and was dead code until now; it finally scales the treasure weight here.
|
||
func (p *AdventurePlugin) resolveSecretRoom(userID id.UserID, run *DungeonRun, zone ZoneDefinition, node ZoneNode) string {
|
||
var b strings.Builder
|
||
b.WriteString(secretRoomDiscoveryLine(node))
|
||
|
||
var rewards []string
|
||
if line := p.grantJournalPage(userID, nil); line != "" {
|
||
rewards = append(rewards, line)
|
||
}
|
||
if line := p.grantSecretRoomKey(userID, node); line != "" {
|
||
rewards = append(rewards, line)
|
||
}
|
||
|
||
// LootBias-weighted treasure roll. Floors at the elite weight so even the
|
||
// leanest secret out-rolls a standard kill; the richest (abyss 3.0) still
|
||
// rolls below a boss. checkTreasureDrop DMs the discovery itself on a hit,
|
||
// exactly as a combat-room drop does, so it isn't folded into the outcome.
|
||
weight := node.Content.LootBias
|
||
if weight < advTreasureWeightElite {
|
||
weight = advTreasureWeightElite
|
||
}
|
||
p.rollZoneTreasure(userID, zone.ID, weight)
|
||
|
||
// Guaranteed cache so the room always pays out something visible.
|
||
for _, item := range consumableCache(int(zone.Tier), secretRoomCacheCount) {
|
||
it := item
|
||
if line := p.grantZoneItem(userID, &it, "🧪"); line != "" {
|
||
rewards = append(rewards, line)
|
||
beatTreasure(run, it.Name, "cache")
|
||
}
|
||
}
|
||
|
||
if len(rewards) > 0 {
|
||
b.WriteString("\n\n")
|
||
b.WriteString(strings.Join(rewards, "\n"))
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
func (p *AdventurePlugin) resolveTrapRoom(userID id.UserID, run *DungeonRun, zone ZoneDefinition) (damage int, narration string) {
|
||
dndChar, _ := LoadDnDCharacter(userID)
|
||
if dndChar == nil {
|
||
return 0, ""
|
||
}
|
||
trap, ok := pickZoneTrap(zone, run.RunID, run.CurrentRoom)
|
||
if !ok {
|
||
return p.resolveTrapRoomLegacy(userID, run, zone, dndChar)
|
||
}
|
||
return p.applyTrapEffect(userID, run, zone, dndChar, trap)
|
||
}
|
||
|
||
// applyTrapEffect runs the detect roll and damage application for a
|
||
// chosen trap. Split out from resolveTrapRoom so tests can target a
|
||
// specific trap kind without depending on the room-seed selector.
|
||
func (p *AdventurePlugin) applyTrapEffect(userID id.UserID, run *DungeonRun, zone ZoneDefinition, dndChar *DnDCharacter, trap zoneTrapDef) (int, string) {
|
||
detect := performSkillCheck(dndChar, trap.DetectSkill, trap.DetectDC)
|
||
return p.applyTrapEffectWithDetect(userID, run, zone, dndChar, trap, detect)
|
||
}
|
||
|
||
// applyTrapEffectWithDetect is the deterministic core of trap resolution:
|
||
// given a precomputed detection check result, it produces the narration
|
||
// and persists HP loss on a failed detect. Used by applyTrapEffect (which
|
||
// rolls the d20) and by tests (which inject a fixed result).
|
||
func (p *AdventurePlugin) applyTrapEffectWithDetect(
|
||
userID id.UserID,
|
||
run *DungeonRun,
|
||
zone ZoneDefinition,
|
||
dndChar *DnDCharacter,
|
||
trap zoneTrapDef,
|
||
detect SkillCheckResult,
|
||
) (int, string) {
|
||
var b strings.Builder
|
||
if detect.Success {
|
||
// Detected: the TwinBee "you spotted it" line only fires here, so a
|
||
// failed detection no longer contradicts the next line. The mismatch
|
||
// where "Perception roll pays off" preceded a tripped trap was
|
||
// caused by this line firing unconditionally.
|
||
if line := twinBeeLine(zone.ID, DMTrapDetected, run.RunID, narrationCadence(run)); line != "" {
|
||
b.WriteString(line)
|
||
b.WriteString("\n")
|
||
}
|
||
b.WriteString(trapSpottedHeader(trap, detect))
|
||
return 0, b.String()
|
||
}
|
||
|
||
dmg := rollTrapDamage(trap, run.RunID, run.CurrentRoom)
|
||
// Tiefling fiendish heritage — fire traps deal half damage.
|
||
if trap.DamageType == "fire" && dndChar.Race == RaceTiefling {
|
||
dmg = max(1, dmg/2)
|
||
}
|
||
if dmg >= dndChar.HPCurrent {
|
||
dmg = dndChar.HPCurrent - 1
|
||
if dmg < 0 {
|
||
dmg = 0
|
||
}
|
||
}
|
||
if dmg > 0 {
|
||
dndChar.HPCurrent -= dmg
|
||
if err := SaveDnDCharacter(dndChar); err != nil {
|
||
slog.Error("zone: trap HP persist", "user", userID, "err", err)
|
||
}
|
||
}
|
||
// Trip flavor lives in trap.Trigger (mechanism-specific) which the
|
||
// damage header now surfaces. The generic DMTrapTripped pool is no
|
||
// longer pulled here — it mixed dart/ceiling/pit/glyph lines that
|
||
// frequently mismatched the actual trap.
|
||
b.WriteString(trapDamageHeader(trap, dmg, dndChar.HPCurrent, dndChar.HPMax))
|
||
return dmg, b.String()
|
||
}
|
||
|
||
// resolveTrapRoomLegacy is the pre-D2a flat-percent fallback used when a
|
||
// zone tier has no entries in the trap catalog yet (Tier 2+). Removed
|
||
// once D3a/D4a fill in the higher-tier catalogs.
|
||
func (p *AdventurePlugin) resolveTrapRoomLegacy(userID id.UserID, run *DungeonRun, zone ZoneDefinition, dndChar *DnDCharacter) (int, string) {
|
||
tier := int(zone.Tier)
|
||
pct := 0.08 + 0.03*float64(tier-1)
|
||
dmg := int(float64(dndChar.HPMax) * pct)
|
||
if dmg < 1 {
|
||
dmg = 1
|
||
}
|
||
if dmg >= dndChar.HPCurrent {
|
||
dmg = dndChar.HPCurrent - 1
|
||
if dmg < 0 {
|
||
dmg = 0
|
||
}
|
||
}
|
||
dndChar.HPCurrent -= dmg
|
||
if err := SaveDnDCharacter(dndChar); err != nil {
|
||
slog.Error("zone: trap HP persist (legacy)", "user", userID, "err", err)
|
||
}
|
||
// Legacy path always trips — no detection check. Drop the TwinBee pool
|
||
// picks here for the same reason as applyTrapEffectWithDetect: the
|
||
// detected line shouldn't fire when nothing was detected, and the
|
||
// generic tripped pool routinely mismatches the actual trap. Plain
|
||
// damage line carries the result.
|
||
var b strings.Builder
|
||
b.WriteString(fmt.Sprintf("💢 The trap takes **%d HP** (%d/%d remaining).", dmg, dndChar.HPCurrent, dndChar.HPMax))
|
||
return dmg, b.String()
|
||
}
|
||
|
||
// ── Loot ─────────────────────────────────────────────────────────────────────
|
||
|
||
// rollZoneLoot rolls every entry in zone.Loot and returns the IDs that
|
||
// dropped. UniqueAlways entries always drop. Probabilistic entries roll
|
||
// independently. Coin-pattern IDs ("coins_<dice>x<mult>") are expanded
|
||
// to a single "coins" item with rolled value; everything else becomes a
|
||
// treasure-tier inventory item with a tier-derived placeholder value
|
||
// (zone equipment registry wiring is a later content phase).
|
||
func (p *AdventurePlugin) rollZoneLoot(userID id.UserID, run *DungeonRun, zone ZoneDefinition, bossCleared bool) []string {
|
||
rng := rand.New(rand.NewPCG(uint64(zoneSelectorHash(run.RunID, run.CurrentRoom)), 0x100712))
|
||
// DM mood tilts probabilistic drop chances. UniqueAlways entries are
|
||
// untouched — those are story drops, not flavor for the DM to gate.
|
||
moodMult := dmMoodCombatTilt(run.DMMood).LootQualityMod
|
||
if moodMult == 0 {
|
||
moodMult = 1.0
|
||
}
|
||
var granted []string
|
||
for _, entry := range zone.Loot {
|
||
// BossOnly (signature) drops only roll on the true zone-boss / whole-zone
|
||
// clear. rollZoneLoot fires on every region completion of a multi-region
|
||
// zone; without this gate a T6 signature item would get four roll chances
|
||
// per run instead of one at the boss.
|
||
if entry.BossOnly && !bossCleared {
|
||
continue
|
||
}
|
||
if !entry.UniqueAlways {
|
||
chance := entry.DropChance * moodMult
|
||
if rng.Float64() > chance {
|
||
continue
|
||
}
|
||
}
|
||
item, ok := zoneLootToInventory(entry, zone, rng)
|
||
if !ok {
|
||
continue
|
||
}
|
||
if err := addAdvInventoryItem(userID, item); err != nil {
|
||
slog.Error("zone: addAdvInventoryItem", "user", userID, "item", item.Name, "err", err)
|
||
continue
|
||
}
|
||
if err := addLoot(run.RunID, entry.ItemID); err != nil {
|
||
slog.Error("zone: addLoot audit", "user", userID, "item", entry.ItemID, "err", err)
|
||
}
|
||
granted = append(granted, entry.ItemID)
|
||
source := "zone"
|
||
if bossCleared {
|
||
source = "boss"
|
||
}
|
||
beatTreasure(run, item.Name, source)
|
||
}
|
||
return granted
|
||
}
|
||
|
||
// zoneLootToInventory converts a ZoneLootEntry into an AdvItem. Coin
|
||
// entries get a rolled gold value; named items become treasure with a
|
||
// tier-scaled placeholder value. Returns ok=false for entries we should
|
||
// silently skip (none today, but the contract leaves room for future
|
||
// quest-only entries that don't materialize as inventory rows).
|
||
func zoneLootToInventory(entry ZoneLootEntry, zone ZoneDefinition, rng *rand.Rand) (AdvItem, bool) {
|
||
if strings.HasPrefix(entry.ItemID, "coins_") {
|
||
val := rollCoinPattern(entry.ItemID, rng)
|
||
return AdvItem{
|
||
Name: "Coin Pouch",
|
||
Type: "treasure",
|
||
Tier: int(zone.Tier),
|
||
Value: int64(val),
|
||
}, true
|
||
}
|
||
// Registry-backed loot (T6 signature items and any future magic-item drops)
|
||
// materializes as a real equippable magic_item — the SkillSource tag is what
|
||
// the equip path and magicItemEffectFor key off. Plain named entries (legacy
|
||
// zone signature items not in the registry) fall through to treasure below.
|
||
if mi, ok := magicItemRegistry[entry.ItemID]; ok {
|
||
item := magicItemSell(mi)
|
||
item.SkillSource = "magic_item:" + mi.ID
|
||
return item, true
|
||
}
|
||
tier := int(zone.Tier)
|
||
displayName := titleCaseUnderscored(entry.ItemID)
|
||
value := int64(50 * tier * tier) // T1=50, T2=200, T3=450, T4=800, T5=1250
|
||
if entry.UniqueAlways {
|
||
value = int64(100 * tier * tier)
|
||
}
|
||
return AdvItem{
|
||
Name: displayName,
|
||
Type: "treasure",
|
||
Tier: tier,
|
||
Value: value,
|
||
}, true
|
||
}
|
||
|
||
// rollCoinPattern parses "coins_<n>d<sides>x<mult>" — e.g.
|
||
// "coins_2d10x5" → 2d10 × 5. Bad input falls back to a tier-agnostic
|
||
// 25-coin floor so the player still gets *something* and the bug is
|
||
// loud in logs but not run-ending.
|
||
func rollCoinPattern(id string, rng *rand.Rand) int {
|
||
body := strings.TrimPrefix(id, "coins_")
|
||
xParts := strings.SplitN(body, "x", 2)
|
||
if len(xParts) != 2 {
|
||
slog.Warn("zone: bad coin pattern (no x)", "id", id)
|
||
return 25
|
||
}
|
||
mult, err := atoi(xParts[1])
|
||
if err != nil || mult <= 0 {
|
||
slog.Warn("zone: bad coin pattern mult", "id", id)
|
||
return 25
|
||
}
|
||
dice := strings.SplitN(xParts[0], "d", 2)
|
||
if len(dice) != 2 {
|
||
slog.Warn("zone: bad coin pattern dice", "id", id)
|
||
return 25
|
||
}
|
||
n, err := atoi(dice[0])
|
||
if err != nil || n <= 0 {
|
||
slog.Warn("zone: bad coin pattern n", "id", id)
|
||
return 25
|
||
}
|
||
sides, err := atoi(dice[1])
|
||
if err != nil || sides <= 0 {
|
||
slog.Warn("zone: bad coin pattern sides", "id", id)
|
||
return 25
|
||
}
|
||
roll := 0
|
||
for i := 0; i < n; i++ {
|
||
roll += rng.IntN(sides) + 1
|
||
}
|
||
return roll * mult
|
||
}
|
||
|
||
// atoi — local strconv-free parser to avoid a single-call import.
|
||
func atoi(s string) (int, error) {
|
||
if s == "" {
|
||
return 0, fmt.Errorf("empty")
|
||
}
|
||
n := 0
|
||
for _, r := range s {
|
||
if r < '0' || r > '9' {
|
||
return 0, fmt.Errorf("non-digit %q", r)
|
||
}
|
||
n = n*10 + int(r-'0')
|
||
}
|
||
return n, nil
|
||
}
|
||
|
||
// titleCaseUnderscored renders "wpn_handaxe_+1" → "Wpn Handaxe +1".
|
||
// Pure ASCII; no Unicode handling needed for our zone-loot ID space.
|
||
func titleCaseUnderscored(id string) string {
|
||
parts := strings.Split(id, "_")
|
||
for i, p := range parts {
|
||
if p == "" {
|
||
continue
|
||
}
|
||
c0 := p[0]
|
||
if c0 >= 'a' && c0 <= 'z' {
|
||
parts[i] = string(c0-32) + p[1:]
|
||
}
|
||
}
|
||
return strings.Join(parts, " ")
|
||
}
|