v0.1.9: 百年报告 & 发布之窗(报告/迁移/阵型/冬祷/哨兵)
- 百年族史报告:buildReport 纯函数(人口/声望/战力三线、死因榜、突破密度、 大比战绩、代际长度带)+ 春秋原 SVG 折线视图 + 自动措辞结语 - 存档迁移管线 v2:migrate 链(v1→v2)+ 未来版本拒载(TOO_NEW)+ 导出信封 带 appId/schemaVersion + 导入全链路迁移校验;saveState 自动写 CURRENT_SCHEMA - 战斗阵型:锋阵(+10%攻)/雁阵(御5%+溃伤-15%)/蛇阵(溃伤-30%),三处出战接入 (遣队/大比/劫掠)点将面板下拉 + 战报首行标阵 - 冬至岁祷:每年十一月三选(观星+3声望/祈福全族修为/问卜吉凶) - 发布准备:scripts/verify-release.mjs 验收哨兵(npm run verify/verify:package) + README 全面重写(架构/守护/版本沿革);版本号三处归一 - 测试 861→881(报告四例/迁移五链/阵型六则含胜率统计/冬祷四则); 金钟罩三指纹零漂移;typecheck/build 通过
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
import { GameState } from '../types/domain'
|
||||
|
||||
export interface LegacyReport {
|
||||
years: number[]
|
||||
population: number[]
|
||||
reputation: number[]
|
||||
power: number[]
|
||||
deathCauses: Record<string, number>
|
||||
breakthroughs: { year: number; count: number }[]
|
||||
tourneys: { year: number; rank: number }[]
|
||||
genSpan: { gen: number; from: number; to: number }[]
|
||||
totalYears: number
|
||||
}
|
||||
|
||||
export function buildReport(s: GameState): LegacyReport {
|
||||
const reports = s.yearlyReports.slice().sort((a, b) => a.year - b.year)
|
||||
const years = reports.map((r) => r.year)
|
||||
const population = reports.map((r) => r.births - r.deaths)
|
||||
const reputation = reports.map((r) => r.rep)
|
||||
const power = reports.map((r) => r.power)
|
||||
|
||||
const deathCauses: Record<string, number> = {}
|
||||
for (const e of s.chronicle) {
|
||||
if (e.category !== 'death') continue
|
||||
const cause = (e.text.match(/(寿元将尽|幼夭|伤势不治|突破走火|渡劫陨落|战殁|云游飞升)/)?.[0] ?? e.text.split(',')[0] ?? '其他').slice(0, 14)
|
||||
deathCauses[cause] = (deathCauses[cause] ?? 0) + 1
|
||||
}
|
||||
|
||||
const btCount = new Map<number, number>()
|
||||
for (const e of s.chronicle) {
|
||||
if (e.category === 'breakthrough') btCount.set(e.year, (btCount.get(e.year) ?? 0) + 1)
|
||||
}
|
||||
const breakthroughs = [...btCount.entries()].map(([year, count]) => ({ year, count })).sort((a, b) => a.year - b.year)
|
||||
|
||||
const members = Object.values(s.members)
|
||||
const genSpan: LegacyReport['genSpan'] = []
|
||||
const byGen = new Map<number, { min: number; max: number }>()
|
||||
for (const c of members) {
|
||||
const span = byGen.get(c.generation) ?? { min: c.bornYear, max: c.bornYear }
|
||||
span.min = Math.min(span.min, c.bornYear)
|
||||
span.max = Math.max(span.max, c.deathYear ?? s.year)
|
||||
byGen.set(c.generation, span)
|
||||
}
|
||||
for (const [gen, span] of [...byGen.entries()].sort((a, b) => a[0] - b[0])) {
|
||||
genSpan.push({ gen, from: span.min, to: span.max })
|
||||
}
|
||||
|
||||
return {
|
||||
years,
|
||||
population,
|
||||
reputation,
|
||||
power,
|
||||
deathCauses,
|
||||
breakthroughs,
|
||||
tourneys: s.stats.tourneyHistory.slice(),
|
||||
genSpan,
|
||||
totalYears: s.year
|
||||
}
|
||||
}
|
||||
|
||||
/** 依据数据自动措辞的族史结语 */
|
||||
export function verdictLine(r: LegacyReport): string {
|
||||
if (r.years.length < 4) return '岁月尚浅,家族仍在晨雾中前行。'
|
||||
const finalRep = r.reputation[r.reputation.length - 1]
|
||||
const peakRep = Math.max(...r.reputation)
|
||||
const peakIndex = r.reputation.indexOf(peakRep)
|
||||
const peakPower = Math.max(...r.power)
|
||||
const btSum = r.breakthroughs.reduce((a, b) => a + b.count, 0)
|
||||
const mainCause = Object.entries(r.deathCauses).sort((a, b) => b[1] - a[1])[0]
|
||||
|
||||
const lines: string[] = []
|
||||
if (peakRep >= 60) lines.push(`家族声望曾至 ${peakRep} 之巅(第${peakIndex + 1}年),四方来贺。`)
|
||||
else if (finalRep < 0) lines.push('晚境声名凋零,门可罗雀。')
|
||||
else lines.push('不显山露水,然香火未冷。')
|
||||
lines.push(`战力峰值 ${peakPower},共记突破 ${btSum} 次。`)
|
||||
if (mainCause && mainCause[1] > 0) lines.push(`族中弃世者多缘「${mainCause[0]}」。`)
|
||||
const best = r.tourneys.reduce((m, t) => (m === null || t.rank < m.rank ? t : m), null as { year: number; rank: number } | null)
|
||||
if (best) lines.push(`太虚大比最佳战果:第${best.rank}名(${best.year}年)。`)
|
||||
return lines.join('')
|
||||
}
|
||||
@@ -42,6 +42,7 @@ export interface EffectDef {
|
||||
addTech?: string
|
||||
feisheng?: { stay: boolean }
|
||||
tournament?: boolean
|
||||
formation?: string
|
||||
apprentice?: { build: boolean }
|
||||
trib?: { memberId: string; mode: 'rash' | 'guard' | 'delay' }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
export type FormationId = 'vanguard' | 'echelon' | 'serpent'
|
||||
|
||||
export interface FormationDef {
|
||||
id: FormationId
|
||||
name: string
|
||||
icon: string
|
||||
desc: string
|
||||
atk: number
|
||||
def: number
|
||||
retreatWound: number
|
||||
}
|
||||
|
||||
export const FORMATIONS: Record<FormationId, FormationDef> = {
|
||||
vanguard: {
|
||||
id: 'vanguard',
|
||||
name: '锋阵',
|
||||
icon: '锋',
|
||||
desc: '一往无前:攻势 +10%。',
|
||||
atk: 1.1,
|
||||
def: 1,
|
||||
retreatWound: 1
|
||||
},
|
||||
echelon: {
|
||||
id: 'echelon',
|
||||
name: '雁阵',
|
||||
icon: '雁',
|
||||
desc: '羽翼相援:均衡,御力 +5%。',
|
||||
atk: 1,
|
||||
def: 1.05,
|
||||
retreatWound: 0.85
|
||||
},
|
||||
serpent: {
|
||||
id: 'serpent',
|
||||
name: '蛇阵',
|
||||
icon: '蛇',
|
||||
desc: '盘而不乱:溃退伤损 -30%。',
|
||||
atk: 0.95,
|
||||
def: 1.05,
|
||||
retreatWound: 0.7
|
||||
}
|
||||
}
|
||||
|
||||
export function formationById(id: string | undefined): FormationDef {
|
||||
return FORMATIONS[(id as FormationId) ?? 'vanguard'] ?? FORMATIONS.vanguard
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { EnemyDef, LootDef } from '../../data/secrets'
|
||||
import { traitBonuses } from '../pcgen'
|
||||
import { npcById } from '../../data/npcs'
|
||||
import { aspirationById, fitBonusOf } from '../../data/aspirations'
|
||||
import { formationById, FormationId } from '../../data/formations'
|
||||
|
||||
export function combatPowerOf(w: World, c: Character): number {
|
||||
if (!c.alive) return 0
|
||||
@@ -68,13 +69,15 @@ export function resolveEncounter(
|
||||
kind: BattleLog['kind']
|
||||
year: number
|
||||
month: number
|
||||
formation?: FormationId
|
||||
}
|
||||
): EncounterResult {
|
||||
const form = formationById(opts.formation)
|
||||
let team = 0
|
||||
for (const c of opts.team) team += combatPowerOf(w, c)
|
||||
const enemy = enemyPowerOf(opts.enemy, opts.risk)
|
||||
const enemy = Math.round(enemyPowerOf(opts.enemy, opts.risk) * form.def)
|
||||
const jitter = w.rng.between(0.88, 1.12)
|
||||
const teamFinal = Math.round(team * jitter)
|
||||
const teamFinal = Math.round(team * form.atk * jitter)
|
||||
const roll = w.rng.next()
|
||||
const win = teamFinal >= enemy * 1.08
|
||||
const lose = teamFinal < enemy * 0.82
|
||||
@@ -85,7 +88,7 @@ export function resolveEncounter(
|
||||
const names = opts.team.map((c) => c.name).join('、')
|
||||
const lines: string[] = []
|
||||
lines.push(`—— ${opts.title} ——`)
|
||||
lines.push(`${year}年${month}月,${names}遇上了【${opts.enemy.name}】。(敌势 ${enemy},我阵 ${teamFinal})`)
|
||||
lines.push(`${year}年${month}月,${names}以「${form.name}」列阵,迎上了【${opts.enemy.name}】。(敌势 ${enemy},我阵 ${teamFinal})`)
|
||||
if (win) {
|
||||
lines.push(`首战告捷:${w.rng.pick(WIN_DESC)}`)
|
||||
} else if (lose) {
|
||||
@@ -105,7 +108,8 @@ export function resolveEncounter(
|
||||
loss.push(`${c.name} 陨落`)
|
||||
w.chronicle('death', `${c.name} 战殁于${opts.enemy.name},一身所学俱付尘烟。`, c.id, true)
|
||||
} else if (severity < 0.45) {
|
||||
c.health = Math.max(1, c.health - 40 - w.rng.int(0, 25))
|
||||
const woundAmt = Math.round((40 + w.rng.int(0, 25)) * form.retreatWound)
|
||||
c.health = Math.max(1, c.health - woundAmt)
|
||||
c.state = 'wounded'
|
||||
loss.push(`${c.name} 重伤`)
|
||||
}
|
||||
@@ -156,7 +160,8 @@ function itemName(id: string): string {
|
||||
export function resolveRaid(
|
||||
w: World,
|
||||
npcId: string,
|
||||
team: Character[]
|
||||
team: Character[],
|
||||
formation?: FormationId
|
||||
): EncounterResult {
|
||||
const npc = w.state.npcFamilies[npcId]
|
||||
const def = npcById(npcId)
|
||||
@@ -176,7 +181,8 @@ export function resolveRaid(
|
||||
team,
|
||||
kind: 'war',
|
||||
year: w.state.year,
|
||||
month: w.state.month
|
||||
month: w.state.month,
|
||||
formation
|
||||
})
|
||||
if (res.win) {
|
||||
npc.relation = Math.min(60, npc.relation + 25)
|
||||
|
||||
@@ -143,6 +143,20 @@ export function dynamicEventFor(id: string, w?: World): EventDef | undefined {
|
||||
]
|
||||
}
|
||||
}
|
||||
if (id === 'ev-winterprayer') {
|
||||
return {
|
||||
id,
|
||||
name: '冬至岁祷',
|
||||
category: 'daily',
|
||||
weight: 0,
|
||||
text: '冬至夜长。家祠前灯影摇曳,族长问:今岁如何告辞?',
|
||||
options: [
|
||||
{ label: '登坛观星', hint: '声望+3', eff: { rep: 3 } },
|
||||
{ label: '合族祈福', hint: '全族修为小精进', eff: { memberBy: { by: 'inspire', target: 'all', n: 2 } } },
|
||||
{ label: '投签问卜', hint: '或有吉凶', eff: { flag: { prayerAsk: true } } }
|
||||
]
|
||||
}
|
||||
}
|
||||
if (id === 'ev-recruit') {
|
||||
return {
|
||||
id,
|
||||
@@ -262,6 +276,17 @@ export function eventRoll(w: World): void {
|
||||
fire(w, `ev-tournament-${tourneyNext}`)
|
||||
return
|
||||
}
|
||||
// 冬至岁祷(每年十一月一掷),只在 season 系统开启时
|
||||
if (s.month === 11 && w.sysEnabled('season')) {
|
||||
const key = `prayerDone-${s.year}`
|
||||
if (!s.family.flag[key]) {
|
||||
s.family.flag[key] = true
|
||||
if (w.rng.chance(0.6)) {
|
||||
fire(w, 'ev-winterprayer')
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
// 四邻回声(偶数年一掷,不论成败封缄)
|
||||
if (s.year % 2 === 0) {
|
||||
const key = `echoDone-${s.year}`
|
||||
@@ -304,7 +329,14 @@ export function fire(w: World, id: string): void {
|
||||
w.pendingEvent(id)
|
||||
}
|
||||
|
||||
export function applyEventChoice(w: World, eventId: string, optionIdx: number, squad?: string[]): void {
|
||||
export function applyEventChoice(
|
||||
w: World,
|
||||
eventId: string,
|
||||
optionIdx: number,
|
||||
squad?: string[],
|
||||
_extra?: unknown,
|
||||
formation?: string
|
||||
): void {
|
||||
const s = w.state
|
||||
const def = findEvent(eventId, w)
|
||||
if (!def) {
|
||||
@@ -313,6 +345,7 @@ export function applyEventChoice(w: World, eventId: string, optionIdx: number, s
|
||||
}
|
||||
const opt = def.options[optionIdx]
|
||||
if (opt) {
|
||||
if (formation) opt.eff.formation = formation
|
||||
applyEffect(w, opt.eff, squad)
|
||||
if (def.once && !s.completedEvents.includes(def.id)) s.completedEvents.push(def.id)
|
||||
}
|
||||
@@ -404,7 +437,7 @@ export function applyEffect(w: World, eff: EffectDef, squad?: string[]): void {
|
||||
if (!fam.techniques.includes(eff.addTech)) fam.techniques.push(eff.addTech)
|
||||
}
|
||||
if (eff.tournament) {
|
||||
runTournament(w, squad)
|
||||
runTournament(w, squad, eff.formation as never)
|
||||
}
|
||||
if (eff.apprentice?.build) {
|
||||
buildApprentice(w)
|
||||
@@ -419,6 +452,18 @@ export function applyEffect(w: World, eff: EffectDef, squad?: string[]): void {
|
||||
finalizeApprentice(w, stayId, 'stay')
|
||||
delete fam.flag['apprenticeStay']
|
||||
}
|
||||
if (eff.flag?.['prayerAsk']) {
|
||||
const blessing = w.rng.chance(0.6)
|
||||
if (blessing) {
|
||||
w.state.family.stones += 60
|
||||
w.log('good', '问卜得吉:仓库中多出一笔异财。')
|
||||
} else {
|
||||
w.state.family.stones = Math.max(0, w.state.family.stones - 40)
|
||||
w.state.family.reputation -= 1
|
||||
w.log('bad', '问卜得凶:家宅小有晦气。')
|
||||
}
|
||||
delete w.state.family.flag['prayerAsk']
|
||||
}
|
||||
if (eff.trib) {
|
||||
const c = w.state.members[eff.trib.memberId]
|
||||
if (c?.alive) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { World } from '../world'
|
||||
import { MissionState } from '../../types/domain'
|
||||
import { FormationId } from '../../data/formations'
|
||||
import { missionById, MissionDef, ENEMIES } from '../../data/secrets'
|
||||
import { resolveEncounter, rollWarbooty } from './combat'
|
||||
import { techniqueById } from '../../data/techniques'
|
||||
@@ -46,7 +47,8 @@ export function missionTick(w: World): void {
|
||||
team: squad,
|
||||
kind: 'scout',
|
||||
year: w.state.year,
|
||||
month: w.state.month
|
||||
month: w.state.month,
|
||||
formation: m.formation as FormationId | undefined
|
||||
})
|
||||
const line = res.win ? '战而胜之,征程继续!' : res.draw ? '僵持之后双方罢手,队伍休整再进。' : '不敌,只得暂避锋芒。'
|
||||
m.log.push(line)
|
||||
@@ -117,7 +119,7 @@ export function canSendMission(w: World, def: MissionDef, members: string[]): bo
|
||||
return w.state.missions.filter((m) => !m.done).length < 3
|
||||
}
|
||||
|
||||
export function sendMission(w: World, defId: string, members: string[]): boolean {
|
||||
export function sendMission(w: World, defId: string, members: string[], formation?: FormationId): boolean {
|
||||
const def = missionById(defId)
|
||||
if (!canSendMission(w, def, members)) return false
|
||||
const m: MissionState = {
|
||||
@@ -129,7 +131,8 @@ export function sendMission(w: World, defId: string, members: string[]): boolean
|
||||
stage: 0,
|
||||
stageMonth: 0,
|
||||
log: [`冬衣已备,饯行酒干,众人于 ${w.state.year} 年 ${w.state.month} 月出发。`],
|
||||
done: false
|
||||
done: false,
|
||||
formation
|
||||
}
|
||||
members.forEach((id) => {
|
||||
const c = w.memberById(id)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ENEMIES, EnemyDef } from '../../data/secrets'
|
||||
import { resolveEncounter } from './combat'
|
||||
import { rankPower } from './events'
|
||||
import { resolveBreakthrough } from './cultivation'
|
||||
import { FormationId } from '../../data/formations'
|
||||
|
||||
export const SECTS = ['云栖宗', '太谷书院', '玄微剑阁', '丹霞洞天']
|
||||
|
||||
@@ -19,7 +20,7 @@ export function gateEnemy(idx: number, year: number): EnemyDef {
|
||||
}
|
||||
}
|
||||
|
||||
export function runTournament(w: World, squad?: string[]): void {
|
||||
export function runTournament(w: World, squad?: string[], formation?: FormationId): void {
|
||||
if (!w.sysEnabled('tournament')) {
|
||||
w.log('info', '太虚大比:赛事未启。')
|
||||
return
|
||||
@@ -52,7 +53,8 @@ export function runTournament(w: World, squad?: string[]): void {
|
||||
team: aliveTeam,
|
||||
kind: 'scout',
|
||||
year: s.year,
|
||||
month: s.month
|
||||
month: s.month,
|
||||
formation
|
||||
})
|
||||
if (!res.win) break
|
||||
wins++
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { GameState } from '../types/domain'
|
||||
import { normalizeGameState } from '../engine/world'
|
||||
|
||||
export const CURRENT_SCHEMA = 2
|
||||
export const APP_ID = 'cotyc'
|
||||
|
||||
export interface MigrationError {
|
||||
code: 'TOO_NEW' | 'UNKNOWN_VERSION'
|
||||
version: number
|
||||
message: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 迁移链:obj.from 升到 obj.to 逐级执行(任何一步失败即中断抛出,原状态不被写入)。
|
||||
* v1(≤0.1.8)→ v2(0.1.9):补 stats/finance/annals 字段并打上 schema 版本戳。
|
||||
*/
|
||||
const MIGRATIONS: Array<{ from: number; to: number; fn: (s: GameState) => GameState }> = [
|
||||
{
|
||||
from: 1,
|
||||
to: 2,
|
||||
fn: (s) => {
|
||||
normalizeGameState(s)
|
||||
s.schemaVersion = 2
|
||||
return s
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
export function migrateIfNeeded(state: GameState): { ok: true; state: GameState } | { ok: false; error: MigrationError } {
|
||||
const version = typeof state.schemaVersion === 'number' ? state.schemaVersion : 1
|
||||
if (version > CURRENT_SCHEMA) {
|
||||
return {
|
||||
ok: false,
|
||||
error: { code: 'TOO_NEW', version, message: `存档版本 ${version} 高于当前游戏支持(${CURRENT_SCHEMA}),请升级游戏。` }
|
||||
}
|
||||
}
|
||||
let cur = state
|
||||
let v = version
|
||||
let guard = 0
|
||||
while (v < CURRENT_SCHEMA && guard < 20) {
|
||||
const step = MIGRATIONS.find((m) => m.from === v)
|
||||
if (!step) {
|
||||
return { ok: false, error: { code: 'UNKNOWN_VERSION', version: v, message: `未知存档版本 ${v},无法迁移。` } }
|
||||
}
|
||||
try {
|
||||
cur = step.fn(cur)
|
||||
} catch (e) {
|
||||
return { ok: false, error: { code: 'UNKNOWN_VERSION', version: v, message: `迁移失败:${String(e)}` } }
|
||||
}
|
||||
v = step.to
|
||||
guard++
|
||||
}
|
||||
return { ok: true, state: cur }
|
||||
}
|
||||
|
||||
export function exportEnvelope(state: GameState): string {
|
||||
return JSON.stringify({ app: APP_ID, schemaVersion: state.schemaVersion ?? CURRENT_SCHEMA, payload: state })
|
||||
}
|
||||
|
||||
export function parseImportEnvelope(text: string): { ok: true; state: GameState } | { ok: false; error: string } {
|
||||
try {
|
||||
const raw = JSON.parse(text) as { app?: string; schemaVersion?: number; payload?: GameState; state?: GameState }
|
||||
if (raw.app !== undefined && raw.app !== APP_ID) return { ok: false, error: '非本作存档(app 标识不符)。' }
|
||||
const state = raw.payload ?? raw.state
|
||||
if (!state || typeof state !== 'object') return { ok: false, error: '存档内容为空或损坏。' }
|
||||
const migrated = migrateIfNeeded(state as GameState)
|
||||
if (migrated.ok) return { ok: true, state: migrated.state }
|
||||
return { ok: false, error: migrated.error.message }
|
||||
} catch (e) {
|
||||
return { ok: false, error: `解析失败:${String(e)}` }
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { GameState, SaveMeta, SnapshotMeta, Id } from '../types/domain'
|
||||
import { CURRENT_SCHEMA, APP_ID, exportEnvelope, parseImportEnvelope, migrateIfNeeded } from './migrate'
|
||||
|
||||
const DB_PREFIX = 'cotyc-save-'
|
||||
let seqCounter = 0
|
||||
@@ -40,6 +41,7 @@ export class SaveSlot {
|
||||
|
||||
async saveState(state: GameState, label: string): Promise<string> {
|
||||
const id = `${state.year}.${state.month}.${Date.now().toString(36)}-${(seqCounter++ % 1296).toString(36)}`
|
||||
state.schemaVersion = CURRENT_SCHEMA
|
||||
const json = JSON.stringify(state)
|
||||
await this.driver.run(
|
||||
`INSERT INTO snapshot (id, year, month, savedAt, label, data) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
@@ -112,19 +114,23 @@ export class SaveSlot {
|
||||
|
||||
async exportAll(): Promise<string> {
|
||||
const state = await this.loadState()
|
||||
if (!state) throw new Error('无可导出存档')
|
||||
const meta = await this.getMeta()
|
||||
return JSON.stringify({ app: 'cotyc', schemaVersion: 1, meta, state })
|
||||
const envelope = JSON.parse(exportEnvelope(state)) as { schemaVersion: number; payload: GameState }
|
||||
return JSON.stringify({ app: APP_ID, schemaVersion: envelope.schemaVersion, meta, state: envelope.payload })
|
||||
}
|
||||
|
||||
async importAll(data: string): Promise<boolean> {
|
||||
try {
|
||||
const parsed = JSON.parse(data) as { app?: string; schemaVersion?: number; state?: GameState }
|
||||
if (!parsed.state) return false
|
||||
const parsed = parseImportEnvelope(data)
|
||||
if (!parsed.ok) return false
|
||||
const state = parsed.state
|
||||
state.schemaVersion = CURRENT_SCHEMA
|
||||
await this.driver.exec(`DELETE FROM snapshot`)
|
||||
await this.driver.exec(`DELETE FROM meta`)
|
||||
await this.saveState(parsed.state, 'import')
|
||||
const aliveCount = Object.values(parsed.state.members).filter((c) => c.alive).length
|
||||
await this.setMeta(buildSimpleMeta(parsed.state, this.slot, aliveCount))
|
||||
await this.saveState(state, 'import')
|
||||
const aliveCount = Object.values(state.members).filter((c) => c.alive).length
|
||||
await this.setMeta(buildSimpleMeta(state, this.slot, aliveCount))
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
|
||||
@@ -100,6 +100,7 @@ export interface MissionState {
|
||||
log: string[]
|
||||
done: boolean
|
||||
result?: string
|
||||
formation?: string
|
||||
}
|
||||
|
||||
export interface ChronicleEntry {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { eventCategoryName } from '../../game/data/events'
|
||||
import { describeRealm } from '../../game/data/realms'
|
||||
import { Character } from '../../game/types/domain'
|
||||
import { combatPowerOf } from '../../game/engine/systems/combat'
|
||||
import { FORMATIONS, FormationId } from '../../game/data/formations'
|
||||
|
||||
export function EventModal() {
|
||||
const pendingEventId = useGameStore((s) => s.pendingEventId)
|
||||
@@ -14,6 +15,7 @@ export function EventModal() {
|
||||
const setSpeed = useGameStore((s) => s.setSpeed)
|
||||
const [squadPicking, setSquadPicking] = useState(false)
|
||||
const [squadSel, setSquadSel] = useState<string[]>([])
|
||||
const [formation, setFormation] = useState<FormationId>('vanguard')
|
||||
if (!pendingEventId || !pendingEventDef || !world) return null
|
||||
|
||||
const raidIdx = pendingEventDef.options.findIndex((o) => o.eff.raid || o.eff.tournament)
|
||||
@@ -62,7 +64,20 @@ export function EventModal() {
|
||||
|
||||
{squadPicking && (
|
||||
<div className="modal-squad" style={{ marginBottom: 14 }}>
|
||||
<div className="dim" style={{ marginBottom: 6 }}>迎战点将(默认四人,可改动)</div>
|
||||
<div className="dim" style={{ marginBottom: 6 }}>迎战·列阵(默认四人,可改动)</div>
|
||||
<div className="formation-pick" style={{ marginBottom: 8 }}>
|
||||
{(Object.keys(FORMATIONS) as FormationId[]).map((fid) => (
|
||||
<div
|
||||
key={fid}
|
||||
className={`tag ${formation === fid ? 'gold-t' : ''}`}
|
||||
style={{ cursor: 'pointer', padding: '3px 10px' }}
|
||||
onClick={() => setFormation(fid)}
|
||||
title={FORMATIONS[fid].desc}
|
||||
>
|
||||
{FORMATIONS[fid].name}·{FORMATIONS[fid].desc}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="squad-picker">
|
||||
{squadPool.map((c) => (
|
||||
<div
|
||||
|
||||
@@ -4,6 +4,7 @@ import { sendMission, recallAll } from '../../game/engine/systems/missions'
|
||||
import { combatPowerOf } from '../../game/engine/systems/combat'
|
||||
import { useState } from 'react'
|
||||
import { describeRealm, MAJOR_ORDER } from '../../game/data/realms'
|
||||
import { FORMATIONS, FormationId } from '../../game/data/formations'
|
||||
|
||||
export default function ExpeditionPanel() {
|
||||
const world = useGameStore((s) => s.world)
|
||||
@@ -13,6 +14,7 @@ export default function ExpeditionPanel() {
|
||||
const revision = useGameStore((s) => s.revision)
|
||||
void revision
|
||||
const [squad, setSquad] = useState<string[]>([])
|
||||
const [formation, setFormation] = useState<FormationId>('vanguard')
|
||||
if (!world) return null
|
||||
const w = world
|
||||
const s = w.state
|
||||
@@ -78,11 +80,24 @@ export default function ExpeditionPanel() {
|
||||
))}
|
||||
{candidates.length === 0 && <span className="dim2">无可派遣之人</span>}
|
||||
</div>
|
||||
<div className="formation-pick" style={{ marginBottom: 8 }}>
|
||||
{(Object.keys(FORMATIONS) as FormationId[]).map((fid) => (
|
||||
<div
|
||||
key={fid}
|
||||
className={`tag ${formation === fid ? 'gold-t' : ''}`}
|
||||
style={{ cursor: 'pointer', padding: '3px 10px' }}
|
||||
onClick={() => setFormation(fid)}
|
||||
title={FORMATIONS[fid].desc}
|
||||
>
|
||||
{FORMATIONS[fid].name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
disabled={squad.length < def.minMembers || squad.length > def.maxMembers || active.length >= 3}
|
||||
onClick={() => {
|
||||
sendMission(w, def.id, squad)
|
||||
sendMission(w, def.id, squad, formation)
|
||||
setSquad([])
|
||||
bump()
|
||||
}}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { resolveLegacy as doesIt } from '../../game/core/legacy'
|
||||
import { ResolveModal } from '../components/ResolveModal'
|
||||
import { buildBiography } from '../../game/core/biography'
|
||||
import { yearAxis, decadeLabel } from '../../game/core/yearaxis'
|
||||
import { buildReport, verdictLine } from '../../game/core/legacyreport'
|
||||
|
||||
void doesIt
|
||||
|
||||
@@ -13,7 +14,7 @@ export default function LegacyPanel() {
|
||||
const revision = useGameStore((s) => s.revision)
|
||||
void revision
|
||||
const [showResolve, setShowResolve] = useState(false)
|
||||
const [view, setView] = useState<'dims' | 'chronicle' | 'report' | 'scroll' | 'axis'>('dims')
|
||||
const [view, setView] = useState<'dims' | 'chronicle' | 'report' | 'scroll' | 'axis' | 'legacy'>('dims')
|
||||
if (!world) return null
|
||||
const w = world
|
||||
const s = w.state
|
||||
@@ -56,6 +57,7 @@ export default function LegacyPanel() {
|
||||
<button className="btn" onClick={() => setView('chronicle')}>年表</button>
|
||||
<button className="btn" onClick={() => setView('scroll')}>长卷</button>
|
||||
<button className="btn" onClick={() => setView('axis')}>年轴</button>
|
||||
<button className="btn" onClick={() => setView('legacy')}>百年报告</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -112,6 +114,8 @@ export default function LegacyPanel() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === 'legacy' && <LegacyReportView />}
|
||||
|
||||
{view === 'axis' && <AxisView />}
|
||||
|
||||
{view === 'scroll' && <ScrollScroll />}
|
||||
@@ -137,6 +141,48 @@ export default function LegacyPanel() {
|
||||
)
|
||||
}
|
||||
|
||||
function LegacyReportView() {
|
||||
const world = useGameStore((s) => s.world)
|
||||
if (!world) return null
|
||||
const r = buildReport(world.state)
|
||||
const span = r.years.length
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-title">百年族史报告</div>
|
||||
{span < 4 ? (
|
||||
<div className="dim">开卷尚浅,待岁月沉淀后再启此卷。</div>
|
||||
) : (
|
||||
<>
|
||||
<Sparklines r={r} />
|
||||
<div className="dim" style={{ marginTop: 10 }}>死因榜:{Object.entries(r.deathCauses).filter(([k]) => k !== '').sort((a, b) => b[1] - a[1]).slice(0, 4).map(([k, v]) => `${k}×${v}`).join(' · ') || '尚无记录'}</div>
|
||||
<div className="dim" style={{ marginTop: 6 }}>突破之劲:{r.breakthroughs.slice(-6).map((b) => `${b.year}年×${b.count}`).join(' · ')}</div>
|
||||
<div className="resolve-poem" style={{ marginTop: 14 }}>{verdictLine(r)}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Sparklines({ r }: { r: ReturnType<typeof buildReport> }) {
|
||||
const width = 640
|
||||
const height = 150
|
||||
const pts = (seq: number[], color: string) => {
|
||||
const max = Math.max(...seq, 1)
|
||||
const step = seq.length > 1 ? width / (seq.length - 1) : width
|
||||
return seq.map((v, i) => `${(i * step).toFixed(1)},${(height - 20 - (v / max) * (height - 40)).toFixed(1)}`).join(' ')
|
||||
}
|
||||
return (
|
||||
<svg width="100%" viewBox={`0 0 ${width} ${height}`} style={{ maxHeight: 160, background: 'rgba(20,15,9,0.5)', borderRadius: 4 }}>
|
||||
<polyline fill="none" stroke="#8fae6a" strokeWidth="2" points={pts(r.population, 'green')} />
|
||||
<polyline fill="none" stroke="#c9a227" strokeWidth="2" points={pts(r.reputation, 'gold')} />
|
||||
<polyline fill="none" stroke="#a9a9a9" strokeWidth="2" points={pts(r.power, 'gray')} />
|
||||
<text x="4" y="14" fill="#8fae6a" fontSize="10">人口</text>
|
||||
<text x="64" y="14" fill="#c9a227" fontSize="10">声望</text>
|
||||
<text x="124" y="14" fill="#aaa" fontSize="10">战力</text>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function AxisView() {
|
||||
const world = useGameStore((s) => s.world)
|
||||
if (!world) return null
|
||||
|
||||
@@ -1498,3 +1498,10 @@ body {
|
||||
.monument-strip .card-title {
|
||||
color: #cdaa63;
|
||||
}
|
||||
|
||||
/* ---------- 阵型点选 ---------- */
|
||||
.formation-pick {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user